Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0c9ecca97 | ||
|
|
d022d1c02f | ||
|
|
3476a489e2 | ||
|
|
ce0352ec61 | ||
|
|
d9c061bfb8 | ||
|
|
abbfc56a7a | ||
|
|
d81f080077 | ||
|
|
fdc41b7b69 | ||
|
|
0ec05835ff | ||
|
|
b50c982f86 | ||
|
|
53e1af5196 | ||
|
|
c474cd64ed | ||
|
|
9f34f0e5c3 | ||
|
|
1c6c95955e | ||
|
|
5dbde04c6f | ||
|
|
6d3a964ebf | ||
|
|
0e63657157 | ||
|
|
0023ef98b5 | ||
|
|
32ed02d153 | ||
|
|
5cf3deb006 | ||
|
|
de6b36e3d3 | ||
|
|
fc740987cd | ||
|
|
77cdebec0f | ||
|
|
0880d87f31 | ||
|
|
5f75998512 | ||
|
|
20d6888449 | ||
|
|
42eff68a29 | ||
|
|
6d2d2f19a5 | ||
|
|
bba7e05b46 | ||
|
|
cccda31525 | ||
|
|
55e5ab747a | ||
|
|
95d39ca1b8 | ||
|
|
ce8d478ecd | ||
|
|
c474e66106 | ||
|
|
cc9db2ea05 | ||
|
|
ed0b4666bc | ||
|
|
0504d284e2 | ||
|
|
30473f886d | ||
|
|
4187a1a519 | ||
|
|
e42831cfa9 | ||
|
|
c4fb9aefb7 | ||
|
|
9ba965ae11 | ||
|
|
b1d3b61339 | ||
|
|
0fc7503c08 |
No files matched your search
@@ -55,3 +55,6 @@ next-env.d.ts
|
||||
|
||||
# clerk configuration (can include secrets)
|
||||
/.clerk/
|
||||
|
||||
# local marketing plans (not part of the app)
|
||||
/marketing/
|
||||
@@ -50,7 +50,7 @@ The client-side "which pet is currently active" state lives in `src/context/Acti
|
||||
|
||||
### Denormalized engagement counters
|
||||
|
||||
`Post.reactionCount/commentCount/repostCount` and `Advertisement.adReactionCount/adCommentCount/adRepostCount` are maintained transactionally at every create/delete site (reactions, comments, reposts, ads routers, and `admin.ts` moderation deletes) instead of computed via live `_count`/`count()` on read. Feed/explore/posts/admin reads use these columns directly. If you add a new mutation that creates or deletes a reaction/comment/repost, you must increment/decrement the matching counter in the same transaction — there is no background reconciliation job.
|
||||
`Post.reactionCount/commentCount/repostCount` and `Advertisement.adReactionCount/adCommentCount/adRepostCount` are maintained transactionally at every create/delete site (reactions, comments, reposts, ads routers, and the admin moderation deletes in `admin-procedures/`) instead of computed via live `_count`/`count()` on read. Feed/explore/posts/admin reads use these columns directly. If you add a new mutation that creates or deletes a reaction/comment/repost, you must increment/decrement the matching counter in the same transaction — there is no background reconciliation job.
|
||||
|
||||
### Routing structure
|
||||
|
||||
@@ -84,13 +84,15 @@ 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,pets,moderators,posts,reports,ads,referrals,curator,…}`. The dashboard has a collapsible "Tierarten" card (`SpeciesDistributionCard`, `petAdmin.speciesDistribution`: pets per species incl. empty species and per breed, curator pet excluded). The "Tiere" page (`pets`, router `pet-admin.ts`) manages single pets: verified/founding toggles, clearing profile content, deleting a pet (reason is sent to the owner as a team DM on another of their pets, or by e-mail if it was their only pet) and the owner's referral ranks.
|
||||
Gated by path secret (see `proxy.ts` above) *and* by role at the tRPC layer: `assertAdmin()` (`src/trpc/routers/admin-procedures/guards.ts`, re-exported from `admin.ts`) allows either `ADMIN_OWNER_ID` (env-configured super-admin bootstrap) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Actions are written to `ModerationLog` via `modLog()` (`src/lib/admin/mod-log.ts`). `trpc.admin.*` is assembled in `src/trpc/routers/admin.ts` from per-area procedure objects in `src/trpc/routers/admin-procedures/` (posts, users, reports, ads, …) — add new admin procedures there, not in `admin.ts`. Every admin page reloads its visible data after any successful mutation (`AdminAutoRefresh` in the admin layout), so admin pages need no manual `invalidateQueries`. Pages live under `src/app/p/[secret]/{users,pets,moderators,posts,reports,ads,referrals,curator,…}`. The dashboard has a collapsible "Tierarten" card (`SpeciesDistributionCard`, `petAdmin.speciesDistribution`: pets per species incl. empty species and per breed, curator pet excluded). The "Tiere" page (`pets`, router `pet-admin.ts`) manages single pets: verified/founding toggles, clearing profile content, deleting a pet (reason is sent to the owner as a team DM on another of their pets, or by e-mail if it was their only pet) and the owner's referral ranks.
|
||||
|
||||
**Report statistics (`ReportDecision`, `src/lib/report-decisions.ts`):** every moderator decision on a report is stored as an append-only row (UPHELD/DISMISSED) before the `Report` itself is deleted — shown on the users page, the pet detail sheet and the "Auswertung" tab of `/p/[secret]/reports`. A new path that resolves reports by moderator decision must run `reportDecisionOps()` in the same transaction (current: `dismissReport`, `upholdReport`, `deletePostAsModerator` in `admin-procedures/`, `petAdmin.delete`). Rows have no FKs: `deletePetCompletely` nulls the pet's ids, `deleteOwnerAccount` anonymizes the owner's ids — never delete the rows, other owners' counts depend on them.
|
||||
|
||||
**Never delete a pet with a bare `prisma.pet.delete`** — use `deletePetCompletely()` (`src/lib/delete-pet.ts`). `StoryView` (D-08), `Report.reporter` and `Repost.originalPost` have no cascade and would block the delete, and the denormalized counters on *other* pets' posts/stories must drop by this pet's paws/comments/paw-backs. `pets.delete`, `deleteOwnerAccount` and the admin delete all go through it.
|
||||
|
||||
### Download redirect `/app`
|
||||
|
||||
`src/app/app/route.ts` (public in `proxy.ts`) is the target for flyers/QR codes: Android → Play Store with sanitized `utm_*` passed as Play `referrer`, iOS → `APP_STORE_URL` in `src/lib/app-links.ts` once set (still `null` until the iOS release; until then the web start page), everything else → web start page; `?ref=<code>` → `/r/<code>`. Logic is pure in `src/lib/app-redirect.ts`; clicks are counted per day/source/platform in Redis (`appredirect:<date>` hashes, `src/lib/app-redirect-stats.ts`) and shown in the admin referral overview. Redis on the NAS is the `pawfeed-redis` container (not `glitchtip-redis`), password in `docker/.env` `REDIS_PASSWORD`.
|
||||
`src/app/app/route.ts` (public in `proxy.ts`) is the target for flyers/QR codes: Android → Play Store with sanitized `utm_*` passed as Play `referrer`, iOS → `APP_STORE_URL` in `src/lib/app-links.ts` (App Store, live since 2026-10-08), everything else → web start page; `?ref=<code>` → `/r/<code>`. Logic is pure in `src/lib/app-redirect.ts`; clicks are counted per day/source/platform in Redis (`appredirect:<date>` hashes, `src/lib/app-redirect-stats.ts`) and shown in the admin referral overview. Redis on the NAS is the `pawfeed-redis` container (not `glitchtip-redis`), password in `docker/.env` `REDIS_PASSWORD`.
|
||||
|
||||
### New-pet welcome & founding members
|
||||
|
||||
@@ -114,7 +116,7 @@ Despite `next.config.ts`'s `output: "standalone"` and the original stack researc
|
||||
|
||||
**Runs as 3 load-balanced replicas, not a single container (since 2026-08-16, Gitea #26).** `docker/docker-compose.yml` defines `pawfeed-1`/`pawfeed-2`/`pawfeed-3` (via the `x-pawfeed-common` YAML anchor) instead of one `pawfeed` service — Next.js standalone output is single-process (`node server.js`), so one container only ever used one CPU core regardless of how many the NAS has. A load test on 2026-08-15 hit a hard ceiling at ~60–65 req/sec with one core pegged at 133% while 3 cores sat idle; splitting into 3 replicas fixed it (confirmed via a follow-up load test showing ~100% CPU spread evenly across all three). Nginx Proxy Manager (container `nginx-app-1` on this NAS, admin UI at `192.168.1.222:81`) load-balances across them via a `least_conn` upstream block — that block lives in `/Docker-Data/nginxproxymanager/data/nginx/custom/http_top.conf` on the NAS host (an nginx `upstream {}` is only valid in the `http{}` context, which NPM's per-host "Custom Nginx Configuration" field can't reach — that field only writes into the `server{}`/`location{}` context, and holds the `proxy_pass http://pawfeed_upstream;` override for the `pawfeed.org` Proxy Host instead). Full runbook, decisions, and risk notes: `docker/SCALING-ROADMAP.md`. **Deploying a config or code change means rebuilding all 3 replicas** — there is no separate single-container path anymore, and NPM's proxy target is `pawfeed_upstream`, not a bare `pawfeed:3000` hostname. Use `./docker/rolling-deploy.sh` for deploys against the live site — it rebuilds the shared `pawfeed:latest` image once, then restarts `pawfeed-1`/`2`/`3` one at a time, gated on each becoming `healthy` per the Docker `healthcheck` (`GET /api/health`, a dependency-free liveness check — see `src/app/api/health/route.ts`) before moving to the next, so at least 2 replicas always serve traffic. Verified zero-downtime on 2026-08-16 (continuous curl monitoring showed unbroken HTTP 200 through a full rolling deploy). `docker compose up -d --build` (`start.sh`) still works but restarts all 3 at once — only use it for first-time setup or when a brief outage is acceptable. `src/lib/prisma.ts`'s Prisma pg pool size is capped per-replica via `DB_POOL_MAX` (compose sets `10`) so 3 replicas stay well under the Supabase pooler's client-connection limit.
|
||||
|
||||
**Cron jobs run from the NAS crontab** (user `daniel`, `crontab -l`), not from `vercel.json` (whose `crons` array is inert here). Each entry calls `docker/run-cron.sh /api/cron/<name>`, which reads `CRON_SECRET` from `docker/.env`, hits the route with the bearer token via the DDNS host (`--resolve` to localhost — plain `pawfeed.org` fails TLS from the NAS) and logs every run with status + body to `docker/cron.log`. Current schedule: `trim-feeds` 04:00 (Redis feeds → 90 days), `cleanup-stories` 04:30 (deletes stories older than 7 days incl. views, paws, comments, Supabase image and Mux asset — `src/lib/story-cleanup.ts`, max 200 per run), `anniversaries` 08:00, `vaccine-reminders` 08:15 (one-time reminder + push 30 days before `Vaccine.nextDueDate`, `src/lib/vaccine-reminders.ts`, guarded by `Vaccine.reminderSentAt`), `pet-suggestions` Mondays 09:00, plus `docker/backup-db.sh` 03:00. A new cron route needs a new crontab line on the NAS — it does not run otherwise.
|
||||
**Cron jobs run from the NAS crontab** (user `daniel`, `crontab -l`), (there is no `vercel.json` — it was removed as inert). Every cron route checks the bearer token via `isCronAuthorized()` (`src/lib/timing-safe-compare.ts`). Each entry calls `docker/run-cron.sh /api/cron/<name>`, which reads `CRON_SECRET` from `docker/.env`, hits the route with the bearer token via the DDNS host (`--resolve` to localhost — plain `pawfeed.org` fails TLS from the NAS) and logs every run with status + body to `docker/cron.log`. Current schedule: `trim-feeds` 04:00 (Redis feeds → 90 days), `cleanup-stories` 04:30 (deletes stories older than 7 days incl. views, paws, comments, Supabase image and Mux asset — `src/lib/story-cleanup.ts`, max 200 per run), `anniversaries` 08:00, `vaccine-reminders` 08:15 (one-time reminder + push 30 days before `Vaccine.nextDueDate`, `src/lib/vaccine-reminders.ts`, guarded by `Vaccine.reminderSentAt`), `pet-suggestions` Mondays 09:00, plus `docker/backup-db.sh` 03:00. A new cron route needs a new crontab line on the NAS — it does not run otherwise.
|
||||
|
||||
### Directories that are not app code
|
||||
|
||||
|
||||
@@ -310,7 +310,7 @@ In Mux Dashboard -> Settings -> Webhooks, add an endpoint pointing to `https://y
|
||||
|
||||
### 6. Cron jobs (production only)
|
||||
|
||||
There is no serverless cron here — `vercel.json`'s `crons` array is inert on a self-hosted deployment. Trigger the cron routes externally (e.g. a NAS-side crontab hitting them with a bearer token):
|
||||
There is no serverless cron here (and no `vercel.json`). Trigger the cron routes externally (e.g. a NAS-side crontab hitting them with a bearer token):
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/anniversaries # daily 08:00
|
||||
|
||||
@@ -126,7 +126,11 @@
|
||||
"addPhotoDesc": "Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.",
|
||||
"done": "Fertig",
|
||||
"skipForNow": "Jetzt überspringen",
|
||||
"loading": "Wird geladen"
|
||||
"loading": "Wird geladen",
|
||||
"caretakerSwitch": "Ich betreue ein Tier mit",
|
||||
"caretakerTitle": "Ein Tier mitbetreuen",
|
||||
"caretakerDesc": "Gib den Einladungscode ein, den du vom Haupt-Besitzer bekommen hast. Sobald er deine Anfrage freigibt, kannst du loslegen.",
|
||||
"caretakerBack": "Doch ein eigenes Tier anlegen"
|
||||
},
|
||||
"Language": {
|
||||
"switch": "Sprache",
|
||||
@@ -490,7 +494,8 @@
|
||||
"typeUnknown": "hat eine neue Mitteilung für dich.",
|
||||
"typePetDeceased": "ist über die Regenbogenbrücke gegangen. In liebevoller Erinnerung 🤍",
|
||||
"typePetLost": "wird vermisst. Vielleicht kannst du helfen – tippe für alle Infos.",
|
||||
"typePetFound": "ist wieder sicher zu Hause. Danke fürs Mithelfen! 🎉"
|
||||
"typePetFound": "ist wieder sicher zu Hause. Danke fürs Mithelfen! 🎉",
|
||||
"typeCaretakerRequest": "Jemand möchte dein Tier mitbetreuen – bitte prüfe die Anfrage."
|
||||
},
|
||||
"PhotoPostForm": {
|
||||
"captionPlaceholder": "Bildunterschrift schreiben…",
|
||||
@@ -646,7 +651,8 @@
|
||||
"title": "Meine Tiere",
|
||||
"addPet": "Tier anlegen",
|
||||
"emptyTitle": "Füge dein erstes Tier hinzu",
|
||||
"emptyDesc": "Gib deinem Tier ein eigenes Profil auf PawFeed."
|
||||
"emptyDesc": "Gib deinem Tier ein eigenes Profil auf PawFeed.",
|
||||
"caretakerHeading": "Ich betreue ein Tier mit"
|
||||
},
|
||||
"PetProfile": {
|
||||
"editProfile": "Profil bearbeiten",
|
||||
@@ -755,6 +761,7 @@
|
||||
"footerAgb": "AGB",
|
||||
"navHome": "Home",
|
||||
"navAbout": "Über uns",
|
||||
"footerPress": "Presse",
|
||||
"navLogin": "Anmelden",
|
||||
"navSignUp": "Registrieren",
|
||||
"heroBadge1": "Neuer Beitrag von Bubi",
|
||||
@@ -769,10 +776,11 @@
|
||||
"footerTagline": "Wo Haustiere die Stars sind.",
|
||||
"footerDesignedBy": "Designed & Powered by PawFeed",
|
||||
"footerSecurityBadge": "Sicherheits-Check: Note A+",
|
||||
"heroAndroidHint": "Auch als App für Android",
|
||||
"heroAndroidHint": "Auch als App für iPhone und Android",
|
||||
"playStoreAlt": "Jetzt bei Google Play",
|
||||
"appStoreAlt": "Laden im App Store",
|
||||
"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."
|
||||
"appBody": "Die App für iPhone und Android 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",
|
||||
@@ -916,7 +924,7 @@
|
||||
"lastSeenPlacePlaceholder": "z. B. Stadtpark Nord, Nähe Spielplatz",
|
||||
"description": "Erkennungsmerkmale & Hinweise",
|
||||
"descriptionPlaceholder": "z. B. rotes Halsband, gechippt, eher scheu – bitte nicht rufen",
|
||||
"contactHint": "Kontaktdaten sind freiwillig und erscheinen nur auf der Teilen-Seite dieser Meldung. Eine Nachricht über PawFeed ist immer möglich.",
|
||||
"contactHint": "Kontaktdaten sind freiwillig – bitte nur deine eigenen eintragen. Sie erscheinen nur auf der Teilen-Seite dieser Meldung und werden gelöscht, sobald die Meldung beendet ist. Eine Nachricht über PawFeed ist immer möglich.",
|
||||
"contactPhone": "Telefonnummer (optional)",
|
||||
"contactEmail": "E-Mail (optional)",
|
||||
"allowIndexing": "Seite darf über Suchmaschinen gefunden werden",
|
||||
@@ -936,5 +944,40 @@
|
||||
"bannerTitle": "{name} wird vermisst",
|
||||
"bannerText": "Zuletzt gesehen: {place} · {date}",
|
||||
"bannerLink": "Alle Infos & Seite teilen"
|
||||
},
|
||||
"Caretaker": {
|
||||
"heading": "Mitbetreuer",
|
||||
"intro": "Kümmert sich noch jemand um {name}? Lade eine zweite Person ein – sie nutzt ihr eigenes Konto und kann als {name} posten, Nachrichten beantworten und die Gesundheitsdaten sehen.",
|
||||
"createInvite": "Mitbetreuer einladen",
|
||||
"inviteHint": "Gib diesen Code an die Person weiter. Sie gibt ihn in der App unter „Meine Tiere“ oder bei der Anmeldung ein. Danach entscheidest du, ob du die Anfrage freigibst.",
|
||||
"copy": "Code kopieren",
|
||||
"copyLink": "Link kopieren",
|
||||
"copied": "Kopiert",
|
||||
"validUntil": "Gültig bis {date}",
|
||||
"newCode": "Neuen Code erzeugen",
|
||||
"revokeInvite": "Einladung zurückziehen",
|
||||
"requestFrom": "Anfrage zur Mitbetreuung von {who}",
|
||||
"requestHint": "Gibst du frei, kann diese Person als {name} handeln. Löschen, Privatsphäre und Gedenkmodus bleiben bei dir.",
|
||||
"approve": "Freigeben",
|
||||
"reject": "Ablehnen",
|
||||
"approved": "Freigegeben – ihr kümmert euch jetzt gemeinsam.",
|
||||
"activeWith": "{who} betreut {name} mit dir.",
|
||||
"remove": "Mitbetreuer entfernen",
|
||||
"removeConfirm": "Soll die Person {name} nicht mehr mitbetreuen? Ihre bisherigen Beiträge als {name} bleiben erhalten.",
|
||||
"youCoCare": "Du betreust {name} mit.",
|
||||
"caretakerLimits": "Löschen, Privatsphäre und Gedenkmodus verwaltet der Haupt-Besitzer.",
|
||||
"leave": "Mitbetreuung beenden",
|
||||
"leaveConfirm": "Möchtest du {name} nicht mehr mitbetreuen? Du kannst später wieder eingeladen werden.",
|
||||
"left": "Du betreust {name} nicht mehr mit.",
|
||||
"cancel": "Abbrechen",
|
||||
"unknownAccount": "einem anderen Konto",
|
||||
"failed": "Das hat leider nicht geklappt.",
|
||||
"codeLabel": "Einladungscode",
|
||||
"codeHint": "Den Code bekommst du vom Haupt-Besitzer des Tiers. Er ist 7 Tage gültig.",
|
||||
"redeem": "Einlösen",
|
||||
"requestSent": "Anfrage gesendet – sobald sie freigegeben ist, findest du {name} unter „Meine Tiere“.",
|
||||
"waiting": "Deine Anfrage für {name} wartet auf Freigabe.",
|
||||
"checkAgain": "Erneut prüfen",
|
||||
"withdraw": "Zurückziehen"
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,11 @@
|
||||
"addPhotoDesc": "Give your pet a face on PawFeed. You can always change this later.",
|
||||
"done": "Done",
|
||||
"skipForNow": "Skip for now",
|
||||
"loading": "Loading"
|
||||
"loading": "Loading",
|
||||
"caretakerSwitch": "I co-care for a pet",
|
||||
"caretakerTitle": "Co-care for a pet",
|
||||
"caretakerDesc": "Enter the invitation code you got from the main owner. As soon as they approve your request, you're all set.",
|
||||
"caretakerBack": "Create my own pet instead"
|
||||
},
|
||||
"Language": {
|
||||
"switch": "Language",
|
||||
@@ -490,7 +494,8 @@
|
||||
"typeUnknown": "has a new notification for you.",
|
||||
"typePetDeceased": "has crossed the rainbow bridge. In loving memory 🤍",
|
||||
"typePetLost": "is missing. Maybe you can help – tap for all details.",
|
||||
"typePetFound": "is safely back home. Thanks for helping! 🎉"
|
||||
"typePetFound": "is safely back home. Thanks for helping! 🎉",
|
||||
"typeCaretakerRequest": "Someone would like to co-care for your pet – please review the request."
|
||||
},
|
||||
"PhotoPostForm": {
|
||||
"captionPlaceholder": "Write a caption…",
|
||||
@@ -646,7 +651,8 @@
|
||||
"title": "My Pets",
|
||||
"addPet": "Add Pet",
|
||||
"emptyTitle": "Add your first pet",
|
||||
"emptyDesc": "Give your pet their own profile on PawFeed."
|
||||
"emptyDesc": "Give your pet their own profile on PawFeed.",
|
||||
"caretakerHeading": "I co-care for a pet"
|
||||
},
|
||||
"PetProfile": {
|
||||
"editProfile": "Edit profile",
|
||||
@@ -755,6 +761,7 @@
|
||||
"footerAgb": "Terms",
|
||||
"navHome": "Home",
|
||||
"navAbout": "About",
|
||||
"footerPress": "Press",
|
||||
"navLogin": "Log in",
|
||||
"navSignUp": "Sign up",
|
||||
"heroBadge1": "New post from Bubi",
|
||||
@@ -769,10 +776,11 @@
|
||||
"footerTagline": "Where pets are the stars.",
|
||||
"footerDesignedBy": "Designed & Powered by PawFeed",
|
||||
"footerSecurityBadge": "Security check: Grade A+",
|
||||
"heroAndroidHint": "Also available as an Android app",
|
||||
"heroAndroidHint": "Also available as an app for iPhone and Android",
|
||||
"playStoreAlt": "Get it on Google Play",
|
||||
"appStoreAlt": "Download on the App Store",
|
||||
"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."
|
||||
"appBody": "The iPhone and 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",
|
||||
@@ -916,7 +924,7 @@
|
||||
"lastSeenPlacePlaceholder": "e.g. North City Park, near the playground",
|
||||
"description": "Distinguishing features & notes",
|
||||
"descriptionPlaceholder": "e.g. red collar, microchipped, rather shy – please don't call out",
|
||||
"contactHint": "Contact details are optional and only appear on this report's share page. A message via PawFeed is always possible.",
|
||||
"contactHint": "Contact details are optional – please only enter your own. They only appear on this report's share page and are deleted once the report ends. A message via PawFeed is always possible.",
|
||||
"contactPhone": "Phone number (optional)",
|
||||
"contactEmail": "Email (optional)",
|
||||
"allowIndexing": "Allow search engines to find this page",
|
||||
@@ -936,5 +944,40 @@
|
||||
"bannerTitle": "{name} is missing",
|
||||
"bannerText": "Last seen: {place} · {date}",
|
||||
"bannerLink": "All details & share page"
|
||||
},
|
||||
"Caretaker": {
|
||||
"heading": "Co-caretaker",
|
||||
"intro": "Does someone else look after {name} too? Invite a second person – they use their own account and can post as {name}, answer messages and see the health records.",
|
||||
"createInvite": "Invite a co-caretaker",
|
||||
"inviteHint": "Share this code with that person. They enter it under “My pets” or during sign-up. You then decide whether to approve the request.",
|
||||
"copy": "Copy code",
|
||||
"copyLink": "Copy link",
|
||||
"copied": "Copied",
|
||||
"validUntil": "Valid until {date}",
|
||||
"newCode": "Create new code",
|
||||
"revokeInvite": "Withdraw invitation",
|
||||
"requestFrom": "Co-care request from {who}",
|
||||
"requestHint": "If you approve, this person can act as {name}. Deleting, privacy and memorial mode stay with you.",
|
||||
"approve": "Approve",
|
||||
"reject": "Decline",
|
||||
"approved": "Approved – you now care for this pet together.",
|
||||
"activeWith": "{who} co-cares for {name} with you.",
|
||||
"remove": "Remove co-caretaker",
|
||||
"removeConfirm": "Should this person stop co-caring for {name}? Their posts as {name} stay.",
|
||||
"youCoCare": "You co-care for {name}.",
|
||||
"caretakerLimits": "Deleting, privacy and memorial mode are managed by the main owner.",
|
||||
"leave": "Stop co-caring",
|
||||
"leaveConfirm": "Do you want to stop co-caring for {name}? You can be invited again later.",
|
||||
"left": "You no longer co-care for {name}.",
|
||||
"cancel": "Cancel",
|
||||
"unknownAccount": "another account",
|
||||
"failed": "Sorry, that didn't work.",
|
||||
"codeLabel": "Invitation code",
|
||||
"codeHint": "You get the code from the pet's main owner. It is valid for 7 days.",
|
||||
"redeem": "Redeem",
|
||||
"requestSent": "Request sent – once approved, you'll find {name} under “My pets”.",
|
||||
"waiting": "Your request for {name} is waiting for approval.",
|
||||
"checkAgain": "Check again",
|
||||
"withdraw": "Withdraw"
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "org.pawfeed.app",
|
||||
"buildNumber": "3",
|
||||
"buildNumber": "4",
|
||||
"usesAppleSignIn": true,
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
|
||||
@@ -184,13 +184,13 @@ function makeStyles(colors: ColorTokens) {
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite },
|
||||
errorText: { ...typography.body, color: colors.alertRed },
|
||||
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
followers: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
followers: { ...typography.caption },
|
||||
footer: { marginVertical: 16 },
|
||||
filters: { gap: 10, marginBottom: 12 },
|
||||
speciesTabs: { gap: 8 },
|
||||
tab: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray },
|
||||
tabActive: { backgroundColor: colors.marigoldOrange },
|
||||
tabText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
|
||||
tabText: { ...typography.caption, color: colors.ink },
|
||||
tabTextActive: { color: colors.pureWhite },
|
||||
breedFilter: { width: 220 },
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ const SELF_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
BIRTHDAY: "notifications.birthday",
|
||||
ADOPTION_DAY: "notifications.adoptionDay",
|
||||
REFERRAL_RANK_UP: "notifications.referralRankUp",
|
||||
CARETAKER_REQUEST: "notifications.caretakerRequest",
|
||||
};
|
||||
|
||||
// Suffix only — the actor's name is rendered separately as its own tappable
|
||||
@@ -228,7 +229,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
row: { flexDirection: "row", gap: 12, padding: 12, borderRadius: 12 },
|
||||
rowUnread: { backgroundColor: colors.marigoldTint },
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
timestamp: { ...typography.caption },
|
||||
footer: { marginVertical: 16 },
|
||||
selfIcon: {
|
||||
width: 40,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Card } from "../../../src/components/Card";
|
||||
import { formatPetName } from "../../../src/lib/format";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
|
||||
import type { Pet } from "../../../src/api/types";
|
||||
import { CaretakerRedeem } from "../../../src/components/caretaker/CaretakerRedeem";
|
||||
|
||||
/**
|
||||
* List of the owner's own pets (pets.list()). Tapping a row opens that
|
||||
@@ -60,6 +61,15 @@ export default function PetsScreen() {
|
||||
<Text style={getTypography(colors).body}>{t("pets.empty")}</Text>
|
||||
</View>
|
||||
}
|
||||
// Mitbetreuer: Einladungscode eines anderen Kontos einlösen
|
||||
ListFooterComponent={
|
||||
<View style={styles.caretaker}>
|
||||
<Text style={styles.caretakerHeading}>{t("caretaker.redeemHeading")}</Text>
|
||||
<CaretakerRedeem />
|
||||
</View>
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +122,8 @@ function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
|
||||
separator: { height: 12 },
|
||||
caretaker: { marginTop: 32, gap: 8 },
|
||||
caretakerHeading: { ...typography.title, fontSize: 16 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite },
|
||||
row: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function AppLayout() {
|
||||
<Stack.Screen name="pets/[petId]/following" options={{ ...headerOptions, title: t("screens.following") }} />
|
||||
<Stack.Screen name="pets/[petId]/health/index" options={{ ...headerOptions, title: t("screens.health") }} />
|
||||
<Stack.Screen name="pets/[petId]/milestones" options={{ ...headerOptions, title: t("screens.milestones") }} />
|
||||
<Stack.Screen name="lost/[petId]" options={{ ...headerOptions, title: t("screens.lost") }} />
|
||||
<Stack.Screen name="posts/[postId]/comments" options={{ ...headerOptions, title: t("screens.comments") }} />
|
||||
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: t("screens.conversation") }} />
|
||||
<Stack.Screen name="search" options={{ ...headerOptions, title: t("screens.search") }} />
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import { useMemo } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Share, StyleSheet, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Pressable, ScrollView, 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 { ChevronRight, PawPrint } 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 { Reveal } from "../../src/components/Reveal";
|
||||
import { InviteCard } from "../../src/components/referrals/InviteCard";
|
||||
import { RankLadder } from "../../src/components/referrals/RankLadder";
|
||||
import { RedeemCodeForm } from "../../src/components/referrals/RedeemCodeForm";
|
||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
import { fontFamily, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
|
||||
const STATUS_KEYS = {
|
||||
type InviteeStatus = "counted" | "waiting" | "in-review" | "revoked";
|
||||
|
||||
const STATUS_KEYS: Record<InviteeStatus, string> = {
|
||||
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.
|
||||
* Port of web's /einladen in the same look as the settings card: InviteCard
|
||||
* on top (rank, code, progress, native share — no extra native module, OTA-
|
||||
* safe), rank timeline, invited pets with status pills, late code entry.
|
||||
*/
|
||||
export default function InviteScreen() {
|
||||
const { t } = useTranslation();
|
||||
@@ -44,12 +47,12 @@ export default function InviteScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
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}` });
|
||||
}
|
||||
const statusColor: Record<InviteeStatus, string> = {
|
||||
counted: "#059669",
|
||||
waiting: colors.marigoldOrange,
|
||||
"in-review": colors.quietGray,
|
||||
revoked: colors.alertRed,
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -57,51 +60,68 @@ export default function InviteScreen() {
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.subtitle}>{t("referrals.subtitle")}</Text>
|
||||
<Text style={styles.intro}>{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>
|
||||
<InviteCard />
|
||||
|
||||
<Reveal index={2}>
|
||||
<RankLadder
|
||||
ranks={ranks}
|
||||
achievedRankIds={new Set(data.achievedRankIds)}
|
||||
qualifiedCount={data.qualifiedCount}
|
||||
nextRankId={data.nextRankId}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal index={3}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.heading}>{t("referrals.inviteesHeading")}</Text>
|
||||
{data.invitees.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<View style={styles.emptyIcon}>
|
||||
<PawPrint color={colors.marigoldOrange} size={22} />
|
||||
</View>
|
||||
<Text style={styles.emptyText}>{t("referrals.inviteesEmpty")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
data.invitees.map((invitee, index) => {
|
||||
const status = invitee.status as InviteeStatus;
|
||||
return (
|
||||
<Pressable
|
||||
key={invitee.pet?.id ?? `invitee-${index}`}
|
||||
style={({ pressed }) => [styles.inviteeRow, pressed && invitee.pet && styles.pressed]}
|
||||
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" />
|
||||
) : (
|
||||
<View style={styles.noPetAvatar} />
|
||||
)}
|
||||
<Text style={styles.inviteeName} numberOfLines={1}>
|
||||
{invitee.pet?.name ?? t("referrals.inviteeNoPet")}
|
||||
</Text>
|
||||
<View style={[styles.statusPill, { borderColor: statusColor[status] }]}>
|
||||
<Text style={[styles.statusText, { color: statusColor[status] }]}>{t(STATUS_KEYS[status])}</Text>
|
||||
</View>
|
||||
{invitee.pet ? <ChevronRight size={16} color={colors.quietGray} /> : null}
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</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>
|
||||
</Reveal>
|
||||
|
||||
{data.canRedeem && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.heading}>{t("referrals.redeemHeading")}</Text>
|
||||
<Text style={styles.muted}>{t("referrals.redeemSubtitle")}</Text>
|
||||
<RedeemCodeForm />
|
||||
</View>
|
||||
<Reveal index={4}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.heading}>{t("referrals.redeemHeading")}</Text>
|
||||
<Text style={styles.muted}>{t("referrals.redeemSubtitle")}</Text>
|
||||
<RedeemCodeForm />
|
||||
</View>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Text style={styles.footnote}>{t("referrals.rulesNote")}</Text>
|
||||
@@ -114,18 +134,20 @@ function makeStyles(colors: ColorTokens) {
|
||||
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 },
|
||||
intro: { ...typography.body, fontSize: 15, lineHeight: 22, color: colors.quietGray },
|
||||
notice: { ...typography.body, fontSize: 13, padding: 12, borderRadius: 14, backgroundColor: colors.marigoldTint, color: colors.ink },
|
||||
card: { gap: 12, padding: 18, borderRadius: 22, 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" },
|
||||
empty: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||
emptyIcon: { width: 44, height: 44, borderRadius: 13, backgroundColor: colors.marigoldTint, alignItems: "center", justifyContent: "center" },
|
||||
emptyText: { ...typography.body, flex: 1, fontSize: 13.5, lineHeight: 19, color: colors.quietGray },
|
||||
inviteeRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
|
||||
inviteeName: { ...typography.body, flex: 1, fontWeight: "600" },
|
||||
footnote: { ...typography.body, fontSize: 12, color: colors.quietGray },
|
||||
pressed: { opacity: 0.7 },
|
||||
noPetAvatar: { width: 32, height: 32, borderRadius: 16, borderWidth: 1.5, borderStyle: "dashed", borderColor: colors.hairlineGray },
|
||||
inviteeName: { flex: 1, fontFamily: fontFamily.semiBold, fontSize: 14.5, color: colors.ink },
|
||||
statusPill: { paddingHorizontal: 9, paddingVertical: 3, borderRadius: 999, borderWidth: 1 },
|
||||
statusText: { fontFamily: fontFamily.semiBold, fontSize: 11.5 },
|
||||
footnote: { ...typography.body, fontSize: 12, lineHeight: 18, color: colors.quietGray },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, ScrollView, Pressable, Image, ActivityIndicator, Linking, Share, StyleSheet } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Clock, Info, Mail, MapPin, MessageCircle, Phone, Share2, Siren } from "lucide-react-native";
|
||||
import { useTRPC } from "../../../src/api/trpc";
|
||||
import { useActivePet } from "../../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../../src/context/ThemeContext";
|
||||
import { Button } from "../../../src/components/Button";
|
||||
import { env } from "../../../src/lib/env";
|
||||
import { getMediaUrl, getAvatarUrl } from "../../../src/lib/media-url";
|
||||
import { isoToDisplayDate } from "../../../src/lib/date-format";
|
||||
import { useBottomSafePadding } from "../../../src/hooks/useBottomSafePadding";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
|
||||
|
||||
const RED = "#dc2626";
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
/** „04.10.2026, 14:30“ in Ortszeit des Geräts. */
|
||||
function formatDateTime(value: Date | string): string {
|
||||
const d = new Date(value);
|
||||
const iso = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
return `${isoToDisplayDate(iso)}, ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vermisst-Ansicht in der App — dieselben Angaben wie die öffentliche Seite
|
||||
* /vermisst/<code>, ohne die App zu verlassen. Ziel des Profil-Banners und des
|
||||
* Antippens einer PET_LOST-Mitteilung (src/lib/notification-route.ts).
|
||||
*/
|
||||
export default function LostPetScreen() {
|
||||
const { petId } = useLocalSearchParams<{ petId: string }>();
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const router = useRouter();
|
||||
const { activePetId, pets } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
|
||||
const { data: report, isLoading } = useQuery(trpc.lostReports.byPet.queryOptions({ petId }));
|
||||
const openConversation = useMutation(
|
||||
trpc.conversations.getOrCreate.mutationOptions({
|
||||
onSuccess: (conversation) => router.push(`/messages/${conversation.id}`),
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.emptyTitle}>{t("lostView.notActiveTitle")}</Text>
|
||||
<Text style={styles.muted}>{t("lostView.notActiveText")}</Text>
|
||||
<Button title={t("lostView.toProfile")} variant="outline" onPress={() => router.replace(`/pets/${petId}`)} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const { pet } = report;
|
||||
const isOwnPet = pets.some((p) => p.id === pet.id);
|
||||
const heroUrl = getAvatarUrl(pet.avatarKey) ?? (report.photos[0] ? getMediaUrl(report.photos[0].storageKey) : undefined);
|
||||
const photoUrls = report.photos.map((p) => getMediaUrl(p.storageKey));
|
||||
const publicUrl = `${env.apiUrl}/vermisst/${report.code}`;
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={[styles.container, bottomPadding]}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.badge}>
|
||||
<Siren size={13} color="#fff" />
|
||||
<Text style={styles.badgeText}>{t("lostView.badge")}</Text>
|
||||
</View>
|
||||
<Text style={styles.heading}>{t("lostView.heading", { name: pet.name })}</Text>
|
||||
<Text style={styles.muted}>
|
||||
{pet.speciesName}
|
||||
{pet.breedName ? ` · ${pet.breedName}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{heroUrl ? <Image source={{ uri: heroUrl }} style={styles.hero} alt={pet.name} /> : null}
|
||||
|
||||
<View style={styles.card}>
|
||||
<Fact icon={<MapPin size={16} color={colors.quietGray} />} label={t("lostView.lastSeenPlace")} value={report.lastSeenPlace} styles={styles} />
|
||||
<Fact icon={<Clock size={16} color={colors.quietGray} />} label={t("lostView.lastSeenAt")} value={formatDateTime(report.lastSeenAt)} styles={styles} />
|
||||
{report.description ? (
|
||||
<Fact icon={<Info size={16} color={colors.quietGray} />} label={t("lostView.details")} value={report.description} styles={styles} />
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{!isOwnPet && (
|
||||
<View style={styles.contactCard}>
|
||||
<Text style={styles.contactHeading}>{t("lostView.contactHeading", { name: pet.name })}</Text>
|
||||
{report.contactPhone ? (
|
||||
<ContactButton
|
||||
icon={<Phone size={18} color={colors.ink} />}
|
||||
label={report.contactPhone}
|
||||
onPress={() => void Linking.openURL(`tel:${report.contactPhone!.replace(/[^+0-9]/g, "")}`)}
|
||||
styles={styles}
|
||||
/>
|
||||
) : null}
|
||||
{report.contactEmail ? (
|
||||
<ContactButton
|
||||
icon={<Mail size={18} color={colors.ink} />}
|
||||
label={report.contactEmail}
|
||||
onPress={() =>
|
||||
void Linking.openURL(
|
||||
`mailto:${report.contactEmail}?subject=${encodeURIComponent(t("lostView.mailSubject", { name: pet.name }))}`
|
||||
)
|
||||
}
|
||||
styles={styles}
|
||||
/>
|
||||
) : null}
|
||||
{activePetId ? (
|
||||
<ContactButton
|
||||
icon={<MessageCircle size={18} color={colors.ink} />}
|
||||
label={t("lostView.message")}
|
||||
onPress={() => openConversation.mutate({ senderPetId: activePetId, recipientPetId: pet.id })}
|
||||
styles={styles}
|
||||
/>
|
||||
) : null}
|
||||
<Text style={styles.hint}>{t("lostView.approachHint")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{photoUrls.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t("lostView.photosHeading")}</Text>
|
||||
<View style={styles.grid}>
|
||||
{photoUrls.map((url, index) => (
|
||||
<Pressable
|
||||
key={report.photos[index].postId}
|
||||
style={styles.gridItem}
|
||||
onPress={() =>
|
||||
router.push({ pathname: "/image-viewer", params: { images: JSON.stringify(photoUrls), index: String(index) } })
|
||||
}
|
||||
>
|
||||
<Image source={{ uri: url }} style={styles.gridImage} alt={t("lostView.photoAlt", { name: pet.name })} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
style={styles.shareButton}
|
||||
accessibilityRole="button"
|
||||
onPress={() => void Share.share({ message: `${t("lostView.shareText", { name: pet.name })} ${publicUrl}` })}
|
||||
>
|
||||
<Share2 size={18} color={colors.ink} />
|
||||
<Text style={styles.shareText}>{t("lostView.share")}</Text>
|
||||
</Pressable>
|
||||
<Button title={t("lostView.toProfile")} variant="outline" onPress={() => router.push(`/pets/${pet.id}`)} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
type Styles = ReturnType<typeof makeStyles>;
|
||||
|
||||
function Fact({ icon, label, value, styles }: { icon: React.ReactNode; label: string; value: string; styles: Styles }) {
|
||||
return (
|
||||
<View style={styles.fact}>
|
||||
<View style={styles.factIcon}>{icon}</View>
|
||||
<View style={styles.flex}>
|
||||
<Text style={styles.factLabel}>{label}</Text>
|
||||
<Text style={styles.factValue}>{value}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactButton({ icon, label, onPress, styles }: { icon: React.ReactNode; label: string; onPress: () => void; styles: Styles }) {
|
||||
return (
|
||||
<Pressable style={styles.contactButton} onPress={onPress} accessibilityRole="button">
|
||||
{icon}
|
||||
<Text style={styles.contactButtonText} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { padding: 16, gap: 16, backgroundColor: colors.porchWhite, flexGrow: 1 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12, padding: 24, backgroundColor: colors.porchWhite },
|
||||
emptyTitle: { ...typography.title, fontSize: 18, textAlign: "center" },
|
||||
header: { alignItems: "center", gap: 6 },
|
||||
badge: { flexDirection: "row", alignItems: "center", gap: 4, backgroundColor: RED, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4 },
|
||||
badgeText: { ...typography.label, color: "#fff", letterSpacing: 0.6 },
|
||||
heading: { ...typography.title, fontSize: 24, textAlign: "center" },
|
||||
muted: { ...typography.body, color: colors.quietGray, textAlign: "center" },
|
||||
hero: { width: "100%", aspectRatio: 1, borderRadius: radii.lg, backgroundColor: colors.whisperGray },
|
||||
card: { gap: 12, padding: 14, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.hairlineGray, backgroundColor: colors.pureWhite },
|
||||
fact: { flexDirection: "row", gap: 10 },
|
||||
factIcon: { paddingTop: 2 },
|
||||
flex: { flex: 1 },
|
||||
factLabel: { ...typography.caption },
|
||||
factValue: { ...typography.body },
|
||||
contactCard: { gap: 8, padding: 14, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.alertRedTint, backgroundColor: colors.alertRedTint },
|
||||
contactHeading: { ...typography.title, fontSize: 16 },
|
||||
contactButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
height: 48,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: radii.lg,
|
||||
backgroundColor: colors.pureWhite,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
contactButtonText: { ...typography.body, fontFamily: typography.title.fontFamily, flexShrink: 1 },
|
||||
hint: { ...typography.caption, marginTop: 4 },
|
||||
section: { gap: 8 },
|
||||
sectionTitle: { ...typography.title, fontSize: 15 },
|
||||
grid: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
|
||||
gridItem: { width: "32%", aspectRatio: 1 },
|
||||
gridImage: { width: "100%", height: "100%", borderRadius: 8, backgroundColor: colors.whisperGray },
|
||||
shareButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
height: 48,
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
shareText: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
});
|
||||
}
|
||||
@@ -132,7 +132,7 @@ function makeStyles(colors: ColorTokens, insetsBottom: number) {
|
||||
bubbleOwn: { backgroundColor: colors.marigoldOrange, borderBottomRightRadius: 4 },
|
||||
bubbleText: { ...typography.body },
|
||||
bubbleTextOwn: { color: colors.pureWhite },
|
||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 10 },
|
||||
timestamp: { ...typography.caption, fontSize: 10 },
|
||||
inputRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -100,7 +100,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
nameRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
preview: { ...typography.body, color: colors.quietGray },
|
||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
timestamp: { ...typography.caption },
|
||||
unreadDot: {
|
||||
backgroundColor: colors.marigoldOrange,
|
||||
borderRadius: 8,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { View, Text, Image as RNImage, Pressable, ScrollView, ActivityIndicator, StyleSheet, Alert } from "react-native";
|
||||
import { View, Text, Image as RNImage, Pressable, ScrollView, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -8,8 +8,7 @@ import { ImagePlus } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||
import { uploadPickedImage } from "../../src/lib/upload";
|
||||
import { MILESTONE_META, USER_INITIATED_MILESTONE_TYPES, type CreatableMilestoneType } from "../../src/lib/milestone-meta";
|
||||
import { TextField } from "../../src/components/TextField";
|
||||
import { Button } from "../../src/components/Button";
|
||||
@@ -49,11 +48,8 @@ export default function NewMilestoneScreen() {
|
||||
);
|
||||
|
||||
const pickImage = useCallback(async () => {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert(t("onboarding.galleryPermissionDenied"));
|
||||
return;
|
||||
}
|
||||
// Kein Berechtigungsdialog: der System-Bildwähler (iOS PHPicker, Android Photo Picker)
|
||||
// braucht keine Freigabe der Fotomediathek (Apple 5.1.1(iv), 07.10.).
|
||||
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 });
|
||||
if (!result.canceled) setAsset(result.assets[0]);
|
||||
}, []);
|
||||
@@ -65,13 +61,7 @@ export default function NewMilestoneScreen() {
|
||||
try {
|
||||
let imageKey: string | undefined;
|
||||
if (asset) {
|
||||
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
const { key } = await uploadPickedImage(asset, (file) => getPresignedUrlMutation.mutateAsync({ petId: activePetId, ...file }));
|
||||
imageKey = key;
|
||||
}
|
||||
createMutation.mutate({
|
||||
@@ -145,7 +135,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { padding: 16, gap: 14, backgroundColor: colors.porchWhite, flexGrow: 1 },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
label: { ...typography.caption },
|
||||
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||
chip: {
|
||||
flexDirection: "row",
|
||||
@@ -171,7 +161,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
},
|
||||
pickerText: { ...typography.body, color: colors.ink },
|
||||
preview: { width: "100%", height: 220, borderRadius: radii.md, backgroundColor: colors.hairlineGray },
|
||||
previewHint: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", marginTop: 6 },
|
||||
previewHint: { ...typography.caption, textAlign: "center", marginTop: 6 },
|
||||
notes: { height: 88, textAlignVertical: "top", paddingTop: 12 },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BreedPicker } from "../../../../src/components/pet/BreedPicker";
|
||||
import { PrivacySettings } from "../../../../src/components/pet/PrivacySettings";
|
||||
import { MemorialSettings } from "../../../../src/components/pet/MemorialSettings";
|
||||
import { LostReportSettings } from "../../../../src/components/lost/LostReportSettings";
|
||||
import { CaretakerSettings } from "../../../../src/components/caretaker/CaretakerSettings";
|
||||
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";
|
||||
@@ -121,6 +122,9 @@ export default function EditPetScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// Ältere Server ohne Mitbetreuer liefern keine Rolle → wie bisher Haupt-Besitzer.
|
||||
const isMainOwner = pet.role !== "CARETAKER";
|
||||
|
||||
return (
|
||||
// FlatList, not ScrollView — kept from the original nested-ScrollView
|
||||
// fix (see onboarding/pet.tsx's comment); OptionPicker's dropdown now
|
||||
@@ -175,19 +179,28 @@ export default function EditPetScreen() {
|
||||
|
||||
<Button title={t("common.save")} disabled={updateMutation.isPending} onPress={handleSave} />
|
||||
|
||||
<PrivacySettings
|
||||
petId={pet.id}
|
||||
petName={pet.name}
|
||||
dmPolicy={pet.dmPolicy}
|
||||
followerVisibility={pet.followerVisibility}
|
||||
/>
|
||||
{/* Privatsphäre, Gedenkmodus, Löschen: nur der Haupt-Besitzer */}
|
||||
{isMainOwner && (
|
||||
<PrivacySettings
|
||||
petId={pet.id}
|
||||
petName={pet.name}
|
||||
dmPolicy={pet.dmPolicy}
|
||||
followerVisibility={pet.followerVisibility}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MemorialSettings petId={pet.id} petName={pet.name} deceasedAt={pet.deceasedAt} />
|
||||
<CaretakerSettings petId={pet.id} petName={pet.name} />
|
||||
|
||||
<Pressable style={styles.deleteRow} onPress={confirmDelete} disabled={deleteMutation.isPending}>
|
||||
<Trash2 color={colors.alertRed} size={18} />
|
||||
<Text style={styles.deleteText}>{t("editPet.deletePet")}</Text>
|
||||
</Pressable>
|
||||
{isMainOwner && (
|
||||
<>
|
||||
<MemorialSettings petId={pet.id} petName={pet.name} deceasedAt={pet.deceasedAt} />
|
||||
|
||||
<Pressable style={styles.deleteRow} onPress={confirmDelete} disabled={deleteMutation.isPending}>
|
||||
<Trash2 color={colors.alertRed} size={18} />
|
||||
<Text style={styles.deleteText}>{t("editPet.deletePet")}</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -201,7 +214,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
container: { padding: 16, gap: 8, backgroundColor: colors.porchWhite, flexGrow: 1 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 12 },
|
||||
label: { ...typography.caption, marginTop: 12 },
|
||||
multiline: { height: 88, textAlignVertical: "top", paddingTop: 12 },
|
||||
error: { ...typography.body, color: colors.alertRed, marginTop: 8 },
|
||||
deleteRow: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, FlatList, Pressable, ActivityIndicator, Alert, Linking, StyleSheet } from "react-native";
|
||||
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";
|
||||
@@ -18,7 +18,6 @@ import { useBlockPet } from "../../../../src/hooks/useBlockPet";
|
||||
import { useVisiblePostIds } from "../../../../src/hooks/useVisiblePostIds";
|
||||
import { formatPetName } from "../../../../src/lib/format";
|
||||
import { isoToDisplayDate } from "../../../../src/lib/date-format";
|
||||
import { env } from "../../../../src/lib/env";
|
||||
import { getAvatarUrl } from "../../../../src/lib/media-url";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
|
||||
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
||||
@@ -153,7 +152,7 @@ export default function PetProfileScreen() {
|
||||
<Pressable
|
||||
style={styles.lostBanner}
|
||||
accessibilityRole="link"
|
||||
onPress={() => void Linking.openURL(`${env.apiUrl}/vermisst/${pet.lostReport?.code}`)}
|
||||
onPress={() => router.push(`/lost/${pet.id}`)}
|
||||
>
|
||||
<View style={styles.lostTitleRow}>
|
||||
<Siren size={15} color="#dc2626" />
|
||||
@@ -292,10 +291,10 @@ function makeStyles(colors: ColorTokens) {
|
||||
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
|
||||
stat: { alignItems: "center" },
|
||||
statCount: { ...typography.title, fontSize: 18 },
|
||||
statLabel: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
statLabel: { ...typography.caption },
|
||||
iconRow: { flexDirection: "row", gap: 20, marginTop: 14 },
|
||||
iconButton: { alignItems: "center", gap: 4 },
|
||||
iconButtonText: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
iconButtonText: { ...typography.caption, fontSize: 11 },
|
||||
section: { ...typography.body, alignSelf: "stretch", marginTop: 12 },
|
||||
sectionTitle: { ...typography.title, fontSize: 15, alignSelf: "stretch", marginTop: 16 },
|
||||
divider: { height: 1, backgroundColor: colors.hairlineGray, alignSelf: "stretch", marginTop: 16 },
|
||||
|
||||
@@ -104,7 +104,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
},
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
rowTitle: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowDate: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
rowDate: { ...typography.caption },
|
||||
rowNotes: { ...typography.body, fontSize: 13 },
|
||||
thumb: { width: 48, height: 48, borderRadius: radii.md },
|
||||
addButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, height: 48, marginTop: 8 },
|
||||
|
||||
@@ -8,8 +8,7 @@ import { X, ImagePlus, Camera } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||
import { uploadPickedImage } from "../../src/lib/upload";
|
||||
import { TextField } from "../../src/components/TextField";
|
||||
import { Button } from "../../src/components/Button";
|
||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
@@ -64,13 +63,7 @@ export default function NewPostScreen() {
|
||||
const uploadAsset = useCallback(
|
||||
async (asset: ImagePicker.ImagePickerAsset, index: number) => {
|
||||
try {
|
||||
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId!,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
const { key } = await uploadPickedImage(asset, (file) => getPresignedUrlMutation.mutateAsync({ petId: activePetId!, ...file }));
|
||||
setImages((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], status: "done", key };
|
||||
@@ -119,11 +112,8 @@ export default function NewPostScreen() {
|
||||
);
|
||||
|
||||
const pickFromGallery = useCallback(async () => {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert(t("onboarding.galleryPermissionDenied"));
|
||||
return;
|
||||
}
|
||||
// Kein Berechtigungsdialog: der System-Bildwähler (iOS PHPicker, Android Photo Picker)
|
||||
// braucht keine Freigabe der Fotomediathek (Apple 5.1.1(iv), 07.10.).
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
allowsMultipleSelection: true,
|
||||
|
||||
@@ -187,8 +187,8 @@ function makeStyles(colors: ColorTokens, insetsBottom: number) {
|
||||
rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 },
|
||||
bodyText: { ...typography.body },
|
||||
petName: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 },
|
||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
pendingLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
timestamp: { ...typography.caption, fontSize: 11 },
|
||||
pendingLabel: { ...typography.caption, fontSize: 11 },
|
||||
deleteButton: { alignSelf: "flex-start", padding: 4 },
|
||||
inputRow: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -248,7 +248,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
tabs: { flexDirection: "row", gap: 8, paddingHorizontal: 16, paddingVertical: 12 },
|
||||
tabButton: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray },
|
||||
tabButtonActive: { backgroundColor: colors.marigoldOrange },
|
||||
tabButtonText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
|
||||
tabButtonText: { ...typography.caption, color: colors.ink },
|
||||
tabButtonTextActive: { color: colors.pureWhite },
|
||||
list: { padding: 16, flexGrow: 1 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||
@@ -258,7 +258,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
petRowMain: { flex: 1, flexDirection: "row", alignItems: "center", gap: 10 },
|
||||
petRowText: { flex: 1 },
|
||||
petName: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
petSpecies: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
petSpecies: { ...typography.caption },
|
||||
ownerBlock: { marginBottom: 16 },
|
||||
ownerName: { ...typography.body, fontFamily: typography.title.fontFamily, marginBottom: 4 },
|
||||
tagGrid: { padding: 2 },
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { uploadImageToSupabase, uploadFileToUrl } from "../../src/lib/upload";
|
||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||
import { uploadFileToUrl, uploadPickedImage } from "../../src/lib/upload";
|
||||
import { StoryEditor, type EditorMedia, type EditorResult } from "../../src/components/story-editor/StoryEditor";
|
||||
import { fontFamily } from "../../src/theme/tokens";
|
||||
|
||||
@@ -95,17 +94,11 @@ export default function StoryEditorScreen() {
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
const resized = await resizeForUpload(media.uri, media.width, media.height);
|
||||
const size = await imageSize(resized.uri).catch(() => ({ width: media.width, height: media.height }));
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
const uploaded = await uploadPickedImage(media, (file) => getPresignedUrlMutation.mutateAsync({ petId: activePetId, ...file }));
|
||||
const size = await imageSize(uploaded.uri).catch(() => ({ width: media.width, height: media.height }));
|
||||
await createMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
storageKey: key,
|
||||
storageKey: uploaded.key,
|
||||
blurhash: null,
|
||||
...editorFields,
|
||||
mediaWidth: size.width,
|
||||
|
||||
@@ -46,11 +46,8 @@ export default function NewStoryScreen() {
|
||||
);
|
||||
|
||||
const pickFromGallery = useCallback(async () => {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert(t("onboarding.galleryPermissionDenied"));
|
||||
return;
|
||||
}
|
||||
// Kein Berechtigungsdialog: der System-Bildwähler (iOS PHPicker, Android Photo Picker)
|
||||
// braucht keine Freigabe der Fotomediathek (Apple 5.1.1(iv), 07.10.).
|
||||
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], quality: 0.8 });
|
||||
if (result.canceled) return;
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, ScrollView, Platform } from "react-native";
|
||||
import { Animated, Text, StyleSheet, Platform } from "react-native";
|
||||
import { useSignIn } from "@clerk/expo";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "../../src/components/Button";
|
||||
import { TextField } from "../../src/components/TextField";
|
||||
import { PasswordField } from "../../src/components/PasswordField";
|
||||
import { LanguageSwitcher } from "../../src/components/LanguageSwitcher";
|
||||
import { KeyRound, Lock, Mail } from "lucide-react-native";
|
||||
import { GoogleSignInButton } from "../../src/components/GoogleSignInButton";
|
||||
import { AppleSignInButton } from "../../src/components/AppleSignInButton";
|
||||
import { AuthDivider, AuthFooterLink, AuthShell } from "../../src/components/auth/AuthShell";
|
||||
import { AuthField } from "../../src/components/auth/AuthField";
|
||||
import { Reveal } from "../../src/components/Reveal";
|
||||
import { useShake } from "../../src/hooks/useShake";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
|
||||
|
||||
@@ -35,6 +37,9 @@ export default function SignInScreen() {
|
||||
const [code, setCode] = useState("");
|
||||
const [step, setStep] = useState<"credentials" | "verify-device">("credentials");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCredentialError, setIsCredentialError] = useState(false);
|
||||
const { shakeStyle, shake } = useShake();
|
||||
const isBusy = fetchStatus === "fetching";
|
||||
|
||||
const finalizeIfComplete = async () => {
|
||||
if (signIn.status !== "complete") {
|
||||
@@ -50,6 +55,8 @@ export default function SignInScreen() {
|
||||
const { error: signInError } = await signIn.password({ identifier: emailAddress, password });
|
||||
if (signInError) {
|
||||
setError(signInError.longMessage ?? signInError.message);
|
||||
setIsCredentialError(true);
|
||||
shake();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,6 +78,7 @@ export default function SignInScreen() {
|
||||
const { error: verifyError } = await signIn.mfa.verifyEmailCode({ code });
|
||||
if (verifyError) {
|
||||
setError(verifyError.longMessage ?? verifyError.message);
|
||||
shake();
|
||||
return;
|
||||
}
|
||||
await finalizeIfComplete();
|
||||
@@ -78,89 +86,110 @@ export default function SignInScreen() {
|
||||
|
||||
if (step === "verify-device") {
|
||||
return (
|
||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
||||
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.form}>
|
||||
<Text style={styles.title}>{t("auth.verifyDeviceTitle")}</Text>
|
||||
<Text style={styles.hint}>{t("auth.verifyDeviceHint", { email: emailAddress })}</Text>
|
||||
<TextField
|
||||
autoCapitalize="none"
|
||||
keyboardType="number-pad"
|
||||
placeholder={t("auth.codePlaceholder")}
|
||||
value={code}
|
||||
onChangeText={setCode}
|
||||
/>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Button title={t("auth.confirmButton")} disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
<AuthShell>
|
||||
<Reveal index={3}>
|
||||
<Text style={styles.title}>{t("auth.verifyDeviceTitle")}</Text>
|
||||
</Reveal>
|
||||
<Reveal index={4}>
|
||||
<Text style={styles.hint}>{t("auth.verifyDeviceHint", { email: emailAddress })}</Text>
|
||||
</Reveal>
|
||||
<Animated.View style={shakeStyle}>
|
||||
<AuthField
|
||||
icon={KeyRound}
|
||||
autoCapitalize="none"
|
||||
keyboardType="number-pad"
|
||||
placeholder={t("auth.codePlaceholder")}
|
||||
value={code}
|
||||
onChangeText={setCode}
|
||||
/>
|
||||
</Animated.View>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Button title={t("auth.confirmButton")} isLoading={isBusy} onPress={onVerifyPress} style={styles.submit} />
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
const onCredentialChange = (setter: (value: string) => void) => (value: string) => {
|
||||
setter(value);
|
||||
setIsCredentialError(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
||||
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.form}>
|
||||
<LanguageSwitcher />
|
||||
<Text style={styles.title}>{t("auth.signInTitle")}</Text>
|
||||
<AuthShell
|
||||
footer={
|
||||
<Reveal index={11}>
|
||||
<AuthFooterLink
|
||||
prompt={t("auth.noAccountPrompt")}
|
||||
action={t("auth.signUpButton")}
|
||||
onPress={() => router.replace("/(auth)/sign-up")}
|
||||
/>
|
||||
</Reveal>
|
||||
}
|
||||
>
|
||||
<Reveal index={3}>
|
||||
<Text style={styles.title}>{t("auth.welcomeBack")}</Text>
|
||||
</Reveal>
|
||||
<Reveal index={4}>
|
||||
<Text style={styles.hint}>{t("auth.welcomeBackHint")}</Text>
|
||||
</Reveal>
|
||||
<Animated.View style={[styles.fields, shakeStyle]}>
|
||||
<Reveal index={5}>
|
||||
<AuthField
|
||||
icon={Mail}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
value={emailAddress}
|
||||
onChangeText={onCredentialChange(setEmailAddress)}
|
||||
isInvalid={isCredentialError}
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal index={6}>
|
||||
<AuthField
|
||||
icon={Lock}
|
||||
isSecure
|
||||
autoComplete="password"
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
value={password}
|
||||
onChangeText={onCredentialChange(setPassword)}
|
||||
isInvalid={isCredentialError}
|
||||
/>
|
||||
</Reveal>
|
||||
</Animated.View>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Reveal index={7}>
|
||||
<Button title={t("auth.signInButton")} isLoading={isBusy} onPress={onSignInPress} style={styles.submit} />
|
||||
</Reveal>
|
||||
<Reveal index={8}>
|
||||
<AuthDivider label={t("auth.continueWith")} />
|
||||
</Reveal>
|
||||
{/* Apple renders nothing on Android — skip its Reveal so no empty gap remains. */}
|
||||
{Platform.OS === "ios" ? (
|
||||
<Reveal index={9}>
|
||||
<AppleSignInButton
|
||||
onError={setError}
|
||||
onNeedsConsent={() => setError(t("auth.appleNeedsSignUpError"))}
|
||||
/>
|
||||
</Reveal>
|
||||
) : null}
|
||||
<Reveal index={10}>
|
||||
<GoogleSignInButton
|
||||
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"
|
||||
keyboardType="email-address"
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
value={emailAddress}
|
||||
onChangeText={setEmailAddress}
|
||||
/>
|
||||
<PasswordField
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Button
|
||||
title={t("auth.signInButton")}
|
||||
disabled={fetchStatus === "fetching"}
|
||||
onPress={onSignInPress}
|
||||
/>
|
||||
<Pressable onPress={() => router.replace("/(auth)/sign-up")} hitSlop={8}>
|
||||
<Text style={styles.link}>{t("auth.noAccountLink")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</Reveal>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
flex: { flex: 1, backgroundColor: colors.porchWhite },
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 24,
|
||||
backgroundColor: colors.porchWhite,
|
||||
},
|
||||
// Capped so the form stays a readable phone-width column instead of
|
||||
// stretching full-bleed edge-to-edge on tablets (reported: "die gesamte
|
||||
// App sieht sehr großgezogen aus" on a tablet login screenshot) — the
|
||||
// container above had no maxWidth, so flex "stretch" filled the entire
|
||||
// screen width on large devices.
|
||||
form: { width: "100%", maxWidth: 420, gap: 12 },
|
||||
title: { ...typography.headline, marginBottom: 16 },
|
||||
divider: { ...typography.label, textAlign: "center", textTransform: "none" as const },
|
||||
hint: { ...typography.body, color: colors.quietGray, marginBottom: 8 },
|
||||
title: { ...typography.headline, fontSize: 22 },
|
||||
hint: { ...typography.body, color: colors.quietGray, marginTop: -6, marginBottom: 6 },
|
||||
fields: { gap: 10 },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
link: { ...typography.body, color: colors.marigoldDeep, textAlign: "center", marginTop: 8 },
|
||||
submit: { height: 52, borderRadius: 14 },
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, Pressable, Linking, StyleSheet, KeyboardAvoidingView, ScrollView, Platform } from "react-native";
|
||||
import { Animated, View, Text, Pressable, Linking, StyleSheet, Platform } from "react-native";
|
||||
import { useSignUp } from "@clerk/expo";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { Check, KeyRound, Lock, Mail, User } from "lucide-react-native";
|
||||
import { Button } from "../../src/components/Button";
|
||||
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 { AuthDivider, AuthFooterLink, AuthShell } from "../../src/components/auth/AuthShell";
|
||||
import { AuthField } from "../../src/components/auth/AuthField";
|
||||
import { Reveal } from "../../src/components/Reveal";
|
||||
import { useShake } from "../../src/hooks/useShake";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { env } from "../../src/lib/env";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
|
||||
@@ -50,6 +51,12 @@ export default function SignUpScreen() {
|
||||
const [code, setCode] = useState("");
|
||||
const [step, setStep] = useState<"credentials" | "verify-email">("credentials");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { shakeStyle, shake } = useShake();
|
||||
const { shakeStyle: consentShakeStyle, shake: shakeConsent } = useShake();
|
||||
// Set when someone taps Google/Apple before ticking the consent box.
|
||||
const [isConsentNudged, setIsConsentNudged] = useState(false);
|
||||
const showConsentHint = isConsentNudged && !legalAccepted;
|
||||
const isBusy = fetchStatus === "fetching";
|
||||
|
||||
const canSubmit =
|
||||
firstName.trim().length > 0 && lastName.trim().length > 0 && emailAddress.trim().length > 0 && password.length > 0 && legalAccepted;
|
||||
@@ -74,6 +81,7 @@ export default function SignUpScreen() {
|
||||
});
|
||||
if (signUpError) {
|
||||
setError(signUpError.longMessage ?? signUpError.message);
|
||||
shake();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,6 +103,7 @@ export default function SignUpScreen() {
|
||||
const { error: verifyError } = await signUp.verifications.verifyEmailCode({ code });
|
||||
if (verifyError) {
|
||||
setError(verifyError.longMessage ?? verifyError.message);
|
||||
shake();
|
||||
return;
|
||||
}
|
||||
await finalizeIfComplete();
|
||||
@@ -102,112 +111,165 @@ export default function SignUpScreen() {
|
||||
|
||||
if (step === "verify-email") {
|
||||
return (
|
||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
||||
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.form}>
|
||||
<Text style={styles.title}>{t("auth.verifyEmailTitle")}</Text>
|
||||
<Text style={styles.hint}>{t("auth.verifyEmailHint", { email: emailAddress })}</Text>
|
||||
<TextField
|
||||
autoCapitalize="none"
|
||||
keyboardType="number-pad"
|
||||
placeholder={t("auth.codePlaceholder")}
|
||||
value={code}
|
||||
onChangeText={setCode}
|
||||
/>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Button title={t("auth.confirmButton")} disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
<AuthShell>
|
||||
<Reveal index={3}>
|
||||
<Text style={styles.title}>{t("auth.verifyEmailTitle")}</Text>
|
||||
</Reveal>
|
||||
<Reveal index={4}>
|
||||
<Text style={styles.hint}>{t("auth.verifyEmailHint", { email: emailAddress })}</Text>
|
||||
</Reveal>
|
||||
<Animated.View style={shakeStyle}>
|
||||
<AuthField
|
||||
icon={KeyRound}
|
||||
autoCapitalize="none"
|
||||
keyboardType="number-pad"
|
||||
placeholder={t("auth.codePlaceholder")}
|
||||
value={code}
|
||||
onChangeText={setCode}
|
||||
/>
|
||||
</Animated.View>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Button title={t("auth.confirmButton")} isLoading={isBusy} onPress={onVerifyPress} style={styles.submit} />
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
const nudgeConsent = () => {
|
||||
setIsConsentNudged(true);
|
||||
shakeConsent();
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
||||
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.form}>
|
||||
<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")}
|
||||
value={firstName}
|
||||
onChangeText={setFirstName}
|
||||
/>
|
||||
<TextField
|
||||
placeholder={t("auth.lastNamePlaceholder")}
|
||||
value={lastName}
|
||||
onChangeText={setLastName}
|
||||
/>
|
||||
<TextField
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
value={emailAddress}
|
||||
onChangeText={setEmailAddress}
|
||||
/>
|
||||
<PasswordField
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
<Pressable style={styles.checkboxRow} onPress={() => setLegalAccepted((v) => !v)}>
|
||||
<View style={[styles.checkbox, legalAccepted && styles.checkboxChecked]}>
|
||||
{legalAccepted && <Check color={colors.pureWhite} size={14} />}
|
||||
<AuthShell
|
||||
footer={
|
||||
<Reveal index={13}>
|
||||
<AuthFooterLink
|
||||
prompt={t("auth.hasAccountPrompt")}
|
||||
action={t("auth.signInButton")}
|
||||
onPress={() => router.replace("/(auth)/sign-in")}
|
||||
/>
|
||||
</Reveal>
|
||||
}
|
||||
>
|
||||
<Reveal index={3}>
|
||||
<Text style={styles.title}>{t("auth.signUpHeading")}</Text>
|
||||
</Reveal>
|
||||
<Reveal index={4}>
|
||||
<Text style={styles.hint}>{t("auth.signUpHint")}</Text>
|
||||
</Reveal>
|
||||
<Animated.View style={[styles.fields, shakeStyle]}>
|
||||
<Reveal index={5} style={styles.nameRow}>
|
||||
<View style={styles.nameField}>
|
||||
<AuthField
|
||||
icon={User}
|
||||
autoComplete="given-name"
|
||||
placeholder={t("auth.firstNamePlaceholder")}
|
||||
value={firstName}
|
||||
onChangeText={setFirstName}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.checkboxLabel}>{t("auth.legalConsentLabel")}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.nameField}>
|
||||
<AuthField
|
||||
autoComplete="family-name"
|
||||
placeholder={t("auth.lastNamePlaceholder")}
|
||||
value={lastName}
|
||||
onChangeText={setLastName}
|
||||
/>
|
||||
</View>
|
||||
</Reveal>
|
||||
<Reveal index={6}>
|
||||
<AuthField
|
||||
icon={Mail}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
value={emailAddress}
|
||||
onChangeText={setEmailAddress}
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal index={7}>
|
||||
<AuthField
|
||||
icon={Lock}
|
||||
isSecure
|
||||
autoComplete="new-password"
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
</Reveal>
|
||||
</Animated.View>
|
||||
<Reveal index={8}>
|
||||
<Animated.View style={consentShakeStyle}>
|
||||
<Pressable style={styles.checkboxRow} onPress={() => setLegalAccepted((v) => !v)} hitSlop={6}>
|
||||
<View
|
||||
style={[styles.checkbox, showConsentHint && styles.checkboxNudged, legalAccepted && styles.checkboxChecked]}
|
||||
>
|
||||
{legalAccepted && <Check color={colors.pureWhite} size={14} />}
|
||||
</View>
|
||||
<Text style={styles.checkboxLabel}>{t("auth.legalConsentLabel")}</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
{showConsentHint ? <Text style={styles.consentHint}>{t("auth.consentFirstHint")}</Text> : null}
|
||||
<View style={styles.legalLinksRow}>
|
||||
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/nutzungsbedingungen`)}>
|
||||
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/nutzungsbedingungen`)} hitSlop={8}>
|
||||
<Text style={styles.link}>{t("pets.legalTerms")}</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)}>
|
||||
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)} hitSlop={8}>
|
||||
<Text style={styles.link}>{t("pets.legalPrivacy")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
</Reveal>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Reveal index={9}>
|
||||
<Button
|
||||
title={t("auth.signUpButton")}
|
||||
disabled={fetchStatus === "fetching" || !canSubmit}
|
||||
disabled={!canSubmit}
|
||||
isLoading={isBusy}
|
||||
onPress={onSignUpPress}
|
||||
style={styles.submit}
|
||||
/>
|
||||
<Pressable onPress={() => router.replace("/(auth)/sign-in")} hitSlop={8}>
|
||||
<Text style={styles.link}>{t("auth.hasAccountLink")}</Text>
|
||||
</Pressable>
|
||||
</Reveal>
|
||||
<Reveal index={10}>
|
||||
<AuthDivider label={t("auth.continueWith")} />
|
||||
</Reveal>
|
||||
{/* Buttons stay visually active: a greyed-out Google button without any
|
||||
explanation confused users. Before consent, a tap lands on the overlay
|
||||
and points at the checkbox instead of starting the SSO flow. */}
|
||||
<View style={styles.ssoGroup}>
|
||||
{/* Apple renders nothing on Android — skip its Reveal so no empty gap remains. */}
|
||||
{Platform.OS === "ios" ? (
|
||||
<Reveal index={11}>
|
||||
<AppleSignInButton onError={setError} legalAccepted={legalAccepted} />
|
||||
</Reveal>
|
||||
) : null}
|
||||
<Reveal index={12}>
|
||||
<GoogleSignInButton onError={setError} legalAccepted={legalAccepted} />
|
||||
</Reveal>
|
||||
{!legalAccepted ? (
|
||||
<Pressable
|
||||
style={StyleSheet.absoluteFill}
|
||||
onPress={nudgeConsent}
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={t("auth.consentFirstHint")}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
flex: { flex: 1, backgroundColor: colors.porchWhite },
|
||||
// flexGrow (not flex) on a ScrollView's contentContainerStyle — still
|
||||
// centers short content but lets the view actually scroll once the
|
||||
// keyboard pushes content taller than the screen (found.md: password
|
||||
// field and submit button were getting hidden behind the keyboard).
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 24,
|
||||
backgroundColor: colors.porchWhite,
|
||||
},
|
||||
// Capped so the form stays a readable phone-width column instead of
|
||||
// stretching full-bleed edge-to-edge on tablets (reported: "die gesamte
|
||||
// App sieht sehr großgezogen aus" on a tablet login screenshot) — the
|
||||
// container above had no maxWidth, so flex "stretch" filled the entire
|
||||
// screen width on large devices.
|
||||
form: { width: "100%", maxWidth: 420, gap: 12 },
|
||||
title: { ...typography.headline, marginBottom: 16 },
|
||||
divider: { ...typography.label, textAlign: "center", textTransform: "none" as const },
|
||||
hint: { ...typography.body, color: colors.quietGray, marginBottom: 8 },
|
||||
title: { ...typography.headline, fontSize: 22 },
|
||||
hint: { ...typography.body, color: colors.quietGray, marginTop: -6, marginBottom: 6 },
|
||||
fields: { gap: 10 },
|
||||
nameRow: { flexDirection: "row", gap: 10 },
|
||||
nameField: { flex: 1 },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
link: { ...typography.body, color: colors.marigoldDeep, textAlign: "center", marginTop: 8 },
|
||||
submit: { height: 52, borderRadius: 14 },
|
||||
link: { ...typography.body, fontSize: 13, color: colors.marigoldDeep },
|
||||
checkboxRow: { flexDirection: "row", alignItems: "flex-start", gap: 10, marginTop: 4 },
|
||||
checkbox: {
|
||||
width: 20,
|
||||
@@ -220,7 +282,10 @@ function makeStyles(colors: ColorTokens) {
|
||||
marginTop: 2,
|
||||
},
|
||||
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
checkboxNudged: { borderColor: colors.alertRed, borderWidth: 2 },
|
||||
consentHint: { ...typography.body, fontSize: 13, color: colors.alertRed, marginTop: 6 },
|
||||
ssoGroup: { gap: 10 },
|
||||
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
|
||||
legalLinksRow: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 16 },
|
||||
legalLinksRow: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 16, marginTop: 6 },
|
||||
});
|
||||
}
|
||||
@@ -1,42 +1,13 @@
|
||||
import { Pressable } from "react-native";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { fontFamily } from "../../src/theme/tokens";
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
/**
|
||||
* Always closes back to /(app), never router.back() — this screen is
|
||||
* reached two ways: OnboardingRedirect's replace() (no meaningful back
|
||||
* history) for a zero-pet owner, and the Pets tab's "+" button (real back
|
||||
* history) for an existing owner adding another pet. A zero-pet owner who
|
||||
* closes gets bounced right back here by OnboardingRedirect — that's
|
||||
* correct, not a bug: they can't use the app without a first pet, but they
|
||||
* get an obvious "why am I back here" instead of a dead-end screen with no
|
||||
* way to leave at all.
|
||||
* No native header: the onboarding draws its own top bar (back, progress,
|
||||
* close) in pet.tsx. Close always goes to /(app), never router.back() — this
|
||||
* screen is reached via OnboardingRedirect's replace() (zero pets, no back
|
||||
* history) and via the Pets tab's "+" (another pet). A zero-pet owner who
|
||||
* closes gets bounced right back by OnboardingRedirect — intended: they can't
|
||||
* use the app without a first pet.
|
||||
*/
|
||||
function CloseButton() {
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<Pressable onPress={() => router.replace("/(app)")} hitSlop={12} style={{ marginLeft: 8 }}>
|
||||
<X color={colors.ink} size={22} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OnboardingLayout() {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.pureWhite },
|
||||
headerTitleStyle: { fontFamily: fontFamily.bold, color: colors.ink },
|
||||
headerLeft: () => <CloseButton />,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="pet" options={{ title: t("onboarding.addPetTitle") }} />
|
||||
</Stack>
|
||||
);
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
@@ -1,89 +1,251 @@
|
||||
import { useState } from "react";
|
||||
import { ActivityIndicator, FlatList, View } from "react-native";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { ActivityIndicator, BackHandler, KeyboardAvoidingView, StyleSheet, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { OnboardingGuidelines } from "../../src/components/onboarding/OnboardingGuidelines";
|
||||
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";
|
||||
import type { PickerOption } from "../../src/components/OptionPicker";
|
||||
import { ArachnophobiaOptIn } from "../../src/components/onboarding/ArachnophobiaOptIn";
|
||||
import { AvatarStep } from "../../src/components/onboarding/AvatarStep";
|
||||
import { AcquisitionStep } from "../../src/components/onboarding/AcquisitionStep";
|
||||
import { CaretakerOnboarding } from "../../src/components/caretaker/CaretakerOnboarding";
|
||||
import { OnboardingTopBar } from "../../src/components/onboarding/flow/OnboardingTopBar";
|
||||
import { StepSlide } from "../../src/components/onboarding/flow/StepSlide";
|
||||
import { WelcomeStep } from "../../src/components/onboarding/flow/WelcomeStep";
|
||||
import { NameStep } from "../../src/components/onboarding/flow/NameStep";
|
||||
import { SpeciesStep } from "../../src/components/onboarding/flow/SpeciesStep";
|
||||
import { AboutStep } from "../../src/components/onboarding/flow/AboutStep";
|
||||
import { DoneStep } from "../../src/components/onboarding/flow/DoneStep";
|
||||
|
||||
type StepId = "welcome" | "name" | "species" | "about" | "photo" | "spiders" | "source" | "done";
|
||||
|
||||
// ponytail: matched by name because pets.listSpecies has no canonical key; the
|
||||
// taxonomy only has en/de. Add a key to listSpecies once a third locale exists.
|
||||
const SPIDER_NAMES = new Set(["spider", "spinne"]);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Onboarding as short steps (web's order kept where it matters): welcome
|
||||
* (rules + both declarations) -> name -> species/breed -> bio/story (creates
|
||||
* the pet) -> photo -> spider filter -> "Wie hast du von PawFeed erfahren?"
|
||||
* -> closing moment. Lives as its own top-level route (not under (auth) or
|
||||
* (app)) so app/_layout.tsx's "zero pets -> redirect here" check doesn't loop.
|
||||
*
|
||||
* The legal queries are read once (staleTime Infinity) and only invalidated
|
||||
* when leaving: refetching mid-flow (e.g. after the camera backgrounds the
|
||||
* app) would drop the welcome step and shift the progress bar.
|
||||
*/
|
||||
export default function OnboardingPetScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { colors } = useTheme();
|
||||
const { setActivePetId } = useActivePet();
|
||||
const [guidelinesAcknowledged, setGuidelinesAcknowledged] = useState(false);
|
||||
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 { pets, setActivePetId } = useActivePet();
|
||||
const trpc = useTRPC();
|
||||
// Erst nach dem Anlegen des Tiers abfragen — vorher existiert die Owner-Zeile ggf. noch nicht.
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isFirstPet] = useState(() => pets.length === 0);
|
||||
const [stepId, setStepId] = useState<StepId>("welcome");
|
||||
const [direction, setDirection] = useState<1 | -1>(1);
|
||||
const [isCaretakerPath, setIsCaretakerPath] = useState(false);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [species, setSpecies] = useState<PickerOption | null>(null);
|
||||
const [breed, setBreed] = useState<PickerOption | null>(null);
|
||||
const [bio, setBio] = useState("");
|
||||
const [adoptionStory, setAdoptionStory] = useState("");
|
||||
const [createdPet, setCreatedPet] = useState<{ id: string; name: string } | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [photoUri, setPhotoUri] = useState<string | undefined>(undefined);
|
||||
|
||||
const pendingLegal = useQuery({ ...trpc.legal.getPendingUpdate.queryOptions(), staleTime: Infinity });
|
||||
const consentStatus = useQuery({ ...trpc.legal.getUploadConsentStatus.queryOptions(), staleTime: Infinity });
|
||||
const { data: referralState } = useQuery({
|
||||
...trpc.referrals.onboardingState.queryOptions(),
|
||||
// Only after the pet exists — before that the Owner row may not exist yet.
|
||||
enabled: !!createdPet,
|
||||
});
|
||||
const isReferralStateLoading = !!createdPet && !acquisitionStepDone && referralState === undefined;
|
||||
const showAcquisitionStep = !!createdPet && !acquisitionStepDone && !!referralState?.showStep;
|
||||
|
||||
const finish = () => router.replace("/(app)");
|
||||
const needsLegal = (pendingLegal.data?.length ?? 0) > 0;
|
||||
const needsConsent = consentStatus.data ? !consentStatus.data.hasConsented : false;
|
||||
const isSpider = !!species && SPIDER_NAMES.has(species.name.toLowerCase());
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (!guidelinesAcknowledged) {
|
||||
content = <OnboardingGuidelines onContinue={() => setGuidelinesAcknowledged(true)} />;
|
||||
} else if (!uploadConsentGiven) {
|
||||
content = <UploadConsent onContinue={() => setUploadConsentGiven(true)} />;
|
||||
} else if (!arachnophobiaStepDone) {
|
||||
content = <ArachnophobiaOptIn onContinue={() => setArachnophobiaStepDone(true)} />;
|
||||
} else if (isReferralStateLoading) {
|
||||
const steps: StepId[] = [
|
||||
...(needsLegal || needsConsent ? (["welcome"] as const) : []),
|
||||
"name",
|
||||
"species",
|
||||
"about",
|
||||
"photo",
|
||||
// Shown for every new pet: the app has no other place to change it (web: /pets).
|
||||
...(isSpider ? [] : (["spiders"] as const)),
|
||||
...(isFirstPet ? (["source"] as const) : []),
|
||||
];
|
||||
const current: StepId = stepId === "welcome" && !steps.includes("welcome") ? "name" : stepId;
|
||||
const index = steps.indexOf(current);
|
||||
|
||||
const go = useCallback((target: StepId, dir: 1 | -1) => {
|
||||
setDirection(dir);
|
||||
setStepId(target);
|
||||
}, []);
|
||||
const next = () => go(steps[index + 1] ?? "done", 1);
|
||||
// No way back past pet creation — the pet exists, going back would create a second one.
|
||||
const back = index > 0 && !createdPet ? () => go(steps[index - 1], -1) : undefined;
|
||||
|
||||
const leave = () => {
|
||||
queryClient.invalidateQueries({ queryKey: trpc.legal.getPendingUpdate.queryOptions().queryKey });
|
||||
queryClient.invalidateQueries({ queryKey: trpc.legal.getUploadConsentStatus.queryOptions().queryKey });
|
||||
router.replace("/(app)");
|
||||
};
|
||||
|
||||
// Android back gesture: one step back instead of leaving the onboarding.
|
||||
const goBackInFlow = isCaretakerPath ? () => setIsCaretakerPath(false) : back;
|
||||
const goBackRef = useRef(goBackInFlow);
|
||||
useEffect(() => {
|
||||
goBackRef.current = goBackInFlow;
|
||||
});
|
||||
useEffect(() => {
|
||||
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
if (!goBackRef.current) return false;
|
||||
goBackRef.current();
|
||||
return true;
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
const createMutation = useMutation(
|
||||
trpc.pets.create.mutationOptions({
|
||||
onSuccess: (pet) => {
|
||||
// Without this, ActivePetContext keeps "no pets" and OnboardingRedirect
|
||||
// bounces straight back here once the flow ends.
|
||||
queryClient.invalidateQueries({ queryKey: trpc.pets.list.queryOptions().queryKey });
|
||||
setActivePetId(pet.id);
|
||||
setCreatedPet({ id: pet.id, name: pet.name });
|
||||
go("photo", 1);
|
||||
},
|
||||
onError: (err) => setCreateError(err.message),
|
||||
}),
|
||||
);
|
||||
|
||||
const createPet = () => {
|
||||
if (!species) return;
|
||||
setCreateError(null);
|
||||
createMutation.mutate({
|
||||
name: name.trim(),
|
||||
nickname: nickname.trim() || undefined,
|
||||
speciesId: species.id,
|
||||
breedId: breed?.id,
|
||||
bio: bio.trim() || undefined,
|
||||
adoptionStory: adoptionStory.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const petName = createdPet?.name ?? (name.trim() || "?");
|
||||
const isLoadingLegal = pendingLegal.isLoading || consentStatus.isLoading;
|
||||
|
||||
let content: ReactNode;
|
||||
if (isLoadingLegal) {
|
||||
content = <Centered color={colors.marigoldOrange} />;
|
||||
} else if (isCaretakerPath) {
|
||||
content = <CaretakerOnboarding onApproved={leave} onBack={() => setIsCaretakerPath(false)} />;
|
||||
} else if (current === "welcome") {
|
||||
content = <WelcomeStep needsLegal={needsLegal} needsConsent={needsConsent} onContinue={next} />;
|
||||
} else if (current === "name") {
|
||||
content = (
|
||||
<View style={{ padding: 48, alignItems: "center" }}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
<NameStep
|
||||
name={name}
|
||||
nickname={nickname}
|
||||
onChangeName={setName}
|
||||
onChangeNickname={setNickname}
|
||||
onNext={next}
|
||||
onCaretaker={() => setIsCaretakerPath(true)}
|
||||
/>
|
||||
);
|
||||
} 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 {
|
||||
} else if (current === "species") {
|
||||
content = (
|
||||
<PetCreateForm
|
||||
onSuccess={(pet) => {
|
||||
setActivePetId(pet.id);
|
||||
setCreatedPet(pet);
|
||||
<SpeciesStep
|
||||
petName={petName}
|
||||
speciesId={species?.id ?? null}
|
||||
breedName={breed?.name ?? null}
|
||||
onSelectSpecies={(option) => {
|
||||
setSpecies(option);
|
||||
setBreed(null);
|
||||
}}
|
||||
onSelectBreed={setBreed}
|
||||
onNext={next}
|
||||
/>
|
||||
);
|
||||
} else if (current === "about") {
|
||||
content = (
|
||||
<AboutStep
|
||||
petName={petName}
|
||||
bio={bio}
|
||||
adoptionStory={adoptionStory}
|
||||
onChangeBio={setBio}
|
||||
onChangeAdoptionStory={setAdoptionStory}
|
||||
error={createError}
|
||||
isCreating={createMutation.isPending}
|
||||
onCreate={createPet}
|
||||
/>
|
||||
);
|
||||
} else if (current === "photo" && createdPet) {
|
||||
content = (
|
||||
<AvatarStep
|
||||
petId={createdPet.id}
|
||||
petName={createdPet.name}
|
||||
onDone={(uri) => {
|
||||
setPhotoUri(uri);
|
||||
next();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (current === "spiders") {
|
||||
content = <ArachnophobiaOptIn onContinue={next} />;
|
||||
} else if (current === "source") {
|
||||
content = !referralState ? (
|
||||
<Centered color={colors.marigoldOrange} />
|
||||
) : referralState.showStep ? (
|
||||
<AcquisitionStep canRedeem={referralState.canRedeem} onDone={next} />
|
||||
) : (
|
||||
<AutoAdvance onAdvance={next} />
|
||||
);
|
||||
} else {
|
||||
content = <DoneStep petName={petName} photoUri={photoUri} onFinish={leave} />;
|
||||
}
|
||||
|
||||
return (
|
||||
// FlatList, not ScrollView, as the scroll container — kept from the
|
||||
// original nested-ScrollView fix (see git history) even though
|
||||
// OptionPicker's dropdown now renders in a Modal instead of nesting a
|
||||
// ScrollView here, so it's no longer strictly required for that
|
||||
// specific reason; still the established, working scroll container for
|
||||
// this screen.
|
||||
<FlatList
|
||||
data={EMPTY_DATA}
|
||||
renderItem={null}
|
||||
contentContainerStyle={{ flexGrow: 1, backgroundColor: colors.porchWhite }}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListHeaderComponent={<>{content}</>}
|
||||
/>
|
||||
// "padding" on Android too: edge-to-edge means the window no longer resizes for the keyboard.
|
||||
<KeyboardAvoidingView style={[styles.flex, { backgroundColor: colors.porchWhite }]} behavior="padding">
|
||||
<View style={[styles.flex, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
||||
{current !== "done" && !isCaretakerPath ? (
|
||||
<OnboardingTopBar count={steps.length} filled={index + 1} onBack={back} onClose={leave} />
|
||||
) : null}
|
||||
<StepSlide key={isCaretakerPath ? "caretaker" : current} direction={direction}>
|
||||
{content}
|
||||
</StepSlide>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_DATA: never[] = [];
|
||||
function Centered({ color }: { color: string }) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Wie hast du von PawFeed erfahren?" is once per owner — skip straight on when the server says it's done. */
|
||||
function AutoAdvance({ onAdvance }: { onAdvance: () => void }) {
|
||||
useEffect(() => {
|
||||
onAdvance();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||
});
|
||||
@@ -43,7 +43,8 @@
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleTeamId": "C72YB42R2D"
|
||||
"appleTeamId": "C72YB42R2D",
|
||||
"ascAppId": "6816366478"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,3 +209,15 @@ Neu 7:
|
||||
|
||||
- [x] Wunsch 04.10.: Eine Nachrichten-Mitteilung soll beim Antippen direkt zur Nachricht führen (wie bei Kommentaren) — für alle Funktionen: „Benachrichtigung bringt dich auf kürzestem Weg dorthin, wo es wichtig ist.“
|
||||
→ **Umgesetzt (04.10.):** Gemeinsame Zielregel für Mitteilungsliste und Push (`mobile/src/lib/notification-route.ts`, mit Tests): **Nachricht → Gespräch**, Kommentar/Pfote/Erwähnung/Paw-Back → Beitrag, Story-Mitteilungen → die Story, Follow/Vorschlag/Einladung → Profil, Rang → Einladen, Geburtstag/Adoptionstag/Impfung → Gesundheit. Neu: **Antippen einer Push-Mitteilung** führt direkt dorthin (vorher öffnete sich nur die App), auch beim Kaltstart; gilt sie für ein anderes eigenes Tier, wird vorher umgeschaltet. Bei Nachrichten und Storys führt auch der Name zum Ziel. Web: Nachrichten öffnen das Gespräch. Server liefert dafür das Gespräch zur Mitteilung und erweiterte Push-Daten. 386 Tests grün. Commit `4cf2821`, Rolling-Deploy, OTA Runtime 1.0.6: Android `7ea956bf-b519-4a57-a9f0-4008aa212899`, iOS `b8f7c983-fc18-43d7-aea3-10fa4315d4c6`. Nicht am Gerät getestet; Push-Ziele greifen für Mitteilungen ab jetzt (ältere Pushes haben die neuen Daten noch nicht).
|
||||
|
||||
- [x] Gefunden 04.10. (Marketing-Aufnahmen): Nach „Vermisst-Meldung starten“ war die Bestätigung auf Android abgeschnitten („… dass Berli schnell nac…“).
|
||||
→ **Behoben (04.10.):** Android kürzt Dialog-Titel auf zwei Zeilen, und der ganze Text stand als Titel im Dialog. Jetzt kurzer Titel + vollständige Nachricht: „Die Meldung ist raus“ / „Wir drücken die Daumen, dass {Name} schnell nach Hause findet.“, Entwarnung „Wie schön! 🎉“ / „Willkommen zurück, {Name}!“, Fehler „Hoppla“ + Meldung des Servers. Am Android-Emulator geprüft (Fehlerdialog bei der 24-h-Sperre vollständig lesbar). Typecheck, Lint und 406 Tests grün. Commit `b1d3b61`, OTA Runtime 1.0.6: Android `ab6e7fc6-9b24-44a6-bb65-b88e2b0cf937`, iOS `f63c0db6-6f5b-49de-b369-bf6aa7d52a1b`.
|
||||
|
||||
- [x] Wunsch 09.10. (Obsidian „Mobile - UI Rework“): Login-Seite wirkt statisch, Sprachwahl zu dominant, Logo fehlt, Reihenfolge seltsam. Nachtrag: Nutzer versteht nicht, warum „Mit Google anmelden“ auf der Registrierung ausgegraut ist.
|
||||
→ **Umgesetzt (09.10.):** Login und Registrierung neu aufgebaut (gemeinsamer Rahmen `src/components/auth/AuthShell.tsx`): Logo + „PawFeed“ oben, Sprache als kleiner „DE ⌄“-Chip mit Menü, Formular unten (E-Mail/Passwort → Anmelden → „oder weiter mit“ Apple/Google). Animationen nur mit eingebautem `Animated` (OTA-fähig): Logo federt rein, Inhalte gleiten gestaffelt ein, Feldrahmen orange bei Fokus, Ladekringel im Knopf, Felder wackeln + rot bei falschem Login; „Bewegung reduzieren“ wird beachtet. Android: Abstände über Statusleiste/Gestenleiste per Safe-Area, 28 pt Seitenrand (Zurück-Wischzone), Sprachmenü als Modal (Zurück-Geste schließt nur das Menü), Tastatur schiebt das Formular jetzt hoch (`KeyboardAvoidingView` auch auf Android, nötig seit Edge-to-Edge). Google/Apple auf der Registrierung nicht mehr ausgegraut: Tippen ohne Haken markiert die Checkbox rot, lässt sie wackeln und zeigt einen Hinweis. Typecheck + Lint sauber, live im Android-Emulator (Pixel 10 Pro, Gestensteuerung) getestet: Layout, Sprachmenü + Zurück-Geste, Tastatur, Fehlerfall, Hinweis. iOS nur per HTML-Vorschau abgenommen, nicht auf Gerät. Per OTA ausgeliefert 09.10. (Runtime 1.0.6, Android + iOS).
|
||||
|
||||
- [x] Wunsch 09.10. (Obsidian „Mobile - UI Rework“): „Freunde einladen“ in den Einstellungen sieht nach AI-Slop aus; Onboarding dynamischer machen statt statischer Seiten. Nachtrag beim Test: Die Detailseite „Freunde einladen“ passte nicht mehr zum neuen Design.
|
||||
→ **Umgesetzt (09.10.):** Neue `InviteCard` (`src/components/referrals/`) in den Einstellungen und oben auf `/invite`: Rang mit Abzeichen (ohne Rang gestrichelt „Dein erster Rang wartet“), markierbarer Code (Kopieren-Knopf bräuchte natives Modul), animierter Fortschrittsbalken, „Link teilen“. `/invite` neu: Ränge als Zeitleiste mit „Als Nächstes“, Status-Etiketten bei eingeladenen Tieren. Onboarding (`app/onboarding/pet.tsx` + `src/components/onboarding/flow/`): Willkommen mit 3 Regel-Karten + beiden Bestätigungen auf einer Seite → Name (Buchstabe live) → Tierart als Chips + Rasse → Beschreibung/Geschichte (legt Tier an) → Foto → Spinnen-Schalter (entfällt bei Spinne) → Herkunft (nur erstes Tier) → Abschluss mit Pfoten-Konfetti; Fortschrittsbalken, Zurück (nicht nach dem Anlegen), Android-Zurück-Geste geht einen Schritt zurück. Fund: Neue Nutzer sahen auf „Bevor es losgeht“ alle 7 Änderungsnotizen der Rechtstexte statt der Regeln — Regeln jetzt als feste App-Texte, `legal.acknowledge` unverändert (Web-Onboarding hat dasselbe Problem noch). Typecheck + Lint sauber, live im Android-Emulator (Dunkelmodus) getestet: Karte, `/invite`, kompletter Onboarding-Durchlauf mit Testtier „Testi“ (danach über „Tier löschen“ entfernt, DB geprüft). Einmal reagierte „Profil anlegen“ erst beim zweiten Tippen, nicht reproduziert. Hellmodus und iOS nicht auf Gerät geprüft. Per OTA ausgeliefert 09.10. (Runtime 1.0.6, Android + iOS).
|
||||
|
||||
- [x] Gefunden 09.10. (Video-Aufnahmen): Nach dem Onboarding zeigte „Meine Tiere“ das neue Tier nur mit Anfangsbuchstaben, obwohl das Foto hochgeladen war (bis zum App-Neustart).
|
||||
→ **Behoben (09.10.):** `AvatarStep` lädt nach dem Upload die Tierliste neu (`pets.list` invalidiert). Außerdem zeigt der Abschluss-Screen des Onboardings jetzt das hochgeladene Foto statt des Buchstabens. Typecheck + Lint sauber, live im Emulator geprüft (Liste zeigt Foto sofort, Abschluss mit Foto).
|
||||
@@ -12,4 +12,3 @@ export type PostComment = RouterOutputs["comments"]["listByPost"]["comments"][nu
|
||||
export type ChatMessage = RouterOutputs["messages"]["list"]["messages"][number];
|
||||
export type MilestonePost = RouterOutputs["milestones"]["listByPet"][number];
|
||||
export type TrendingPost = RouterOutputs["explore"]["getTrending"]["posts"][number];
|
||||
export type PostDetail = RouterOutputs["posts"]["getById"];
|
||||
@@ -6,7 +6,6 @@ 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;
|
||||
@@ -102,7 +101,7 @@ function AppleSignInButtonInner({ onError, legalAccepted, onNeedsConsent, disabl
|
||||
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
||||
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
|
||||
}
|
||||
cornerRadius={radii.lg}
|
||||
cornerRadius={14}
|
||||
style={styles.button}
|
||||
onPress={onPress}
|
||||
/>
|
||||
@@ -111,6 +110,6 @@ function AppleSignInButtonInner({ onError, legalAccepted, onNeedsConsent, disabl
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: { height: 48, width: "100%" },
|
||||
button: { height: 52, width: "100%" },
|
||||
disabled: { opacity: 0.5 },
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, Text, StyleSheet, type PressableProps } from "react-native";
|
||||
import { ActivityIndicator, Pressable, Text, StyleSheet, type PressableProps } from "react-native";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { radii, fontFamily, type ColorTokens } from "../theme/tokens";
|
||||
|
||||
@@ -8,6 +8,8 @@ type Variant = "primary" | "outline";
|
||||
type Props = PressableProps & {
|
||||
title: string;
|
||||
variant?: Variant;
|
||||
/** Spinner instead of the title; also blocks presses. */
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -16,13 +18,13 @@ type Props = PressableProps & {
|
||||
* Height is 48px rather than the web's 32px: that control size is a
|
||||
* desktop-density choice, too small as a touch target on a phone.
|
||||
*/
|
||||
export function Button({ title, variant = "primary", style, disabled, ...rest }: Props) {
|
||||
export function Button({ title, variant = "primary", style, disabled, isLoading = false, ...rest }: Props) {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
disabled={disabled}
|
||||
disabled={disabled || isLoading}
|
||||
style={(state) => [
|
||||
styles.base,
|
||||
variant === "primary" ? styles.primary : styles.outline,
|
||||
@@ -32,7 +34,11 @@ export function Button({ title, variant = "primary", style, disabled, ...rest }:
|
||||
]}
|
||||
{...rest}
|
||||
>
|
||||
<Text style={variant === "primary" ? styles.primaryText : styles.outlineText}>{title}</Text>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={variant === "primary" ? colors.pureWhite : colors.marigoldOrange} />
|
||||
) : (
|
||||
<Text style={variant === "primary" ? styles.primaryText : styles.outlineText}>{title}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSSO } from "@clerk/expo";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useWarmUpBrowser } from "../hooks/useWarmUpBrowser";
|
||||
import { radii, fontFamily, type ColorTokens } from "../theme/tokens";
|
||||
import { fontFamily, type ColorTokens } from "../theme/tokens";
|
||||
import { GoogleIcon } from "./GoogleIcon";
|
||||
|
||||
type Props = {
|
||||
@@ -109,8 +109,8 @@ export function GoogleSignInButton({ onError, legalAccepted, onNeedsConsent, dis
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
base: {
|
||||
height: 48,
|
||||
borderRadius: radii.lg,
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
|
||||
@@ -14,17 +14,15 @@ import { getTypography, type ColorTokens, type Typography } from "../theme/token
|
||||
* changeLanguage() logic, no backend field (mirrors web: locale lives only
|
||||
* in a cookie/local preference, never on the Owner row).
|
||||
*/
|
||||
export function LanguageSwitcher() {
|
||||
export function useLanguagePreference() {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const queryClient = useQueryClient();
|
||||
const [preference, setPreferenceState] = useState<Locale | null>(() => getLanguagePreference());
|
||||
|
||||
const languageOptions = [
|
||||
{ value: "de" as const, label: t("pets.languageGerman"), flag: "🇩🇪" },
|
||||
{ value: "en" as const, label: t("pets.languageEnglish"), flag: "🇬🇧" },
|
||||
{ value: null, label: t("pets.languageAuto"), flag: "🌐" },
|
||||
{ value: "de" as const, label: t("pets.languageGerman"), flag: "🇩🇪", short: "DE" },
|
||||
{ value: "en" as const, label: t("pets.languageEnglish"), flag: "🇬🇧", short: "EN" },
|
||||
{ value: null, label: t("pets.languageAuto"), flag: "🌐", short: "Auto" },
|
||||
];
|
||||
|
||||
const handleSelect = (value: Locale | null) => {
|
||||
@@ -33,6 +31,14 @@ export function LanguageSwitcher() {
|
||||
queryClient.invalidateQueries();
|
||||
};
|
||||
|
||||
return { preference, languageOptions, handleSelect };
|
||||
}
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const { preference, languageOptions, handleSelect } = useLanguagePreference();
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{languageOptions.map((option) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, StyleSheet } from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { PawPrint } from "lucide-react-native";
|
||||
import { useTRPC } from "../api/trpc";
|
||||
import { usePawToggle } from "../hooks/usePawToggle";
|
||||
import { colors, fontFamily } from "../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
@@ -16,37 +16,12 @@ type Props = {
|
||||
* `false` on mount — the web app's feed response doesn't tell the client
|
||||
* whether the viewer already reacted (postInclude has no reactions relation),
|
||||
* an acknowledged simplification on web that this mirrors rather than fixes.
|
||||
* Same optimistic-update shape: flip locally in onMutate, reconcile the exact
|
||||
* count from the server's authoritative `reacted` in onSuccess, roll back in
|
||||
* onError.
|
||||
* Optimistic toggle via usePawToggle (shared with StoryPawButton).
|
||||
*/
|
||||
export function PawButton({ postId, petId, initialCount }: Props) {
|
||||
const trpc = useTRPC();
|
||||
const [reacted, setReacted] = useState(false);
|
||||
const [count, setCount] = useState(initialCount);
|
||||
|
||||
const toggleMutation = useMutation(
|
||||
trpc.reactions.toggle.mutationOptions({
|
||||
onMutate: () => {
|
||||
const prevReacted = reacted;
|
||||
const prevCount = count;
|
||||
setReacted(!reacted);
|
||||
return { prevReacted, prevCount };
|
||||
},
|
||||
onSuccess: (data, _vars, context) => {
|
||||
setReacted(data.reacted);
|
||||
if (context) {
|
||||
setCount(data.reacted ? context.prevCount + 1 : Math.max(0, context.prevCount - 1));
|
||||
}
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context) {
|
||||
setReacted(context.prevReacted);
|
||||
setCount(context.prevCount);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
const paw = usePawToggle(false, initialCount);
|
||||
const toggleMutation = useMutation(trpc.reactions.toggle.mutationOptions(paw.callbacks));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -56,10 +31,10 @@ export function PawButton({ postId, petId, initialCount }: Props) {
|
||||
>
|
||||
<PawPrint
|
||||
size={20}
|
||||
color={reacted ? colors.marigoldOrange : colors.quietGray}
|
||||
fill={reacted ? colors.marigoldOrange : "transparent"}
|
||||
color={paw.reacted ? colors.marigoldOrange : colors.quietGray}
|
||||
fill={paw.reacted ? colors.marigoldOrange : "transparent"}
|
||||
/>
|
||||
<Text style={styles.count}>{count}</Text>
|
||||
<Text style={styles.count}>{paw.count}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -312,8 +312,8 @@ function makeStyles(colors: ColorTokens) {
|
||||
borderRadius: radii.sm,
|
||||
backgroundColor: colors.marigoldTint,
|
||||
},
|
||||
trendingBadgeText: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 10, color: colors.marigoldDeep },
|
||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
trendingBadgeText: { ...typography.caption, fontSize: 10, color: colors.marigoldDeep },
|
||||
timestamp: { ...typography.caption },
|
||||
caption: { ...typography.body },
|
||||
actions: { flexDirection: "row", alignItems: "center", gap: 20 },
|
||||
actionGroup: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
|
||||
@@ -81,7 +81,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
card: { gap: 8 },
|
||||
attribution: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
attributionText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray },
|
||||
attributionText: { ...typography.caption, color: colors.quietGray },
|
||||
header: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
caption: { ...typography.body },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Animated, Easing, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useReducedMotion } from "../hooks/useReducedMotion";
|
||||
|
||||
const START_DELAY_MS = 250;
|
||||
const STAGGER_MS = 60;
|
||||
const DURATION_MS = 550;
|
||||
const RISE_PX = 18;
|
||||
|
||||
type Props = {
|
||||
/** Position in the entrance sequence — each step starts STAGGER_MS later. */
|
||||
index: number;
|
||||
children: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
/** Fades and slides its children up once on mount, staggered by `index`. */
|
||||
export function Reveal({ index, children, style }: Props) {
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const [progress] = useState(() => new Animated.Value(0));
|
||||
|
||||
useEffect(() => {
|
||||
if (isReducedMotion) {
|
||||
progress.setValue(1);
|
||||
return;
|
||||
}
|
||||
const animation = Animated.timing(progress, {
|
||||
toValue: 1,
|
||||
duration: DURATION_MS,
|
||||
delay: START_DELAY_MS + index * STAGGER_MS,
|
||||
easing: Easing.bezier(0.2, 0.8, 0.2, 1),
|
||||
useNativeDriver: true,
|
||||
});
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [isReducedMotion, index, progress]);
|
||||
|
||||
const translateY = progress.interpolate({ inputRange: [0, 1], outputRange: [RISE_PX, 0] });
|
||||
return <Animated.View style={[style, { opacity: progress, transform: [{ translateY }] }]}>{children}</Animated.View>;
|
||||
}
|
||||
@@ -96,5 +96,5 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
},
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.pureWhite },
|
||||
label: { ...typography.caption, color: colors.pureWhite },
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Animated, Pressable, StyleSheet, TextInput, type TextInputProps } from "react-native";
|
||||
import { Eye, EyeOff, type LucideIcon } from "lucide-react-native";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { fontFamily, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
const FOCUS_MS = 200;
|
||||
|
||||
type Props = TextInputProps & {
|
||||
icon?: LucideIcon;
|
||||
/** Red border after a rejected submit. */
|
||||
isInvalid?: boolean;
|
||||
/** Password field: hidden text plus a show/hide toggle. */
|
||||
isSecure?: boolean;
|
||||
};
|
||||
|
||||
/** Larger auth-screen input: leading icon, animated marigold border on focus, red when invalid. */
|
||||
export function AuthField({ icon: Icon, isInvalid = false, isSecure = false, onFocus, onBlur, ...rest }: Props) {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [focus] = useState(() => new Animated.Value(0));
|
||||
|
||||
const animateFocus = (toValue: number) =>
|
||||
// borderColor can't run on the native driver.
|
||||
Animated.timing(focus, { toValue, duration: FOCUS_MS, useNativeDriver: false }).start();
|
||||
|
||||
useEffect(() => () => focus.stopAnimation(), [focus]);
|
||||
|
||||
const borderColor = isInvalid
|
||||
? colors.alertRed
|
||||
: focus.interpolate({ inputRange: [0, 1], outputRange: [colors.hairlineGray, colors.marigoldOrange] });
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.field, { borderColor }]}>
|
||||
{Icon ? <Icon color={colors.quietGray} size={18} /> : null}
|
||||
<TextInput
|
||||
placeholderTextColor={colors.quietGray}
|
||||
secureTextEntry={isSecure && !isVisible}
|
||||
onFocus={(e) => {
|
||||
animateFocus(1);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
animateFocus(0);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
{...rest}
|
||||
style={styles.input}
|
||||
/>
|
||||
{isSecure ? (
|
||||
<Pressable onPress={() => setIsVisible((v) => !v)} hitSlop={12} accessibilityRole="button">
|
||||
{isVisible ? <EyeOff color={colors.quietGray} size={18} /> : <Eye color={colors.quietGray} size={18} />}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
field: {
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1.5,
|
||||
backgroundColor: colors.pureWhite,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: "100%",
|
||||
fontFamily: fontFamily.regular,
|
||||
fontSize: 15,
|
||||
color: colors.ink,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Animated, Platform, StyleSheet } from "react-native";
|
||||
import Svg, { Circle, Defs, G, LinearGradient, Path, Rect, Stop } from "react-native-svg";
|
||||
import { useReducedMotion } from "../../hooks/useReducedMotion";
|
||||
|
||||
const SIZE = 68;
|
||||
const RADIUS = 22;
|
||||
const TOE_DELAY_MS = 450;
|
||||
const TOE_STAGGER_MS = 70;
|
||||
const TOES = [
|
||||
{ cx: 5.5, cy: 9.5, r: 2.4 },
|
||||
{ cx: 10, cy: 5.2, r: 2.4 },
|
||||
{ cx: 15, cy: 5.6, r: 2.4 },
|
||||
{ cx: 19, cy: 10, r: 2.3 },
|
||||
];
|
||||
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||
|
||||
/** App icon mark for the auth screens: springs in, then the four toes appear one by one. */
|
||||
export function AuthLogo() {
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const [pop] = useState(() => new Animated.Value(0));
|
||||
const [toes] = useState(() => TOES.map(() => new Animated.Value(0)));
|
||||
|
||||
useEffect(() => {
|
||||
if (isReducedMotion) {
|
||||
pop.setValue(1);
|
||||
toes.forEach((toe) => toe.setValue(1));
|
||||
return;
|
||||
}
|
||||
const animation = Animated.parallel([
|
||||
Animated.spring(pop, { toValue: 1, friction: 5, tension: 80, useNativeDriver: true }),
|
||||
Animated.sequence([
|
||||
Animated.delay(TOE_DELAY_MS),
|
||||
// SVG props can't run on the native driver.
|
||||
Animated.stagger(
|
||||
TOE_STAGGER_MS,
|
||||
toes.map((toe) => Animated.timing(toe, { toValue: 1, duration: 300, useNativeDriver: false })),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [isReducedMotion, pop, toes]);
|
||||
|
||||
const scale = pop.interpolate({ inputRange: [0, 1], outputRange: [0.5, 1] });
|
||||
const opacity = pop.interpolate({ inputRange: [0, 0.6], outputRange: [0, 1], extrapolate: "clamp" });
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.mark, { opacity, transform: [{ scale }] }]}>
|
||||
<Svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`}>
|
||||
<Defs>
|
||||
<LinearGradient id="authLogoFill" x1="0" y1="0" x2="1" y2="1">
|
||||
<Stop offset="0" stopColor="#fb923c" />
|
||||
<Stop offset="1" stopColor="#ea580c" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Rect width={SIZE} height={SIZE} rx={RADIUS} fill="url(#authLogoFill)" />
|
||||
<G transform="translate(12 12) scale(1.8333)" fill="#ffffff">
|
||||
{TOES.map((toe, i) => (
|
||||
<AnimatedCircle key={i} cx={toe.cx} cy={toe.cy} r={toe.r} opacity={toes[i]} />
|
||||
))}
|
||||
<Path d="M12.3 11.2c3 0 6.2 3.6 6.2 6.4 0 2.2-1.7 3-3.3 3-1.3 0-1.9-.6-2.9-.6s-1.6.6-2.9.6c-1.6 0-3.3-.8-3.3-3 0-2.8 3.2-6.4 6.2-6.4z" />
|
||||
</G>
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
mark: {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
borderRadius: RADIUS,
|
||||
marginBottom: 12,
|
||||
// Android needs a background for elevation to cast a shadow.
|
||||
backgroundColor: "#ea580c",
|
||||
...Platform.select({
|
||||
ios: { shadowColor: "#ea580c", shadowOpacity: 0.45, shadowRadius: 14, shadowOffset: { width: 0, height: 10 } },
|
||||
android: { elevation: 8 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { KeyboardAvoidingView, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Svg, { Defs, RadialGradient, Rect, Stop } from "react-native-svg";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
import { AuthLogo } from "./AuthLogo";
|
||||
import { LanguageChip } from "./LanguageChip";
|
||||
import { Reveal } from "../Reveal";
|
||||
|
||||
const GLOW_HEIGHT = 420;
|
||||
/** Keeps tappable content out of Android's edge back-gesture zone. */
|
||||
const SIDE_GUTTER = 28;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared frame for sign-in/sign-up: language chip top right, brand block,
|
||||
* then the form anchored to the bottom (thumb zone). Insets are applied by
|
||||
* hand because Android draws edge-to-edge — without them the chip sits under
|
||||
* the status bar and the footer under the gesture bar. Reveal indexes 0–2
|
||||
* are used here, so the screens' own content starts at 3.
|
||||
*/
|
||||
export function AuthShell({ children, footer }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
// "padding" on Android too: edge-to-edge means the window no longer resizes
|
||||
// for the keyboard, so without it the bottom-anchored form stays hidden.
|
||||
<KeyboardAvoidingView style={styles.flex} behavior="padding">
|
||||
<Svg style={styles.glow} width="100%" height={GLOW_HEIGHT} pointerEvents="none">
|
||||
<Defs>
|
||||
<RadialGradient id="authGlow" cx="50%" cy="15%" rx="75%" ry="85%">
|
||||
<Stop offset="0" stopColor={colors.marigoldTint} stopOpacity="1" />
|
||||
<Stop offset="1" stopColor={colors.marigoldTint} stopOpacity="0" />
|
||||
</RadialGradient>
|
||||
</Defs>
|
||||
<Rect width="100%" height="100%" fill="url(#authGlow)" />
|
||||
</Svg>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 24 }]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.column}>
|
||||
<Reveal index={0} style={styles.topline}>
|
||||
<LanguageChip />
|
||||
</Reveal>
|
||||
<View style={styles.brand}>
|
||||
<AuthLogo />
|
||||
<Reveal index={1}>
|
||||
<Text style={styles.wordmark}>PawFeed</Text>
|
||||
</Reveal>
|
||||
<Reveal index={2}>
|
||||
<Text style={styles.tagline}>{t("auth.tagline")}</Text>
|
||||
</Reveal>
|
||||
</View>
|
||||
<View style={styles.form}>{children}</View>
|
||||
{footer}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthDivider({ label }: { label: string }) {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
return (
|
||||
<View style={styles.divider}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>{label}</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type FooterLinkProps = { prompt: string; action: string; onPress: () => void };
|
||||
|
||||
export function AuthFooterLink({ prompt, action, onPress }: FooterLinkProps) {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
return (
|
||||
<Pressable onPress={onPress} hitSlop={12} style={styles.footer} accessibilityRole="link">
|
||||
<Text style={styles.footerText}>
|
||||
{prompt} <Text style={styles.footerAction}>{action}</Text>
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
flex: { flex: 1, backgroundColor: colors.porchWhite },
|
||||
glow: { position: "absolute", top: 0, left: 0 },
|
||||
container: { flexGrow: 1, paddingHorizontal: SIDE_GUTTER, alignItems: "center" },
|
||||
// maxWidth keeps a phone-width column on tablets.
|
||||
column: { flexGrow: 1, width: "100%", maxWidth: 420 },
|
||||
topline: { alignItems: "flex-end" },
|
||||
brand: { alignItems: "center", marginTop: 16, marginBottom: 32 },
|
||||
wordmark: { ...typography.display, fontSize: 32, lineHeight: 38 },
|
||||
tagline: { ...typography.body, color: colors.quietGray, marginTop: 2 },
|
||||
form: { marginTop: "auto", gap: 10 },
|
||||
divider: { flexDirection: "row", alignItems: "center", gap: 12, marginVertical: 4 },
|
||||
dividerLine: { flex: 1, height: StyleSheet.hairlineWidth, backgroundColor: colors.hairlineGray },
|
||||
dividerText: { ...typography.caption, color: colors.quietGray },
|
||||
footer: { marginTop: 18, alignSelf: "center" },
|
||||
footerText: { ...typography.body, color: colors.quietGray },
|
||||
footerAction: { fontFamily: typography.title.fontFamily, color: colors.marigoldDeep },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Modal, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react-native";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { useLanguagePreference } from "../LanguageSwitcher";
|
||||
import { fontFamily, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
const CHIP_HEIGHT = 34;
|
||||
const MENU_GAP = 6;
|
||||
|
||||
/**
|
||||
* Compact language picker for the auth screens. The menu is a Modal on
|
||||
* purpose: Android's back gesture then closes the menu (onRequestClose)
|
||||
* instead of leaving the app from the root sign-in screen.
|
||||
*/
|
||||
export function LanguageChip() {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { preference, languageOptions, handleSelect } = useLanguagePreference();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [chipBottom, setChipBottom] = useState(insets.top + CHIP_HEIGHT);
|
||||
const chipRef = useRef<View>(null);
|
||||
|
||||
// Measured on press, not onLayout: the entrance animation is still moving the chip at mount.
|
||||
const open = () => {
|
||||
if (!chipRef.current) return setIsOpen(true);
|
||||
chipRef.current.measureInWindow((_x, y, _w, h) => {
|
||||
setChipBottom(y + h);
|
||||
setIsOpen(true);
|
||||
});
|
||||
};
|
||||
|
||||
const current = languageOptions.find((o) => o.value === preference) ?? languageOptions[2];
|
||||
const Chevron = isOpen ? ChevronUp : ChevronDown;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
ref={chipRef}
|
||||
onPress={open}
|
||||
android_ripple={{ color: colors.marigoldTint, borderless: false }}
|
||||
style={styles.chip}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: isOpen }}
|
||||
>
|
||||
<Text style={styles.flag}>{current.flag}</Text>
|
||||
<Text style={styles.chipText}>{current.short}</Text>
|
||||
<Chevron color={colors.quietGray} size={14} />
|
||||
</Pressable>
|
||||
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
statusBarTranslucent
|
||||
navigationBarTranslucent
|
||||
onRequestClose={() => setIsOpen(false)}
|
||||
>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={() => setIsOpen(false)} accessibilityLabel="close" />
|
||||
<View style={[styles.menu, { top: chipBottom + MENU_GAP }]}>
|
||||
{languageOptions.map((option) => {
|
||||
const isActive = option.value === preference;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.label}
|
||||
onPress={() => {
|
||||
handleSelect(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
android_ripple={{ color: colors.marigoldTint }}
|
||||
style={[styles.item, isActive && styles.itemActive]}
|
||||
>
|
||||
<Text style={styles.flag}>{option.flag}</Text>
|
||||
<Text style={[styles.itemText, isActive && styles.itemTextActive]}>{option.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
chip: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
height: CHIP_HEIGHT,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: CHIP_HEIGHT / 2,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
overflow: "hidden",
|
||||
},
|
||||
flag: { fontSize: 14 },
|
||||
chipText: { fontFamily: fontFamily.semiBold, fontSize: 13, color: colors.ink },
|
||||
menu: {
|
||||
position: "absolute",
|
||||
right: 28,
|
||||
minWidth: 180,
|
||||
padding: 6,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
elevation: 6,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
},
|
||||
item: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingVertical: 11, borderRadius: 9, overflow: "hidden" },
|
||||
itemActive: { backgroundColor: colors.marigoldTint },
|
||||
itemText: { fontFamily: fontFamily.regular, fontSize: 14, color: colors.ink },
|
||||
itemTextActive: { fontFamily: fontFamily.semiBold, color: colors.marigoldDeep },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { Button } from "../Button";
|
||||
import { CaretakerRedeem } from "./CaretakerRedeem";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
|
||||
type Props = { onApproved: () => void; onBack: () => void };
|
||||
|
||||
/** Onboarding-Weg „Ich betreue ein Tier mit“ statt eigenes Tier anlegen (web: onboarding/pet/page.tsx). */
|
||||
export function CaretakerOnboarding({ onApproved, onBack }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
return (
|
||||
<View style={styles.wrap}>
|
||||
<Text style={styles.title}>{t("caretaker.onboardingTitle")}</Text>
|
||||
<Text style={styles.desc}>{t("caretaker.onboardingDesc")}</Text>
|
||||
<CaretakerRedeem onApproved={onApproved} />
|
||||
<Button title={t("caretaker.onboardingBack")} variant="outline" onPress={onBack} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
wrap: { padding: 24, gap: 12 },
|
||||
title: { ...typography.title, fontSize: 20 },
|
||||
desc: { ...typography.body, color: colors.quietGray, marginBottom: 8 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, Alert, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Clock } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { TextField } from "../TextField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
/** Nach „Erneut prüfen“, wenn das Tier freigegeben ist (Onboarding → App). */
|
||||
onApproved?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* „Ich betreue ein Tier mit“ — Port von web src/components/caretaker/CaretakerRedeem.tsx:
|
||||
* Einladungscode einlösen und offene Anfragen zeigen, bis der Haupt-Besitzer freigibt.
|
||||
*/
|
||||
export function CaretakerRedeem({ onApproved }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const [code, setCode] = useState("");
|
||||
const { data: requests } = useQuery(trpc.caretakers.myRequests.queryOptions());
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries(trpc.caretakers.myRequests.queryFilter());
|
||||
const onError = (err: { message: string }) => Alert.alert(err.message || t("caretaker.failed"));
|
||||
const redeem = useMutation(
|
||||
trpc.caretakers.redeem.mutationOptions({
|
||||
onSuccess: async ({ petName }) => {
|
||||
Alert.alert(t("caretaker.requestSent", { name: petName }));
|
||||
setCode("");
|
||||
await refresh();
|
||||
},
|
||||
onError,
|
||||
})
|
||||
);
|
||||
const withdraw = useMutation(trpc.caretakers.leave.mutationOptions({ onSuccess: refresh, onError }));
|
||||
|
||||
const checkAgain = async () => {
|
||||
await refresh();
|
||||
const pets = await queryClient.fetchQuery({ ...trpc.pets.list.queryOptions(), staleTime: 0 });
|
||||
await queryClient.invalidateQueries(trpc.pets.pathFilter());
|
||||
if (pets.length > 0) onApproved?.();
|
||||
else Alert.alert(t("caretaker.stillWaiting"));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.wrap}>
|
||||
{requests?.map((r) => (
|
||||
<Card key={r.petId} style={styles.request}>
|
||||
<View style={styles.requestRow}>
|
||||
<Clock size={16} color={colors.quietGray} />
|
||||
<Text style={styles.body}>{t("caretaker.waiting", { name: r.petName })}</Text>
|
||||
</View>
|
||||
<Button title={t("caretaker.checkAgain")} variant="outline" onPress={checkAgain} />
|
||||
<Button
|
||||
title={t("caretaker.withdraw")}
|
||||
variant="outline"
|
||||
disabled={withdraw.isPending}
|
||||
onPress={() => withdraw.mutate({ petId: r.petId })}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Text style={styles.label}>{t("caretaker.codeLabel")}</Text>
|
||||
<TextField
|
||||
value={code}
|
||||
onChangeText={setCode}
|
||||
maxLength={20}
|
||||
autoCapitalize="characters"
|
||||
autoCorrect={false}
|
||||
placeholder="7K3M-QX9A"
|
||||
/>
|
||||
<Text style={styles.hint}>{t("caretaker.codeHint")}</Text>
|
||||
<Button
|
||||
title={t("caretaker.redeem")}
|
||||
disabled={redeem.isPending || !code.trim()}
|
||||
onPress={() => redeem.mutate({ code })}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
wrap: { gap: 8 },
|
||||
request: { gap: 8 },
|
||||
requestRow: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
body: { ...typography.body, flex: 1 },
|
||||
label: { ...typography.caption, marginTop: 4 },
|
||||
hint: { ...typography.caption },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, Alert, Share, StyleSheet } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Users } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useActivePet } from "../../context/ActivePetContext";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { Button } from "../Button";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { isoToDisplayDate } from "../../lib/date-format";
|
||||
|
||||
const WEB_ORIGIN = "https://pawfeed.org";
|
||||
|
||||
type Account = { name: string | null; email: string | null } | null;
|
||||
|
||||
function accountLabel(account: Account, fallback: string): string {
|
||||
if (!account) return fallback;
|
||||
if (account.name && account.email) return `${account.name} (${account.email})`;
|
||||
return account.name ?? account.email ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mitbetreuer in „Profil bearbeiten“ — Port von web
|
||||
* src/components/caretaker/CaretakerSettings.tsx. Haupt-Besitzer: einladen
|
||||
* (Code teilen), freigeben, ablehnen, entfernen. Mitbetreuer: beenden.
|
||||
* Teilen statt Kopieren: Share braucht kein neues natives Modul (OTA-fähig).
|
||||
*/
|
||||
export function CaretakerSettings({ petId, petName }: { petId: string; petName: string }) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const { activePetId, clearActivePetId } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const { data } = useQuery(trpc.caretakers.status.queryOptions({ petId }));
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries(trpc.caretakers.status.queryFilter({ petId }));
|
||||
const onError = (err: { message: string }) => Alert.alert(err.message || t("caretaker.failed"));
|
||||
const createInvite = useMutation(trpc.caretakers.createInvite.mutationOptions({ onSuccess: refresh, onError }));
|
||||
const approve = useMutation(
|
||||
trpc.caretakers.approve.mutationOptions({
|
||||
onSuccess: async () => {
|
||||
Alert.alert(t("caretaker.approved"));
|
||||
await refresh();
|
||||
},
|
||||
onError,
|
||||
})
|
||||
);
|
||||
const remove = useMutation(trpc.caretakers.remove.mutationOptions({ onSuccess: refresh, onError }));
|
||||
const leave = useMutation(
|
||||
trpc.caretakers.leave.mutationOptions({
|
||||
onSuccess: async () => {
|
||||
if (activePetId === petId) clearActivePetId();
|
||||
await queryClient.invalidateQueries();
|
||||
router.replace("/(app)/(tabs)/pets");
|
||||
},
|
||||
onError,
|
||||
})
|
||||
);
|
||||
|
||||
if (!data) return null;
|
||||
const c = data.caretaker;
|
||||
|
||||
const confirm = (title: string, message: string, action: string, onPress: () => void) =>
|
||||
Alert.alert(title, message, [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: action, style: "destructive", onPress },
|
||||
]);
|
||||
|
||||
const shareCode = (code: string) =>
|
||||
Share.share({ message: t("caretaker.shareMessage", { name: petName, code, link: `${WEB_ORIGIN}/mitbetreuen/${code}` }) });
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.headingRow}>
|
||||
<Users size={16} color={colors.ink} />
|
||||
<Text style={styles.heading}>{t("caretaker.heading")}</Text>
|
||||
</View>
|
||||
|
||||
{data.role === "CARETAKER" ? (
|
||||
<>
|
||||
<Text style={styles.hint}>{t("caretaker.youCoCare", { name: petName })}</Text>
|
||||
<Text style={styles.hint}>{t("caretaker.caretakerLimits")}</Text>
|
||||
<Button
|
||||
title={t("caretaker.leave")}
|
||||
variant="outline"
|
||||
disabled={leave.isPending}
|
||||
onPress={() =>
|
||||
confirm(t("caretaker.leave"), t("caretaker.leaveConfirm", { name: petName }), t("caretaker.leave"), () =>
|
||||
leave.mutate({ petId })
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : !c ? (
|
||||
<>
|
||||
<Text style={styles.hint}>{t("caretaker.intro", { name: petName })}</Text>
|
||||
<Button
|
||||
title={t("caretaker.createInvite")}
|
||||
variant="outline"
|
||||
disabled={createInvite.isPending}
|
||||
onPress={() => createInvite.mutate({ petId })}
|
||||
/>
|
||||
</>
|
||||
) : c.status === "INVITED" && c.inviteCode ? (
|
||||
<>
|
||||
<Text style={styles.hint}>{t("caretaker.inviteHint", { name: petName })}</Text>
|
||||
<Text style={styles.code} selectable>
|
||||
{c.inviteCode}
|
||||
</Text>
|
||||
{c.inviteExpiresAt ? (
|
||||
<Text style={styles.hint}>
|
||||
{t("caretaker.validUntil", { date: isoToDisplayDate(new Date(c.inviteExpiresAt).toISOString().slice(0, 10)) })}
|
||||
</Text>
|
||||
) : null}
|
||||
<Button title={t("caretaker.share")} onPress={() => shareCode(c.inviteCode!)} />
|
||||
<Button
|
||||
title={t("caretaker.newCode")}
|
||||
variant="outline"
|
||||
disabled={createInvite.isPending}
|
||||
onPress={() => createInvite.mutate({ petId })}
|
||||
/>
|
||||
<Button
|
||||
title={t("caretaker.revokeInvite")}
|
||||
variant="outline"
|
||||
disabled={remove.isPending}
|
||||
onPress={() => remove.mutate({ petId })}
|
||||
/>
|
||||
</>
|
||||
) : c.status === "PENDING" ? (
|
||||
<>
|
||||
<Text style={styles.body}>{t("caretaker.requestFrom", { who: accountLabel(c.account, t("caretaker.unknownAccount")) })}</Text>
|
||||
<Text style={styles.hint}>{t("caretaker.requestHint", { name: petName })}</Text>
|
||||
<Button title={t("caretaker.approve")} disabled={approve.isPending} onPress={() => approve.mutate({ petId })} />
|
||||
<Button
|
||||
title={t("caretaker.reject")}
|
||||
variant="outline"
|
||||
disabled={remove.isPending}
|
||||
onPress={() => remove.mutate({ petId })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.body}>
|
||||
{t("caretaker.activeWith", { who: accountLabel(c.account, t("caretaker.unknownAccount")), name: petName })}
|
||||
</Text>
|
||||
<Button
|
||||
title={t("caretaker.remove")}
|
||||
variant="outline"
|
||||
disabled={remove.isPending}
|
||||
onPress={() =>
|
||||
confirm(t("caretaker.remove"), t("caretaker.removeConfirm", { name: petName }), t("caretaker.remove"), () =>
|
||||
remove.mutate({ petId })
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
section: { marginTop: 24, gap: 8 },
|
||||
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
heading: { ...typography.title, fontSize: 16 },
|
||||
hint: { ...typography.caption },
|
||||
body: { ...typography.body },
|
||||
code: {
|
||||
...typography.title,
|
||||
fontSize: 22,
|
||||
letterSpacing: 3,
|
||||
textAlign: "center",
|
||||
paddingVertical: 10,
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -147,7 +147,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
},
|
||||
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
|
||||
privacyLink: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 20 },
|
||||
privacyLink: { ...typography.caption, color: colors.marigoldDeep, marginBottom: 20 },
|
||||
ctaRow: { flexDirection: "row", gap: 10 },
|
||||
ctaButton: { flex: 1 },
|
||||
manageBox: {
|
||||
@@ -163,7 +163,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
},
|
||||
manageText: { flex: 1 },
|
||||
manageTitle: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 },
|
||||
manageDesc: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 2 },
|
||||
manageDesc: { ...typography.caption, marginTop: 2 },
|
||||
revokeLink: { ...typography.body, color: colors.alertRed, fontFamily: typography.title.fontFamily, fontSize: 13 },
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { healthSectionStyles } from "./healthSectionStyles";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { addYearsToDisplayDate, displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
|
||||
@@ -143,21 +144,14 @@ export function VaccineSection({ petId }: { petId: string }) {
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
const base = healthSectionStyles(colors);
|
||||
return StyleSheet.create({
|
||||
section: { gap: 10 },
|
||||
sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
sectionTitle: { ...typography.title, fontSize: 16 },
|
||||
form: { gap: 10 },
|
||||
empty: { ...typography.body, color: colors.quietGray },
|
||||
row: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
...base,
|
||||
overdue: { color: colors.alertRed },
|
||||
presetRow: { flexDirection: "row", gap: 8 },
|
||||
preset: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: colors.marigoldTint },
|
||||
presetDisabled: { opacity: 0.4 },
|
||||
presetText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray },
|
||||
presetText: { ...typography.caption, color: colors.ink },
|
||||
hint: { ...typography.caption, color: colors.quietGray },
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { healthSectionStyles } from "./healthSectionStyles";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
|
||||
@@ -112,16 +113,9 @@ export function VetVisitSection({ petId }: { petId: string }) {
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
const base = healthSectionStyles(colors);
|
||||
return StyleSheet.create({
|
||||
section: { gap: 10 },
|
||||
sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
sectionTitle: { ...typography.title, fontSize: 16 },
|
||||
form: { gap: 10 },
|
||||
empty: { ...typography.body, color: colors.quietGray },
|
||||
row: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
...base,
|
||||
rowNotes: { ...typography.body, fontSize: 13, marginTop: 2 },
|
||||
});
|
||||
}
|
||||
@@ -193,10 +193,10 @@ function makeStyles(colors: ColorTokens) {
|
||||
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", gap: 12 },
|
||||
eyebrow: { ...typography.label },
|
||||
readout: { ...typography.title, fontSize: 20, marginTop: 2 },
|
||||
readoutDate: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 12 },
|
||||
readoutDate: { ...typography.caption, fontSize: 12 },
|
||||
deltaChip: { alignItems: "flex-end" },
|
||||
deltaText: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
deltaCaption: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
deltaCaption: { ...typography.caption, fontSize: 11 },
|
||||
plot: { height: CHART_HEIGHT },
|
||||
});
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { healthSectionStyles } from "./healthSectionStyles";
|
||||
import { WeightChart } from "./WeightChart";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import type { ColorTokens } from "../../theme/tokens";
|
||||
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
|
||||
/** Port of web's health.ts weightLog CRUD — see src/app/(app)/pets/[petId]/health/page.tsx's Gewicht tab. */
|
||||
@@ -106,16 +107,9 @@ export function WeightLogSection({ petId }: { petId: string }) {
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
const base = healthSectionStyles(colors);
|
||||
return StyleSheet.create({
|
||||
section: { gap: 10 },
|
||||
sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
sectionTitle: { ...typography.title, fontSize: 16 },
|
||||
form: { gap: 10 },
|
||||
empty: { ...typography.body, color: colors.quietGray },
|
||||
row: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
...base,
|
||||
row: { ...base.row, alignItems: "center" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StyleSheet } from "react-native";
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
/** Shared chrome of the health sections (vaccines, vet visits, weight): header, form, list rows. */
|
||||
export function healthSectionStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
section: { gap: 10 },
|
||||
sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
sectionTitle: { ...typography.title, fontSize: 16 },
|
||||
form: { gap: 10 },
|
||||
empty: { ...typography.body, color: colors.quietGray },
|
||||
row: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
rowText: { flex: 1, gap: 2 },
|
||||
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowSecondary: { ...typography.caption },
|
||||
});
|
||||
}
|
||||
@@ -62,25 +62,29 @@ export function LostReportSettings({ petId, petName, isDeceased }: Props) {
|
||||
const [form, setForm] = useState<Form | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const done = (message: string) => {
|
||||
// Längere Texte immer als Nachricht, nie als Titel: Android kürzt Dialog-Titel auf zwei Zeilen.
|
||||
const done = (title: string, message?: string) => {
|
||||
setForm(null);
|
||||
setError(null);
|
||||
void queryClient.invalidateQueries();
|
||||
Alert.alert(message);
|
||||
Alert.alert(title, message);
|
||||
};
|
||||
const onError = (err: { message: string }) => Alert.alert(err.message || t("lostReport.saveFailed"));
|
||||
const onError = (err: { message: string }) => Alert.alert(t("lostReport.errorTitle"), err.message || t("lostReport.saveFailed"));
|
||||
const create = useMutation(
|
||||
trpc.lostReports.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setIsOpen(false);
|
||||
done(t("lostReport.created", { name: petName }));
|
||||
done(t("lostReport.createdTitle"), t("lostReport.created", { name: petName }));
|
||||
},
|
||||
onError,
|
||||
})
|
||||
);
|
||||
const update = useMutation(trpc.lostReports.update.mutationOptions({ onSuccess: () => done(t("lostReport.updated")), onError }));
|
||||
const markFound = useMutation(
|
||||
trpc.lostReports.markFound.mutationOptions({ onSuccess: () => done(t("lostReport.foundToast", { name: petName })), onError })
|
||||
trpc.lostReports.markFound.mutationOptions({
|
||||
onSuccess: () => done(t("lostReport.foundTitle"), t("lostReport.foundToast", { name: petName })),
|
||||
onError,
|
||||
})
|
||||
);
|
||||
|
||||
if (isDeceased || isPending) return null;
|
||||
@@ -262,7 +266,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
teaser: { gap: 8, padding: 12, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.hairlineGray, marginBottom: 8 },
|
||||
teaserText: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
teaserText: { ...typography.caption },
|
||||
teaserButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -277,8 +281,8 @@ function makeStyles(colors: ColorTokens) {
|
||||
card: { gap: 8, padding: 12, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.alertRedTint, marginBottom: 8 },
|
||||
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
heading: { ...typography.title, fontSize: 16, flexShrink: 1 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
hint: { ...typography.caption },
|
||||
label: { ...typography.caption, marginTop: 4 },
|
||||
link: { ...typography.body, textDecorationLine: "underline" },
|
||||
row: { flexDirection: "row", gap: 8 },
|
||||
flex: { flex: 1 },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTheme } from "../../context/ThemeContext";
|
||||
import { Button } from "../Button";
|
||||
import { RedeemCodeForm } from "../referrals/RedeemCodeForm";
|
||||
import { radii, getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
import { StepScaffold } from "./flow/StepScaffold";
|
||||
|
||||
const SOURCES = [
|
||||
"FRIENDS",
|
||||
@@ -46,9 +47,16 @@ export function AcquisitionStep({ canRedeem, onDone }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{t("referrals.onboardingTitle")}</Text>
|
||||
<Text style={styles.subtitle}>{t("referrals.onboardingSubtitle")}</Text>
|
||||
<StepScaffold
|
||||
title={t("referrals.onboardingTitle")}
|
||||
subtitle={t("referrals.onboardingSubtitle")}
|
||||
footer={
|
||||
<>
|
||||
<Button title={t("common.continue")} isLoading={saveSource.isPending} onPress={handleContinue} style={styles.cta} />
|
||||
<Button title={t("common.skip")} variant="outline" onPress={onDone} style={styles.skip} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
<View style={styles.chips} accessibilityRole="radiogroup">
|
||||
{SOURCES.map((option) => {
|
||||
@@ -82,18 +90,13 @@ export function AcquisitionStep({ canRedeem, onDone }: Props) {
|
||||
</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>
|
||||
</StepScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -102,6 +105,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
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 },
|
||||
cta: { height: 54, borderRadius: 15 },
|
||||
skip: { height: 48, borderRadius: 15, marginTop: 6 },
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||
import { View, Text, Pressable, StyleSheet, Switch } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Bug, Check } from "lucide-react-native";
|
||||
import { EyeOff } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { Button } from "../Button";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { fontFamily, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { StepScaffold } from "./flow/StepScaffold";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
type Props = { onContinue: () => void };
|
||||
@@ -30,61 +31,62 @@ export function ArachnophobiaOptIn({ onContinue }: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.titleRow}>
|
||||
<Bug color={colors.marigoldOrange} size={20} />
|
||||
<Text style={styles.title}>{t("onboarding.arachnophobiaTitle")}</Text>
|
||||
</View>
|
||||
<Text style={styles.subtitle}>{t("onboarding.arachnophobiaSubtitle")}</Text>
|
||||
|
||||
<Pressable style={styles.checkboxRow} onPress={() => setHideSpiders((v) => !v)}>
|
||||
<View style={[styles.checkbox, hideSpiders && styles.checkboxChecked]}>
|
||||
{hideSpiders && <Check color={colors.pureWhite} size={14} />}
|
||||
<StepScaffold
|
||||
title={t("onboarding.spiderTitle")}
|
||||
subtitle={t("onboarding.spiderSubtitle")}
|
||||
footer={
|
||||
<Button
|
||||
title={t("common.continue")}
|
||||
isLoading={setPreferenceMutation.isPending}
|
||||
onPress={() => setPreferenceMutation.mutate({ hideSpiders })}
|
||||
style={styles.cta}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.card}
|
||||
onPress={() => setHideSpiders((v) => !v)}
|
||||
accessibilityRole="switch"
|
||||
accessibilityState={{ checked: hideSpiders }}
|
||||
>
|
||||
<View style={styles.icon}>
|
||||
<EyeOff color={colors.marigoldDeep} size={20} />
|
||||
</View>
|
||||
<Text style={styles.checkboxLabel}>{t("onboarding.arachnophobiaCheckboxLabel")}</Text>
|
||||
<View style={styles.cardText}>
|
||||
<Text style={styles.cardTitle}>{t("onboarding.spiderToggle")}</Text>
|
||||
<Text style={styles.cardSub}>{t("onboarding.spiderWhere")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={hideSpiders}
|
||||
onValueChange={setHideSpiders}
|
||||
trackColor={{ false: colors.hairlineGray, true: colors.marigoldOrange }}
|
||||
thumbColor="#ffffff"
|
||||
ios_backgroundColor={colors.hairlineGray}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.hint}>{t("onboarding.arachnophobiaHint")}</Text>
|
||||
|
||||
<Button
|
||||
title={setPreferenceMutation.isPending ? "…" : t("common.continue")}
|
||||
disabled={setPreferenceMutation.isPending}
|
||||
onPress={() => setPreferenceMutation.mutate({ hideSpiders })}
|
||||
style={styles.cta}
|
||||
/>
|
||||
</View>
|
||||
</StepScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
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: {
|
||||
card: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
padding: 14,
|
||||
borderRadius: radii.xl,
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: 16,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
marginBottom: 16,
|
||||
backgroundColor: colors.pureWhite,
|
||||
},
|
||||
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 },
|
||||
icon: { width: 44, height: 44, borderRadius: 13, backgroundColor: colors.marigoldTint, alignItems: "center", justifyContent: "center" },
|
||||
cardText: { flex: 1 },
|
||||
cardTitle: { fontFamily: fontFamily.semiBold, fontSize: 15, color: colors.ink },
|
||||
cardSub: { ...typography.body, fontSize: 13, color: colors.quietGray },
|
||||
hint: { ...typography.body, fontSize: 13, color: colors.quietGray, marginTop: 4 },
|
||||
cta: { height: 54, borderRadius: 15 },
|
||||
});
|
||||
}
|
||||
@@ -2,28 +2,29 @@ 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";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ImagePlus, Camera } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { uploadImageToSupabase } from "../../lib/upload";
|
||||
import { resizeForUpload } from "../../lib/resize-image";
|
||||
import { PetAvatar } from "../PetAvatar";
|
||||
import { uploadPickedImage } from "../../lib/upload";
|
||||
import { Button } from "../Button";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { fontFamily, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { StepScaffold } from "./flow/StepScaffold";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
type Props = {
|
||||
petId: string;
|
||||
petName: string;
|
||||
onDone: () => void;
|
||||
/** Called with the local photo URI once uploaded, or without one when skipped. */
|
||||
onDone: (uploadedPhotoUri?: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of web's onboarding avatar step (AvatarUpload, invoked from
|
||||
* onboarding/pet/page.tsx once createdPetId exists). Uses the media router
|
||||
* (not posts/stories) — media.getPresignedUrl scopes keys to `avatars/`.
|
||||
* "Skip for now" is always available, matching web: an avatar is never
|
||||
* required to finish onboarding.
|
||||
* An avatar is never required: "Done" finishes onboarding with or without a
|
||||
* photo. No separate "Skip for now" link — Apple (5.1.1(iv)) read it as a
|
||||
* way to dodge a permission prompt.
|
||||
*/
|
||||
export function AvatarStep({ petId, petName, onDone }: Props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,10 +32,11 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const trpc = useTRPC();
|
||||
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
|
||||
const [uploadedKey, setUploadedKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const getPresignedUrlMutation = useMutation(trpc.media.getPresignedUrl.mutationOptions());
|
||||
const queryClient = useQueryClient();
|
||||
const [uploadedUri, setUploadedUri] = useState<string | undefined>(undefined);
|
||||
const confirmUploadMutation = useMutation(trpc.media.confirmUpload.mutationOptions());
|
||||
|
||||
const pickAndUpload = useCallback(
|
||||
@@ -46,28 +48,21 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const resized = await resizeForUpload(picked.uri, picked.width, picked.height);
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
const { key } = await uploadPickedImage(picked, (file) => getPresignedUrlMutation.mutateAsync({ petId, ...file }));
|
||||
await confirmUploadMutation.mutateAsync({ petId, key });
|
||||
setUploadedKey(key);
|
||||
setUploadedUri(picked.uri);
|
||||
// Lists still showed the letter avatar after onboarding until the app restarted.
|
||||
queryClient.invalidateQueries({ queryKey: trpc.pets.list.queryOptions().queryKey });
|
||||
} catch {
|
||||
setError(t("onboarding.uploadError"));
|
||||
}
|
||||
},
|
||||
[petId, getPresignedUrlMutation, confirmUploadMutation]
|
||||
[petId, getPresignedUrlMutation, confirmUploadMutation, queryClient, trpc, t]
|
||||
);
|
||||
|
||||
const pickFromGallery = useCallback(async () => {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert(t("onboarding.galleryPermissionDenied"));
|
||||
return;
|
||||
}
|
||||
// Kein Berechtigungsdialog: der System-Bildwähler (iOS PHPicker, Android Photo Picker)
|
||||
// braucht keine Freigabe der Fotomediathek (Apple 5.1.1(iv), 07.10.).
|
||||
await pickAndUpload(() => ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 }));
|
||||
}, [pickAndUpload]);
|
||||
|
||||
@@ -78,84 +73,100 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
|
||||
return;
|
||||
}
|
||||
await pickAndUpload(() => ImagePicker.launchCameraAsync({ quality: 0.8 }));
|
||||
}, [pickAndUpload]);
|
||||
}, [pickAndUpload, t]);
|
||||
|
||||
const isUploading = getPresignedUrlMutation.isPending || confirmUploadMutation.isPending;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{t("onboarding.avatarTitle")}</Text>
|
||||
<Text style={styles.subtitle}>{t("onboarding.avatarSubtitle")}</Text>
|
||||
|
||||
{asset ? (
|
||||
<View style={styles.previewWrap}>
|
||||
<RNImage source={{ uri: asset.uri }} style={styles.preview} />
|
||||
{isUploading && (
|
||||
<View style={styles.previewOverlay}>
|
||||
<ActivityIndicator color={colors.pureWhite} />
|
||||
</View>
|
||||
<StepScaffold
|
||||
title={t("onboarding.photoTitle", { name: petName })}
|
||||
highlight={petName}
|
||||
subtitle={t("onboarding.photoSubtitle", { name: petName })}
|
||||
footer={
|
||||
// Ohne „Überspringen“-Link (Apple 5.1.1(iv)): „Weiter“ führt auch ohne Foto weiter.
|
||||
<Button title={t("common.continue")} onPress={() => onDone(uploadedUri)} disabled={isUploading} style={styles.cta} />
|
||||
}
|
||||
>
|
||||
<View style={styles.photoRing}>
|
||||
<View style={styles.photo}>
|
||||
{asset ? (
|
||||
<>
|
||||
<RNImage source={{ uri: asset.uri }} style={styles.preview} />
|
||||
{isUploading && (
|
||||
<View style={styles.previewOverlay}>
|
||||
<ActivityIndicator color="#ffffff" />
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.letter}>{petName.trim().charAt(0).toUpperCase()}</Text>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<PetAvatar name={petName} avatarKey={null} size="lg" />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<View style={styles.pickerRow}>
|
||||
<Pressable style={styles.pickerButton} onPress={pickFromGallery} disabled={isUploading}>
|
||||
<Pressable style={styles.pickerButton} onPress={pickFromGallery} disabled={isUploading} android_ripple={{ color: colors.marigoldTint }}>
|
||||
<ImagePlus color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.pickerButton} onPress={takePhoto} disabled={isUploading}>
|
||||
<Pressable style={styles.pickerButton} onPress={takePhoto} disabled={isUploading} android_ripple={{ color: colors.marigoldTint }}>
|
||||
<Camera color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Button title={t("common.done")} onPress={onDone} disabled={isUploading} style={styles.cta} />
|
||||
{!uploadedKey && (
|
||||
<Pressable onPress={onDone} disabled={isUploading} hitSlop={8}>
|
||||
<Text style={styles.skipLink}>{t("onboarding.skipLink")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</StepScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
const PHOTO_SIZE = 156;
|
||||
|
||||
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 },
|
||||
photoRing: {
|
||||
alignSelf: "center",
|
||||
padding: 8,
|
||||
borderRadius: PHOTO_SIZE,
|
||||
borderWidth: 2,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.marigoldOrange,
|
||||
marginTop: 6,
|
||||
},
|
||||
photo: {
|
||||
width: PHOTO_SIZE,
|
||||
height: PHOTO_SIZE,
|
||||
borderRadius: PHOTO_SIZE / 2,
|
||||
overflow: "hidden",
|
||||
backgroundColor: colors.marigoldOrange,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
letter: { fontFamily: fontFamily.extraBold, fontSize: 60, color: "#ffffff" },
|
||||
preview: { width: "100%", height: "100%" },
|
||||
previewOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
...StyleSheet.absoluteFill,
|
||||
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" },
|
||||
pickerRow: { flexDirection: "row", gap: 10, marginTop: 24 },
|
||||
pickerButton: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
height: 48,
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
overflow: "hidden",
|
||||
},
|
||||
pickerText: { ...typography.body, color: colors.ink },
|
||||
cta: { marginTop: 20, alignSelf: "stretch" },
|
||||
skipLink: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray, marginTop: 12 },
|
||||
pickerText: { fontFamily: fontFamily.semiBold, fontSize: 15, color: colors.ink },
|
||||
cta: { height: 54, borderRadius: 15 },
|
||||
});
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { View, Text, Pressable, ActivityIndicator, Linking, StyleSheet } from "react-native";
|
||||
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 { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
type Props = { onContinue: () => void };
|
||||
|
||||
/**
|
||||
* Port of web's src/components/legal/OnboardingGuidelines.tsx. Auto-skips
|
||||
* via onContinue() once acknowledged, or if nothing has ever been published
|
||||
* (legal.getPendingUpdate returns null) — matches web's admin-editable
|
||||
* LegalDocumentVersion content, not a hardcoded first-run screen.
|
||||
*/
|
||||
export function OnboardingGuidelines({ onContinue }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: pending, isLoading } = useQuery(trpc.legal.getPendingUpdate.queryOptions());
|
||||
|
||||
const acknowledgeMutation = useMutation(
|
||||
trpc.legal.acknowledge.mutationOptions({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: trpc.legal.getPendingUpdate.queryOptions().queryKey });
|
||||
onContinue();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const hasNothingToAcknowledge = !isLoading && (!pending || pending.length === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNothingToAcknowledge) onContinue();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasNothingToAcknowledge]);
|
||||
|
||||
if (isLoading || hasNothingToAcknowledge) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.titleRow}>
|
||||
<ScrollText color={colors.marigoldOrange} size={20} />
|
||||
<Text style={styles.title}>{t("onboarding.guidelinesTitle")}</Text>
|
||||
</View>
|
||||
<Text style={styles.subtitle}>{t("onboarding.guidelinesSubtitle")}</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
{pending!.map((version) => (
|
||||
<Text key={version.id} style={styles.summary}>
|
||||
{version.changeSummary}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/nutzungsbedingungen`)}>
|
||||
<Text style={styles.link}>{t("onboarding.termsLink")}</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)}>
|
||||
<Text style={styles.link}>{t("onboarding.privacyLink")}</Text>
|
||||
</Pressable>
|
||||
|
||||
<Button
|
||||
title={acknowledgeMutation.isPending ? "…" : t("onboarding.guidelinesContinue")}
|
||||
disabled={acknowledgeMutation.isPending}
|
||||
onPress={() => acknowledgeMutation.mutate()}
|
||||
style={styles.cta}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,152 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { TextField } from "../TextField";
|
||||
import { Button } from "../Button";
|
||||
import { OptionPicker, type PickerOption } from "../OptionPicker";
|
||||
import { BreedPicker } from "../pet/BreedPicker";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
type Props = { onSuccess: (pet: { id: string; name: string }) => void };
|
||||
|
||||
/**
|
||||
* Port of web's src/components/pet/PetForm.tsx in create mode — name,
|
||||
* nickname, species/breed (data-driven, never hardcoded per CLAUDE.md),
|
||||
* bio, adoption story. Avatar upload happens in a separate step after the
|
||||
* pet exists (see onboarding/pet.tsx), matching web's two-phase flow.
|
||||
*/
|
||||
export function PetCreateForm({ onSuccess }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const [name, setName] = useState("");
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [speciesId, setSpeciesId] = useState<string | null>(null);
|
||||
const [speciesName, setSpeciesName] = useState<string | null>(null);
|
||||
const [breedId, setBreedId] = useState<string | null>(null);
|
||||
const [breedName, setBreedName] = useState<string | null>(null);
|
||||
const [bio, setBio] = useState("");
|
||||
const [adoptionStory, setAdoptionStory] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation(
|
||||
trpc.pets.create.mutationOptions({
|
||||
onSuccess: (pet) => {
|
||||
// Ohne dieses Invalidate bleibt ActivePetContext.pets (trpc.pets.list)
|
||||
// auf dem alten "keine Tiere"-Stand stehen, bis die App neu startet —
|
||||
// OnboardingRedirect wirft dann sofort wieder auf /onboarding/pet
|
||||
// zurück, sobald man nach dem Avatar-Schritt in (app) landet.
|
||||
queryClient.invalidateQueries({ queryKey: trpc.pets.list.queryOptions().queryKey });
|
||||
onSuccess({ id: pet.id, name: pet.name });
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
})
|
||||
);
|
||||
|
||||
const canSubmit = name.trim().length > 0 && !!speciesId && !createMutation.isPending;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{t("petForm.createTitle")}</Text>
|
||||
<Text style={styles.subtitle}>{t("petForm.createSubtitle")}</Text>
|
||||
|
||||
<Text style={styles.label}>{t("petForm.name")}</Text>
|
||||
<TextField placeholder={t("petForm.namePlaceholder")} value={name} onChangeText={setName} maxLength={30} />
|
||||
|
||||
<Text style={styles.label}>{t("petForm.nickname")}</Text>
|
||||
<TextField placeholder={t("petForm.nicknamePlaceholder")} value={nickname} onChangeText={setNickname} maxLength={30} />
|
||||
|
||||
<Text style={styles.label}>{t("petForm.species")}</Text>
|
||||
<SpeciesPicker
|
||||
selectedName={speciesName}
|
||||
onSelect={(option) => {
|
||||
setSpeciesId(option.id);
|
||||
setSpeciesName(option.name);
|
||||
setBreedId(null);
|
||||
setBreedName(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>{t("petForm.breed")}</Text>
|
||||
<BreedPicker speciesId={speciesId} selectedName={breedName} onSelect={(o) => { setBreedId(o.id); setBreedName(o.name); }} />
|
||||
|
||||
<Text style={styles.label}>{t("petForm.bio")}</Text>
|
||||
<TextField
|
||||
placeholder={t("petForm.bioPlaceholder")}
|
||||
value={bio}
|
||||
onChangeText={setBio}
|
||||
maxLength={150}
|
||||
multiline
|
||||
style={styles.multiline}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>{t("petForm.adoptionStory")}</Text>
|
||||
<TextField
|
||||
placeholder={t("petForm.adoptionStoryPlaceholder")}
|
||||
value={adoptionStory}
|
||||
onChangeText={setAdoptionStory}
|
||||
maxLength={500}
|
||||
multiline
|
||||
style={styles.multiline}
|
||||
/>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button
|
||||
title={createMutation.isPending ? "…" : t("petForm.createButton")}
|
||||
disabled={!canSubmit}
|
||||
onPress={() =>
|
||||
createMutation.mutate({
|
||||
name: name.trim(),
|
||||
nickname: nickname.trim() || undefined,
|
||||
speciesId: speciesId as string,
|
||||
breedId: breedId ?? undefined,
|
||||
bio: bio.trim() || undefined,
|
||||
adoptionStory: adoptionStory.trim() || undefined,
|
||||
})
|
||||
}
|
||||
style={styles.cta}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeciesPicker({ selectedName, onSelect }: { selectedName: string | null; onSelect: (o: PickerOption) => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: species = [], isLoading } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: i18n.language }));
|
||||
|
||||
return (
|
||||
<OptionPicker
|
||||
open={open}
|
||||
onToggle={() => setOpen((v) => !v)}
|
||||
selectedName={selectedName}
|
||||
placeholder={t("petForm.selectSpecies")}
|
||||
isLoading={isLoading}
|
||||
options={species}
|
||||
onSelect={(o) => {
|
||||
onSelect(o);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { padding: 24, gap: 6 },
|
||||
title: { ...typography.title, fontSize: 18, marginBottom: 4 },
|
||||
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 10 },
|
||||
multiline: { height: 80, textAlignVertical: "top", paddingTop: 12 },
|
||||
error: { ...typography.body, color: colors.alertRed, marginTop: 8 },
|
||||
cta: { marginTop: 16 },
|
||||
});
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
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";
|
||||
import { ShieldCheck, Check } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { env } from "../../lib/env";
|
||||
import { Button } from "../Button";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
type Props = { onContinue: () => void };
|
||||
|
||||
/**
|
||||
* Port of web's src/components/legal/UploadConsent.tsx — two mandatory,
|
||||
* blocking Willenserklärungen (content rights + data-processing consent,
|
||||
* Gitea #3) shown once before the very first pet can be created. Skips
|
||||
* automatically if already consented (legal.getUploadConsentStatus).
|
||||
*/
|
||||
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);
|
||||
const [dataProcessingChecked, setDataProcessingChecked] = useState(false);
|
||||
|
||||
const { data: status, isLoading } = useQuery(trpc.legal.getUploadConsentStatus.queryOptions());
|
||||
|
||||
const acknowledgeMutation = useMutation(
|
||||
trpc.legal.acknowledgeUploadConsent.mutationOptions({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: trpc.legal.getUploadConsentStatus.queryOptions().queryKey });
|
||||
onContinue();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const alreadyConsented = !isLoading && status?.hasConsented === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (alreadyConsented) onContinue();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [alreadyConsented]);
|
||||
|
||||
if (isLoading || alreadyConsented) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const canContinue = contentRightsChecked && dataProcessingChecked;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.titleRow}>
|
||||
<ShieldCheck color={colors.marigoldOrange} size={20} />
|
||||
<Text style={styles.title}>{t("onboarding.consentTitle")}</Text>
|
||||
</View>
|
||||
<Text style={styles.subtitle}>{t("onboarding.consentSubtitle")}</Text>
|
||||
|
||||
<ConsentCheckbox
|
||||
checked={contentRightsChecked}
|
||||
onToggle={() => setContentRightsChecked((v) => !v)}
|
||||
label={t("onboarding.contentRightsLabel")}
|
||||
/>
|
||||
<ConsentCheckbox
|
||||
checked={dataProcessingChecked}
|
||||
onToggle={() => setDataProcessingChecked((v) => !v)}
|
||||
label={t("onboarding.dataProcessingLabel")}
|
||||
/>
|
||||
|
||||
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)}>
|
||||
<Text style={styles.link}>{t("onboarding.consentPrivacyLink")}</Text>
|
||||
</Pressable>
|
||||
|
||||
<Button
|
||||
title={acknowledgeMutation.isPending ? "…" : t("onboarding.consentContinue")}
|
||||
disabled={!canContinue || acknowledgeMutation.isPending}
|
||||
onPress={() => acknowledgeMutation.mutate()}
|
||||
style={styles.cta}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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]}>
|
||||
{checked && <Check color={colors.pureWhite} size={14} />}
|
||||
</View>
|
||||
<Text style={styles.checkboxLabel}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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,85 @@
|
||||
import { useMemo } from "react";
|
||||
import { StyleSheet, Text, TextInput } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../../context/ThemeContext";
|
||||
import { Button } from "../../Button";
|
||||
import { fontFamily, getTypography, type ColorTokens } from "../../../theme/tokens";
|
||||
import { StepScaffold } from "./StepScaffold";
|
||||
|
||||
const BIO_MAX = 150;
|
||||
const STORY_MAX = 500;
|
||||
|
||||
type Props = {
|
||||
petName: string;
|
||||
bio: string;
|
||||
adoptionStory: string;
|
||||
onChangeBio: (value: string) => void;
|
||||
onChangeAdoptionStory: (value: string) => void;
|
||||
error: string | null;
|
||||
isCreating: boolean;
|
||||
/** Creates the pet — both texts are optional. */
|
||||
onCreate: () => void;
|
||||
};
|
||||
|
||||
/** Step "Erzähl uns von {name}" — optional bio + adoption story, then the pet is created. */
|
||||
export function AboutStep({ petName, bio, adoptionStory, onChangeBio, onChangeAdoptionStory, error, isCreating, onCreate }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
return (
|
||||
<StepScaffold
|
||||
title={t("onboarding.aboutTitle", { name: petName })}
|
||||
highlight={petName}
|
||||
subtitle={t("onboarding.aboutSubtitle")}
|
||||
footer={
|
||||
<>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Button title={t("onboarding.aboutCreate")} isLoading={isCreating} onPress={onCreate} style={styles.cta} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
style={[styles.area, styles.areaTall]}
|
||||
value={bio}
|
||||
onChangeText={onChangeBio}
|
||||
placeholder={t("petForm.bioPlaceholder")}
|
||||
placeholderTextColor={colors.quietGray}
|
||||
maxLength={BIO_MAX}
|
||||
multiline
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.area}
|
||||
value={adoptionStory}
|
||||
onChangeText={onChangeAdoptionStory}
|
||||
placeholder={t("onboarding.storyPlaceholder", { name: petName })}
|
||||
placeholderTextColor={colors.quietGray}
|
||||
maxLength={STORY_MAX}
|
||||
multiline
|
||||
/>
|
||||
</StepScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
area: {
|
||||
minHeight: 72,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
fontFamily: fontFamily.regular,
|
||||
fontSize: 15,
|
||||
color: colors.ink,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
areaTall: { minHeight: 104 },
|
||||
error: { ...typography.body, color: colors.alertRed, marginBottom: 6 },
|
||||
cta: { height: 54, borderRadius: 15 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Animated, Easing, Image, StyleSheet, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PawPrint } from "lucide-react-native";
|
||||
import { useTheme } from "../../../context/ThemeContext";
|
||||
import { useReducedMotion } from "../../../hooks/useReducedMotion";
|
||||
import { Button } from "../../Button";
|
||||
import { Reveal } from "../../Reveal";
|
||||
import { fontFamily, getTypography, type ColorTokens } from "../../../theme/tokens";
|
||||
|
||||
const PAW_COUNT = 16;
|
||||
const BURST_MS = 1300;
|
||||
|
||||
type Props = { petName: string; photoUri?: string; onFinish: () => void };
|
||||
|
||||
/** Closing moment after onboarding: the pet's badge pops in, paws burst around it. */
|
||||
export function DoneStep({ petName, photoUri, onFinish }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const [pop] = useState(() => new Animated.Value(isReducedMotion ? 1 : 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (isReducedMotion) {
|
||||
pop.setValue(1);
|
||||
return;
|
||||
}
|
||||
const animation = Animated.spring(pop, { toValue: 1, friction: 5, tension: 70, useNativeDriver: true });
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [isReducedMotion, pop]);
|
||||
|
||||
const letter = petName.trim().charAt(0).toUpperCase();
|
||||
const scale = pop.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] });
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.center}>
|
||||
<View>
|
||||
{isReducedMotion ? null : <PawBurst color={colors.marigoldOrange} />}
|
||||
<Animated.View style={[styles.badge, { opacity: pop, transform: [{ scale }] }]}>
|
||||
{photoUri ? <Image source={{ uri: photoUri }} style={styles.photo} alt={petName} /> : <Text style={styles.letter}>{letter}</Text>}
|
||||
</Animated.View>
|
||||
</View>
|
||||
<Reveal index={2}>
|
||||
<Text style={styles.title}>{t("onboarding.doneTitle", { name: petName })}</Text>
|
||||
</Reveal>
|
||||
<Reveal index={3}>
|
||||
<Text style={styles.subtitle}>{t("onboarding.doneSubtitle")}</Text>
|
||||
</Reveal>
|
||||
</View>
|
||||
<Button title={t("onboarding.doneCta")} onPress={onFinish} style={styles.cta} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PawBurst({ color }: { color: string }) {
|
||||
const [progress] = useState(() => new Animated.Value(0));
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.timing(progress, {
|
||||
toValue: 1,
|
||||
duration: BURST_MS,
|
||||
easing: Easing.bezier(0.15, 0.7, 0.3, 1),
|
||||
useNativeDriver: true,
|
||||
});
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [progress]);
|
||||
|
||||
return (
|
||||
<View style={burstStyles.layer} pointerEvents="none">
|
||||
{Array.from({ length: PAW_COUNT }, (_, i) => {
|
||||
const angle = (i / PAW_COUNT) * Math.PI * 2;
|
||||
const distance = 110 + (i % 3) * 36;
|
||||
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [0, Math.cos(angle) * distance] });
|
||||
const translateY = progress.interpolate({ inputRange: [0, 1], outputRange: [0, Math.sin(angle) * distance - 40] });
|
||||
const rotate = progress.interpolate({ inputRange: [0, 1], outputRange: ["0deg", `${(i % 2 ? 1 : -1) * (90 + i * 12)}deg`] });
|
||||
const opacity = progress.interpolate({ inputRange: [0, 0.15, 1], outputRange: [0, 1, 0] });
|
||||
return (
|
||||
<Animated.View key={i} style={[burstStyles.paw, { opacity, transform: [{ translateX }, { translateY }, { rotate }] }]}>
|
||||
<PawPrint color={color} fill={color} size={18} />
|
||||
</Animated.View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const burstStyles = StyleSheet.create({
|
||||
layer: { ...StyleSheet.absoluteFill, alignItems: "center", justifyContent: "center" },
|
||||
paw: { position: "absolute" },
|
||||
});
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, paddingHorizontal: 26, paddingBottom: 16 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||
badge: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
backgroundColor: colors.marigoldOrange,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 8,
|
||||
borderColor: colors.marigoldTint,
|
||||
overflow: "hidden",
|
||||
},
|
||||
letter: { fontFamily: fontFamily.extraBold, fontSize: 48, color: "#ffffff" },
|
||||
photo: { width: "100%", height: "100%" },
|
||||
title: {
|
||||
fontFamily: fontFamily.extraBold,
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
letterSpacing: -0.6,
|
||||
color: colors.ink,
|
||||
textAlign: "center",
|
||||
marginTop: 26,
|
||||
},
|
||||
subtitle: { ...typography.body, fontSize: 15, color: colors.quietGray, textAlign: "center", marginTop: 8 },
|
||||
cta: { height: 54, borderRadius: 15 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Animated, Pressable, StyleSheet, Text, TextInput } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../../context/ThemeContext";
|
||||
import { useReducedMotion } from "../../../hooks/useReducedMotion";
|
||||
import { Button } from "../../Button";
|
||||
import { fontFamily, getTypography, type ColorTokens } from "../../../theme/tokens";
|
||||
import { StepScaffold } from "./StepScaffold";
|
||||
|
||||
const NAME_MAX = 30;
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
nickname: string;
|
||||
onChangeName: (value: string) => void;
|
||||
onChangeNickname: (value: string) => void;
|
||||
onNext: () => void;
|
||||
/** "Ich betreue ein Tier mit" — the co-caretaker path instead of creating a pet. */
|
||||
onCaretaker: () => void;
|
||||
};
|
||||
|
||||
/** Step "Wie heißt dein Tier?" — the avatar circle shows the first letter live. */
|
||||
export function NameStep({ name, nickname, onChangeName, onChangeNickname, onNext, onCaretaker }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const [isNicknameOpen, setIsNicknameOpen] = useState(nickname.length > 0);
|
||||
const letter = name.trim().charAt(0).toUpperCase() || "?";
|
||||
const canContinue = name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<StepScaffold
|
||||
title={t("onboarding.nameTitle")}
|
||||
subtitle={t("onboarding.nameSubtitle")}
|
||||
footer={
|
||||
<>
|
||||
<Button title={t("common.continue")} disabled={!canContinue} onPress={onNext} style={styles.cta} />
|
||||
<Pressable onPress={onCaretaker} hitSlop={8} style={styles.caretaker} accessibilityRole="button">
|
||||
<Text style={styles.caretakerText}>{t("caretaker.onboardingSwitch")}</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<LetterAvatar letter={letter} colors={colors} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={onChangeName}
|
||||
placeholder={t("petForm.namePlaceholder")}
|
||||
placeholderTextColor={colors.quietGray}
|
||||
maxLength={NAME_MAX}
|
||||
autoFocus
|
||||
returnKeyType="next"
|
||||
onSubmitEditing={() => canContinue && onNext()}
|
||||
/>
|
||||
{isNicknameOpen ? (
|
||||
<TextInput
|
||||
style={[styles.input, styles.inputSmall]}
|
||||
value={nickname}
|
||||
onChangeText={onChangeNickname}
|
||||
placeholder={t("petForm.nicknamePlaceholder")}
|
||||
placeholderTextColor={colors.quietGray}
|
||||
maxLength={NAME_MAX}
|
||||
/>
|
||||
) : (
|
||||
<Pressable onPress={() => setIsNicknameOpen(true)} hitSlop={8} accessibilityRole="button">
|
||||
<Text style={styles.addLink}>{t("onboarding.addNickname")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</StepScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
function LetterAvatar({ letter, colors }: { letter: string; colors: ColorTokens }) {
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const [bump] = useState(() => new Animated.Value(1));
|
||||
const previous = useRef(letter);
|
||||
|
||||
useEffect(() => {
|
||||
if (previous.current === letter) return;
|
||||
previous.current = letter;
|
||||
if (isReducedMotion) return;
|
||||
bump.setValue(1.1);
|
||||
Animated.spring(bump, { toValue: 1, friction: 4, tension: 160, useNativeDriver: true }).start();
|
||||
}, [letter, isReducedMotion, bump]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[avatarStyles.circle, { backgroundColor: colors.marigoldOrange, transform: [{ scale: bump }] }]}>
|
||||
<Text style={avatarStyles.letter}>{letter}</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const avatarStyles = StyleSheet.create({
|
||||
circle: { width: 104, height: 104, borderRadius: 52, alignSelf: "center", alignItems: "center", justifyContent: "center", marginBottom: 16 },
|
||||
letter: { fontFamily: fontFamily.extraBold, fontSize: 44, color: "#ffffff" },
|
||||
});
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
input: {
|
||||
height: 58,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.marigoldOrange,
|
||||
backgroundColor: colors.pureWhite,
|
||||
paddingHorizontal: 16,
|
||||
fontFamily: fontFamily.semiBold,
|
||||
fontSize: 20,
|
||||
color: colors.ink,
|
||||
},
|
||||
inputSmall: { height: 50, fontSize: 16, borderColor: colors.hairlineGray },
|
||||
addLink: { ...typography.body, fontFamily: fontFamily.semiBold, color: colors.marigoldDeep, marginTop: 2 },
|
||||
cta: { height: 54, borderRadius: 15 },
|
||||
caretaker: { alignSelf: "center", paddingVertical: 12 },
|
||||
caretakerText: { ...typography.body, fontFamily: fontFamily.semiBold, color: colors.quietGray },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft, X } from "lucide-react-native";
|
||||
import { useTheme } from "../../../context/ThemeContext";
|
||||
import { useReducedMotion } from "../../../hooks/useReducedMotion";
|
||||
import type { ColorTokens } from "../../../theme/tokens";
|
||||
|
||||
const FILL_MS = 450;
|
||||
|
||||
type Props = {
|
||||
/** Number of progress segments. */
|
||||
count: number;
|
||||
/** How many segments are filled (current step included). */
|
||||
filled: number;
|
||||
onBack?: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** Back arrow, segmented progress and close button above the onboarding steps. */
|
||||
export function OnboardingTopBar({ count, filled, onBack, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
return (
|
||||
<View style={styles.bar}>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
disabled={!onBack}
|
||||
hitSlop={10}
|
||||
style={[styles.iconButton, !onBack && styles.hidden]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("onboarding.back")}
|
||||
>
|
||||
<ChevronLeft color={colors.ink} size={24} />
|
||||
</Pressable>
|
||||
<View style={styles.segments} accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: count, now: filled }}>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<Segment key={i} isFilled={i < filled} colors={colors} />
|
||||
))}
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
hitSlop={10}
|
||||
style={styles.iconButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("onboarding.close")}
|
||||
>
|
||||
<X color={colors.quietGray} size={22} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Segment({ isFilled, colors }: { isFilled: boolean; colors: ColorTokens }) {
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const [fill] = useState(() => new Animated.Value(isFilled ? 1 : 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (isReducedMotion) {
|
||||
fill.setValue(isFilled ? 1 : 0);
|
||||
return;
|
||||
}
|
||||
// width can't run on the native driver.
|
||||
const animation = Animated.timing(fill, { toValue: isFilled ? 1 : 0, duration: FILL_MS, useNativeDriver: false });
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [isFilled, isReducedMotion, fill]);
|
||||
|
||||
const width = fill.interpolate({ inputRange: [0, 1], outputRange: ["0%", "100%"] });
|
||||
return (
|
||||
<View style={[segmentStyles.track, { backgroundColor: colors.hairlineGray }]}>
|
||||
<Animated.View style={[segmentStyles.fill, { width, backgroundColor: colors.marigoldOrange }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const segmentStyles = StyleSheet.create({
|
||||
track: { flex: 1, height: 5, borderRadius: 3, overflow: "hidden" },
|
||||
fill: { height: 5, borderRadius: 3 },
|
||||
});
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
bar: { flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 16, height: 48, backgroundColor: colors.porchWhite },
|
||||
iconButton: { width: 36, height: 36, alignItems: "center", justifyContent: "center" },
|
||||
hidden: { opacity: 0 },
|
||||
segments: { flex: 1, flexDirection: "row", gap: 5 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from "react";
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../../api/trpc";
|
||||
import { useTheme } from "../../../context/ThemeContext";
|
||||
import { Button } from "../../Button";
|
||||
import { BreedPicker } from "../../pet/BreedPicker";
|
||||
import { Reveal } from "../../Reveal";
|
||||
import type { PickerOption } from "../../OptionPicker";
|
||||
import { fontFamily, getTypography, type ColorTokens } from "../../../theme/tokens";
|
||||
import { StepScaffold } from "./StepScaffold";
|
||||
|
||||
type Props = {
|
||||
petName: string;
|
||||
speciesId: string | null;
|
||||
breedName: string | null;
|
||||
onSelectSpecies: (option: PickerOption) => void;
|
||||
onSelectBreed: (option: PickerOption) => void;
|
||||
onNext: () => void;
|
||||
};
|
||||
|
||||
/** Step "Was für ein Tier ist {name}?" — species as chips (data-driven), breed appears once a species is picked. */
|
||||
export function SpeciesStep({ petName, speciesId, breedName, onSelectSpecies, onSelectBreed, onNext }: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const { data: species = [], isLoading } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: i18n.language }));
|
||||
const sorted = useMemo(() => [...species].sort((a, b) => a.name.localeCompare(b.name, i18n.language)), [species, i18n.language]);
|
||||
|
||||
return (
|
||||
<StepScaffold
|
||||
title={t("onboarding.speciesTitle", { name: petName })}
|
||||
highlight={petName}
|
||||
subtitle={t("onboarding.speciesSubtitle")}
|
||||
footer={<Button title={t("common.continue")} disabled={!speciesId} onPress={onNext} style={styles.cta} />}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
) : (
|
||||
<View style={styles.chips} accessibilityRole="radiogroup">
|
||||
{sorted.map((option) => {
|
||||
const isSelected = option.id === speciesId;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
onPress={() => onSelectSpecies(option)}
|
||||
style={({ pressed }) => [styles.chip, isSelected && styles.chipSelected, pressed && styles.chipPressed]}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ checked: isSelected }}
|
||||
>
|
||||
<Text style={[styles.chipText, isSelected && styles.chipTextSelected]}>{option.name}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
{speciesId ? (
|
||||
<Reveal index={0} style={styles.breed}>
|
||||
<Text style={styles.breedLabel}>{t("onboarding.breedOptional")}</Text>
|
||||
<BreedPicker speciesId={speciesId} selectedName={breedName} onSelect={onSelectBreed} />
|
||||
</Reveal>
|
||||
) : null}
|
||||
</StepScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||
chip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
},
|
||||
chipSelected: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
chipPressed: { transform: [{ scale: 0.94 }] },
|
||||
chipText: { fontFamily: fontFamily.semiBold, fontSize: 14, color: colors.ink },
|
||||
chipTextSelected: { color: "#ffffff" },
|
||||
breed: { marginTop: 10, gap: 6 },
|
||||
breedLabel: { ...typography.caption, color: colors.quietGray },
|
||||
cta: { height: 54, borderRadius: 15 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useTheme } from "../../../context/ThemeContext";
|
||||
import { fontFamily, getTypography, type ColorTokens } from "../../../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
/** Part of the title shown in marigold (the pet's name, "PawFeed"). */
|
||||
highlight?: string;
|
||||
subtitle?: string;
|
||||
children?: ReactNode;
|
||||
/** Pinned below the content: the step's main button(s). */
|
||||
footer: ReactNode;
|
||||
};
|
||||
|
||||
/** Shared layout of every onboarding step: big title, subtitle, content, buttons at the bottom. */
|
||||
export function StepScaffold({ title, highlight, subtitle, children, footer }: Props) {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.title}>{renderHighlighted(title, highlight, styles.highlight)}</Text>
|
||||
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
|
||||
<View style={styles.content}>{children}</View>
|
||||
<View style={styles.footer}>{footer}</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function renderHighlighted(title: string, highlight: string | undefined, style: object): ReactNode {
|
||||
const at = highlight ? title.indexOf(highlight) : -1;
|
||||
if (!highlight || at < 0) return title;
|
||||
return (
|
||||
<>
|
||||
{title.slice(0, at)}
|
||||
<Text style={style}>{highlight}</Text>
|
||||
{title.slice(at + highlight.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { flexGrow: 1, paddingHorizontal: 26, paddingTop: 18, paddingBottom: 16 },
|
||||
title: { fontFamily: fontFamily.extraBold, fontSize: 28, lineHeight: 34, letterSpacing: -0.6, color: colors.ink },
|
||||
highlight: { color: colors.marigoldOrange },
|
||||
subtitle: { ...typography.body, fontSize: 15, color: colors.quietGray, marginTop: 8 },
|
||||
content: { flexGrow: 1, marginTop: 22, gap: 10 },
|
||||
footer: { marginTop: 20, gap: 4 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Animated, Easing, StyleSheet } from "react-native";
|
||||
import { useReducedMotion } from "../../../hooks/useReducedMotion";
|
||||
|
||||
const SLIDE_PX = 48;
|
||||
const DURATION_MS = 380;
|
||||
|
||||
type Props = {
|
||||
/** 1 = came from the previous step (enter from the right), -1 = went back. */
|
||||
direction: 1 | -1;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slides a step in on mount. The parent keys it by step, so each step change
|
||||
* mounts a fresh StepSlide and plays the entrance once.
|
||||
*/
|
||||
export function StepSlide({ direction, children }: Props) {
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const [progress] = useState(() => new Animated.Value(0));
|
||||
|
||||
useEffect(() => {
|
||||
if (isReducedMotion) {
|
||||
progress.setValue(1);
|
||||
return;
|
||||
}
|
||||
const animation = Animated.timing(progress, {
|
||||
toValue: 1,
|
||||
duration: DURATION_MS,
|
||||
easing: Easing.bezier(0.2, 0.8, 0.2, 1),
|
||||
useNativeDriver: true,
|
||||
});
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [isReducedMotion, progress]);
|
||||
|
||||
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [SLIDE_PX * direction, 0] });
|
||||
return <Animated.View style={[styles.fill, { opacity: progress, transform: [{ translateX }] }]}>{children}</Animated.View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({ fill: { flex: 1 } });
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Linking, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Check, Heart, PawPrint, ShieldCheck, type LucideIcon } from "lucide-react-native";
|
||||
import { useTRPC } from "../../../api/trpc";
|
||||
import { env } from "../../../lib/env";
|
||||
import { useTheme } from "../../../context/ThemeContext";
|
||||
import { Button } from "../../Button";
|
||||
import { Reveal } from "../../Reveal";
|
||||
import { getTypography, type ColorTokens } from "../../../theme/tokens";
|
||||
import { StepScaffold } from "./StepScaffold";
|
||||
|
||||
const RULES: { icon: LucideIcon; key: "rule1" | "rule2" | "rule3" }[] = [
|
||||
{ icon: Heart, key: "rule1" },
|
||||
{ icon: PawPrint, key: "rule2" },
|
||||
{ icon: ShieldCheck, key: "rule3" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
/** Terms/privacy have versions this owner hasn't acknowledged yet. */
|
||||
needsLegal: boolean;
|
||||
/** The two first-pet declarations (content rights, data processing) are missing. */
|
||||
needsConsent: boolean;
|
||||
onContinue: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the former OnboardingGuidelines + UploadConsent screens. The
|
||||
* rules are three fixed cards (app i18n): legal.getPendingUpdate returns the
|
||||
* whole changelog for a new owner, which read as a wall of "we updated our
|
||||
* privacy policy" notes instead of community rules. Acknowledging still goes
|
||||
* through legal.acknowledge; both declarations stay separate, explicit
|
||||
* checkboxes with their full text (Gitea #3).
|
||||
*/
|
||||
export function WelcomeStep({ needsLegal, needsConsent, onContinue }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const trpc = useTRPC();
|
||||
const [hasContentRights, setHasContentRights] = useState(false);
|
||||
const [hasDataConsent, setHasDataConsent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const acknowledgeLegal = useMutation(trpc.legal.acknowledge.mutationOptions());
|
||||
const acknowledgeConsent = useMutation(trpc.legal.acknowledgeUploadConsent.mutationOptions());
|
||||
const isSaving = acknowledgeLegal.isPending || acknowledgeConsent.isPending;
|
||||
const canContinue = !needsConsent || (hasContentRights && hasDataConsent);
|
||||
|
||||
const handleContinue = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
if (needsLegal) await acknowledgeLegal.mutateAsync();
|
||||
if (needsConsent) await acknowledgeConsent.mutateAsync();
|
||||
// Cache is refreshed when the onboarding is left (pet.tsx), not here — see there why.
|
||||
onContinue();
|
||||
} catch {
|
||||
setError(t("onboarding.saveError"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StepScaffold
|
||||
title={t("onboarding.welcomeTitle")}
|
||||
highlight="PawFeed"
|
||||
subtitle={t("onboarding.welcomeSubtitle")}
|
||||
footer={
|
||||
<>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Button
|
||||
title={needsConsent ? t("onboarding.welcomeContinueConsent") : t("onboarding.welcomeContinue")}
|
||||
disabled={!canContinue}
|
||||
isLoading={isSaving}
|
||||
onPress={handleContinue}
|
||||
style={styles.cta}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{RULES.map(({ icon: Icon, key }, i) => (
|
||||
<Reveal key={key} index={i}>
|
||||
<View style={styles.rule}>
|
||||
<View style={styles.ruleIcon}>
|
||||
<Icon color={colors.marigoldDeep} size={18} />
|
||||
</View>
|
||||
<View style={styles.ruleText}>
|
||||
<Text style={styles.ruleTitle}>{t(`onboarding.${key}Title`)}</Text>
|
||||
<Text style={styles.ruleBody}>{t(`onboarding.${key}Body`)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Reveal>
|
||||
))}
|
||||
|
||||
<View style={styles.links}>
|
||||
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/nutzungsbedingungen`)} hitSlop={8}>
|
||||
<Text style={styles.link}>{t("onboarding.termsShort")}</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)} hitSlop={8}>
|
||||
<Text style={styles.link}>{t("onboarding.privacyShort")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{needsConsent ? (
|
||||
<View style={styles.consents}>
|
||||
<ConsentCheck
|
||||
isChecked={hasContentRights}
|
||||
onToggle={() => setHasContentRights((v) => !v)}
|
||||
label={t("onboarding.contentRightsLabel")}
|
||||
/>
|
||||
<ConsentCheck
|
||||
isChecked={hasDataConsent}
|
||||
onToggle={() => setHasDataConsent((v) => !v)}
|
||||
label={t("onboarding.dataProcessingLabel")}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</StepScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsentCheck({ isChecked, onToggle, label }: { isChecked: boolean; onToggle: () => void; label: string }) {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.checkRow}
|
||||
onPress={onToggle}
|
||||
hitSlop={4}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: isChecked }}
|
||||
>
|
||||
<View style={[styles.checkbox, isChecked && styles.checkboxChecked]}>
|
||||
{isChecked ? <Check color="#ffffff" size={14} strokeWidth={3} /> : null}
|
||||
</View>
|
||||
<Text style={styles.checkLabel}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
rule: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
padding: 12,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
},
|
||||
ruleIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 11,
|
||||
backgroundColor: colors.marigoldTint,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
ruleText: { flex: 1 },
|
||||
ruleTitle: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 14.5 },
|
||||
ruleBody: { ...typography.body, fontSize: 13, lineHeight: 18, color: colors.quietGray },
|
||||
links: { flexDirection: "row", flexWrap: "wrap", gap: 16, marginTop: 2 },
|
||||
link: { ...typography.caption, color: colors.marigoldDeep },
|
||||
consents: { gap: 12, marginTop: 8 },
|
||||
checkRow: { flexDirection: "row", gap: 10 },
|
||||
checkbox: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 7,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 1,
|
||||
},
|
||||
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
checkLabel: { ...typography.body, flex: 1, fontSize: 12.5, lineHeight: 17 },
|
||||
error: { ...typography.body, color: colors.alertRed, marginBottom: 6 },
|
||||
cta: { height: 54, borderRadius: 15 },
|
||||
});
|
||||
}
|
||||
@@ -108,8 +108,8 @@ function makeStyles(colors: ColorTokens) {
|
||||
section: { marginTop: 24, gap: 8 },
|
||||
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
heading: { ...typography.title, fontSize: 16 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
hint: { ...typography.caption },
|
||||
label: { ...typography.caption, marginTop: 4 },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
});
|
||||
}
|
||||
@@ -137,6 +137,6 @@ function makeStyles(colors: ColorTokens) {
|
||||
optionDivider: { borderTopWidth: 1, borderTopColor: colors.hairlineGray },
|
||||
optionText: { ...typography.body },
|
||||
optionTextSelected: { fontFamily: typography.title.fontFamily, color: colors.marigoldOrange },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
hint: { ...typography.caption },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Animated, Easing, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, PawPrint, Share2 } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { useReducedMotion } from "../../hooks/useReducedMotion";
|
||||
import { useReferralRanks, type ReferralRankView } from "../../hooks/useReferralRanks";
|
||||
import { ReferralIcon } from "../../lib/referral-icons";
|
||||
import { shareReferralLink } from "../../lib/referral-share";
|
||||
import { Reveal } from "../Reveal";
|
||||
import { fontFamily, getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
const FILL_DELAY_MS = 350;
|
||||
const FILL_MS = 900;
|
||||
const BREATHE_MS = 1200;
|
||||
|
||||
type Props = {
|
||||
/** Settings shows a "Ränge" button to the detail page; the detail page itself doesn't. */
|
||||
showRanksLink?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Einladungskarte (Einstellungen und Kopf von /invite): aktueller Rang (oder
|
||||
* der erste, noch offene, gestrichelt), eigener Code, Fortschritt bis zum
|
||||
* nächsten Rang und Teilen direkt aus der Karte.
|
||||
*/
|
||||
export function InviteCard({ showRanksLink = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const { ranks } = useReferralRanks();
|
||||
const { data } = useQuery(trpc.referrals.me.queryOptions());
|
||||
|
||||
if (!data) return <View style={[styles.card, styles.placeholder]} />;
|
||||
|
||||
const activeRanks = ranks.filter((rank) => rank.isActive).sort((a, b) => a.threshold - b.threshold);
|
||||
const achieved = new Set(data.achievedRankIds);
|
||||
const current = activeRanks.filter((rank) => achieved.has(rank.id)).at(-1) ?? null;
|
||||
const next = activeRanks.find((rank) => rank.id === data.nextRankId) ?? null;
|
||||
const shownRank = current ?? next;
|
||||
|
||||
const heading = current ? current.name : next ? t("referrals.cardFirstRankWaiting") : t("referrals.title");
|
||||
const subline = current
|
||||
? `${t("referrals.cardYourRank")} · ${current.description}`
|
||||
: next
|
||||
? `${next.name} · ${next.description}`
|
||||
: null;
|
||||
|
||||
let progressText: string;
|
||||
if (!next) progressText = t("referrals.allRanksReached");
|
||||
else if (!current && data.qualifiedCount === 0) progressText = t("referrals.cardFirstInvite", { count: next.threshold });
|
||||
else progressText = t("referrals.cardProgress", { done: data.qualifiedCount, count: next.threshold, name: next.name });
|
||||
|
||||
const progress = next ? Math.min(1, data.qualifiedCount / next.threshold) : 1;
|
||||
|
||||
return (
|
||||
<Reveal index={0}>
|
||||
<View style={styles.card}>
|
||||
<PawPrint style={styles.watermark} color={colors.marigoldOrange} size={130} />
|
||||
<View style={styles.top}>
|
||||
{shownRank ? <RankDot rank={shownRank} isPending={!current} /> : null}
|
||||
<View style={styles.topText}>
|
||||
<Text style={styles.heading}>{heading}</Text>
|
||||
{subline ? (
|
||||
<Text style={styles.subline} numberOfLines={2}>
|
||||
{subline}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.codeBox}>
|
||||
<Text style={styles.codeLabel}>{t("referrals.cardCodeLabel")}</Text>
|
||||
{/* selectable: long-press copy without a clipboard module (would need a native build). */}
|
||||
<Text style={styles.code} selectable>
|
||||
{data.code}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ProgressBar progress={progress} colors={colors} />
|
||||
<Text style={styles.progressText}>{progressText}</Text>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.share, pressed && styles.pressed]}
|
||||
onPress={() => shareReferralLink(data.code, t)}
|
||||
android_ripple={{ color: "rgba(255,255,255,0.25)" }}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Share2 size={18} color="#ffffff" />
|
||||
<Text style={styles.shareText}>{t("referrals.share")}</Text>
|
||||
</Pressable>
|
||||
{showRanksLink ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.more, pressed && styles.pressed]}
|
||||
onPress={() => router.push("/invite")}
|
||||
android_ripple={{ color: colors.marigoldTint }}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text style={styles.moreText}>{t("referrals.cardRanks")}</Text>
|
||||
<ChevronRight size={16} color={colors.ink} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
function RankDot({ rank, isPending }: { rank: ReferralRankView; isPending: boolean }) {
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const [scale] = useState(() => new Animated.Value(1));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending || isReducedMotion) return;
|
||||
const breathe = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(scale, { toValue: 1.08, duration: BREATHE_MS, useNativeDriver: true }),
|
||||
Animated.timing(scale, { toValue: 1, duration: BREATHE_MS, useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
breathe.start();
|
||||
return () => breathe.stop();
|
||||
}, [isPending, isReducedMotion, scale]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
dotStyles.dot,
|
||||
isPending
|
||||
? { borderColor: rank.color, borderStyle: "dashed", borderWidth: 2 }
|
||||
: { backgroundColor: rank.color },
|
||||
{ transform: [{ scale }] },
|
||||
]}
|
||||
>
|
||||
<ReferralIcon icon={rank.icon} size={20} color={isPending ? rank.color : "#ffffff"} />
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar({ progress, colors }: { progress: number; colors: ColorTokens }) {
|
||||
const isReducedMotion = useReducedMotion();
|
||||
const [fill] = useState(() => new Animated.Value(0));
|
||||
|
||||
useEffect(() => {
|
||||
if (isReducedMotion) {
|
||||
fill.setValue(progress);
|
||||
return;
|
||||
}
|
||||
// width can't run on the native driver.
|
||||
const animation = Animated.timing(fill, {
|
||||
toValue: progress,
|
||||
duration: FILL_MS,
|
||||
delay: FILL_DELAY_MS,
|
||||
easing: Easing.bezier(0.2, 0.8, 0.2, 1),
|
||||
useNativeDriver: false,
|
||||
});
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [progress, isReducedMotion, fill]);
|
||||
|
||||
const width = fill.interpolate({ inputRange: [0, 1], outputRange: ["0%", "100%"] });
|
||||
return (
|
||||
<View style={[barStyles.track, { backgroundColor: colors.whisperGray }]}>
|
||||
<Animated.View style={[barStyles.fill, { width, backgroundColor: colors.marigoldOrange }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const dotStyles = StyleSheet.create({
|
||||
dot: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center" },
|
||||
});
|
||||
|
||||
const barStyles = StyleSheet.create({
|
||||
track: { height: 8, borderRadius: 4, overflow: "hidden" },
|
||||
fill: { height: 8, borderRadius: 4 },
|
||||
});
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
card: {
|
||||
gap: 12,
|
||||
padding: 18,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.marigoldTint,
|
||||
backgroundColor: colors.pureWhite,
|
||||
overflow: "hidden",
|
||||
},
|
||||
placeholder: { height: 236 },
|
||||
watermark: { position: "absolute", right: -26, top: -18, opacity: 0.08, transform: [{ rotate: "18deg" }] },
|
||||
top: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||
topText: { flex: 1 },
|
||||
heading: { ...typography.title, fontSize: 16, lineHeight: 22 },
|
||||
subline: { ...typography.body, fontSize: 12.5, lineHeight: 17, color: colors.quietGray },
|
||||
codeBox: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderStyle: "dashed",
|
||||
borderColor: colors.marigoldOrange,
|
||||
backgroundColor: colors.porchWhite,
|
||||
},
|
||||
codeLabel: { ...typography.label, fontSize: 10.5, letterSpacing: 1 },
|
||||
code: { fontFamily: fontFamily.extraBold, fontSize: 20, letterSpacing: 1.6, color: colors.marigoldDeep, marginTop: 2 },
|
||||
progressText: { ...typography.body, fontSize: 13, color: colors.quietGray, marginTop: -4 },
|
||||
actions: { flexDirection: "row", gap: 8 },
|
||||
share: {
|
||||
flex: 1,
|
||||
height: 46,
|
||||
borderRadius: 13,
|
||||
backgroundColor: colors.marigoldOrange,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
overflow: "hidden",
|
||||
},
|
||||
shareText: { fontFamily: fontFamily.bold, fontSize: 15, color: "#ffffff" },
|
||||
more: {
|
||||
height: 46,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 13,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
overflow: "hidden",
|
||||
},
|
||||
moreText: { fontFamily: fontFamily.semiBold, fontSize: 14, color: colors.ink },
|
||||
pressed: { transform: [{ scale: 0.97 }] },
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,9 @@ 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";
|
||||
import { fontFamily, getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
const DOT = 38;
|
||||
|
||||
type Props = {
|
||||
ranks: ReferralRankView[];
|
||||
@@ -14,14 +16,22 @@ type Props = {
|
||||
nextRankId: string | null;
|
||||
};
|
||||
|
||||
/** Port of web's RankLadder — Fortschritt zum nächsten Rang plus alle aktiven Ränge. */
|
||||
/** "#rrggbb" + alpha → "#rrggbbaa"; other formats are returned unchanged. */
|
||||
function withAlpha(hex: string, alpha: number): string {
|
||||
if (!/^#[0-9a-f]{6}$/i.test(hex)) return hex;
|
||||
return `${hex}${Math.round(alpha * 255).toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* All active ranks as a timeline: achieved ranks filled with a check, the
|
||||
* next one highlighted ("Als Nächstes"), the rest as outlines with their
|
||||
* threshold. Progress itself lives in InviteCard above.
|
||||
*/
|
||||
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;
|
||||
const activeRanks = ranks.filter((rank) => rank.isActive).sort((a, b) => a.threshold - b.threshold);
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
@@ -29,38 +39,38 @@ export function RankLadder({ ranks, achievedRankIds, qualifiedCount, nextRankId
|
||||
<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) => {
|
||||
<View>
|
||||
{activeRanks.map((rank, i) => {
|
||||
const isAchieved = achievedRankIds.has(rank.id);
|
||||
const isNext = rank.id === nextRankId;
|
||||
const isLast = i === activeRanks.length - 1;
|
||||
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 key={rank.id} style={[styles.row, isNext && { backgroundColor: withAlpha(rank.color, 0.12) }]}>
|
||||
{isLast ? null : <View style={[styles.connector, isAchieved && { backgroundColor: rank.color }]} />}
|
||||
<View
|
||||
style={[
|
||||
styles.dot,
|
||||
isAchieved ? { backgroundColor: rank.color } : { borderColor: rank.color, borderWidth: 2 },
|
||||
!isAchieved && !isNext && styles.dotFaded,
|
||||
]}
|
||||
>
|
||||
<ReferralIcon icon={rank.icon} size={18} 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 style={styles.text}>
|
||||
<Text style={[styles.name, !isAchieved && !isNext && styles.faded]}>{rank.name}</Text>
|
||||
<Text style={styles.description}>{rank.description}</Text>
|
||||
</View>
|
||||
{isAchieved ? (
|
||||
<Check size={16} color={colors.marigoldOrange} accessibilityLabel={t("referrals.achieved")} />
|
||||
<View style={[styles.check, { backgroundColor: withAlpha(rank.color, 0.16) }]}>
|
||||
<Check size={14} color={rank.color} strokeWidth={3} accessibilityLabel={t("referrals.achieved")} />
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.muted}>{t("referrals.threshold", { count: rank.threshold })}</Text>
|
||||
<View style={[styles.pill, isNext && { backgroundColor: rank.color }]}>
|
||||
<Text style={[styles.pillText, isNext && styles.pillTextNext]}>
|
||||
{isNext ? t("referrals.ladderNext") : t("referrals.ladderThreshold", { count: rank.threshold })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -73,18 +83,29 @@ export function RankLadder({ ranks, achievedRankIds, qualifiedCount, nextRankId
|
||||
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 },
|
||||
card: { gap: 12, padding: 18, borderRadius: 22, 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 },
|
||||
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 10, paddingHorizontal: 8, borderRadius: 16 },
|
||||
// Line from this dot to the next one; dot centre = 8 (row padding) + DOT / 2.
|
||||
connector: {
|
||||
position: "absolute",
|
||||
left: 8 + DOT / 2 - 1,
|
||||
top: 10 + DOT,
|
||||
bottom: -10,
|
||||
width: 2,
|
||||
backgroundColor: colors.hairlineGray,
|
||||
},
|
||||
dot: { width: DOT, height: DOT, borderRadius: DOT / 2, alignItems: "center", justifyContent: "center", backgroundColor: colors.pureWhite },
|
||||
dotFaded: { opacity: 0.55 },
|
||||
text: { flex: 1 },
|
||||
name: { fontFamily: fontFamily.semiBold, fontSize: 15, color: colors.ink },
|
||||
faded: { color: colors.quietGray },
|
||||
description: { ...typography.body, fontSize: 12.5, lineHeight: 17, color: colors.quietGray },
|
||||
check: { width: 26, height: 26, borderRadius: 13, alignItems: "center", justifyContent: "center" },
|
||||
pill: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999, backgroundColor: colors.whisperGray },
|
||||
pillText: { fontFamily: fontFamily.semiBold, fontSize: 12, color: colors.quietGray },
|
||||
pillTextNext: { color: "#ffffff" },
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { Sun, Moon, SunMoon } from "lucide-react-native";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
||||
|
||||
/**
|
||||
* Darstellung (light/dark/auto) toggle. Extracted from pets.tsx's former
|
||||
@@ -49,7 +48,7 @@ export function AppearanceSection() {
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
sectionTitle: sectionTitleStyle(colors),
|
||||
sectionTitle: { ...typography.caption, marginBottom: 10 },
|
||||
row: { flexDirection: "row", gap: 8 },
|
||||
option: {
|
||||
flex: 1,
|
||||
|
||||
@@ -1,36 +1,6 @@
|
||||
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";
|
||||
import { InviteCard } from "../referrals/InviteCard";
|
||||
|
||||
/** Einstieg zum Empfehlungsprogramm (Bildschirm /invite) aus den Einstellungen. */
|
||||
/** Einstieg zum Empfehlungsprogramm oben in 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 },
|
||||
});
|
||||
return <InviteCard showRanksLink />;
|
||||
}
|
||||
@@ -3,8 +3,7 @@ import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { LanguageSwitcher } from "../LanguageSwitcher";
|
||||
import type { ColorTokens } from "../../theme/tokens";
|
||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
/**
|
||||
* Section chrome (title + spacing) around the shared LanguageSwitcher — see
|
||||
@@ -26,6 +25,6 @@ export function LanguageSection() {
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
sectionTitle: sectionTitleStyle(colors),
|
||||
sectionTitle: { ...getTypography(colors).caption, marginBottom: 10 },
|
||||
});
|
||||
}
|
||||
@@ -69,6 +69,6 @@ function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
section: { gap: 12, alignItems: "center" },
|
||||
legalLink: { ...typography.body, fontSize: 13, color: colors.marigoldDeep },
|
||||
versionText: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
versionText: { ...typography.caption, marginTop: 4 },
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Alert } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@clerk/expo";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { getRegisteredPushToken } from "../../hooks/usePushNotifications";
|
||||
import { useActivePet } from "../../context/ActivePetContext";
|
||||
import { Button } from "../Button";
|
||||
|
||||
@@ -22,6 +25,17 @@ export function LogoutSection() {
|
||||
const { t } = useTranslation();
|
||||
const { signOut } = useAuth();
|
||||
const { clearActivePetId } = useActivePet();
|
||||
const trpc = useTRPC();
|
||||
const clearPushToken = useMutation(trpc.owner.clearPushToken.mutationOptions());
|
||||
|
||||
const logout = async () => {
|
||||
// Dieses Gerät vor dem Abmelden austragen, sonst kämen weiter Mitteilungen des alten Kontos an.
|
||||
// Fehler (z. B. offline) dürfen das Abmelden nie blockieren.
|
||||
const token = getRegisteredPushToken();
|
||||
if (token) await clearPushToken.mutateAsync({ expoPushToken: token }).catch(() => undefined);
|
||||
clearActivePetId();
|
||||
await signOut();
|
||||
};
|
||||
|
||||
const confirmLogout = () => {
|
||||
Alert.alert(t("pets.logoutConfirmTitle"), t("pets.logoutConfirmMessage"), [
|
||||
@@ -30,8 +44,7 @@ export function LogoutSection() {
|
||||
text: t("pets.logoutButton"),
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
clearActivePetId();
|
||||
void signOut();
|
||||
void logout();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
||||
|
||||
/**
|
||||
* Opt-out for the PET_SUGGESTION cron notification
|
||||
@@ -53,7 +52,7 @@ export function PetSuggestionsSection() {
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
sectionTitle: sectionTitleStyle(colors),
|
||||
sectionTitle: { ...typography.caption, marginBottom: 10 },
|
||||
toggleRow: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||
toggleText: { flex: 1 },
|
||||
toggleLabel: { ...typography.body, fontSize: 12, color: colors.quietGray, marginTop: 2 },
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
/**
|
||||
* Shared "section label" chrome (the small uppercase-ish title above a
|
||||
* Settings-screen section, e.g. "DARSTELLUNG"/"SPRACHE"). Extracted so
|
||||
* AppearanceSection/LanguageSection/PetSuggestionsSection can't drift out of
|
||||
* sync on this — they all had the exact same style object copy-pasted
|
||||
* before this file existed (found during code review of the Settings-screen
|
||||
* extraction, see mobile/found.md).
|
||||
*/
|
||||
export function sectionTitleStyle(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return { ...typography.label, textTransform: "none" as const, letterSpacing: 0, marginBottom: 10 };
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { PawPrint } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { usePawToggle } from "../../hooks/usePawToggle";
|
||||
import { colors } from "../../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
@@ -14,53 +14,29 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of web's StoryPawButton — Pfote auf einer Story. Optimistisch: Zustand
|
||||
* und Zähler springen sofort um und werden bei einem Fehler zurückgesetzt.
|
||||
* Der Viewer setzt `key={story.id}`, damit der Zustand pro Story neu startet.
|
||||
* Port of web's StoryPawButton — Pfote auf einer Story. Optimistisch über
|
||||
* usePawToggle (gemeinsam mit PawButton). Der Viewer setzt `key={story.id}`,
|
||||
* damit der Zustand pro Story neu startet.
|
||||
*/
|
||||
export function StoryPawButton({ storyId, petId, initialCount, initialReacted }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const [reacted, setReacted] = useState(initialReacted);
|
||||
const [count, setCount] = useState(initialCount);
|
||||
|
||||
const toggle = useMutation(
|
||||
trpc.stories.toggleReaction.mutationOptions({
|
||||
onMutate: () => {
|
||||
const previous = { reacted, count };
|
||||
setReacted(!reacted);
|
||||
setCount(reacted ? Math.max(0, count - 1) : count + 1);
|
||||
return previous;
|
||||
},
|
||||
// Der Server ist maßgeblich: Zähler aus dem Stand vor dem Tippen + tatsächlicher Änderung.
|
||||
onSuccess: (data, _vars, previous) => {
|
||||
setReacted(data.reacted);
|
||||
if (!previous) return;
|
||||
const delta = data.reacted === previous.reacted ? 0 : data.reacted ? 1 : -1;
|
||||
setCount(Math.max(0, previous.count + delta));
|
||||
},
|
||||
onError: (_err, _vars, previous) => {
|
||||
if (previous) {
|
||||
setReacted(previous.reacted);
|
||||
setCount(previous.count);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
const paw = usePawToggle(initialReacted, initialCount);
|
||||
const toggle = useMutation(trpc.stories.toggleReaction.mutationOptions(paw.callbacks));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !toggle.isPending && toggle.mutate({ storyId, petId })}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={reacted ? t("storyViewer.removePaw") : t("storyViewer.paw")}
|
||||
accessibilityState={{ selected: reacted }}
|
||||
accessibilityLabel={paw.reacted ? t("storyViewer.removePaw") : t("storyViewer.paw")}
|
||||
accessibilityState={{ selected: paw.reacted }}
|
||||
hitSlop={8}
|
||||
style={styles.wrap}
|
||||
>
|
||||
<View style={styles.circle}>
|
||||
<PawPrint size={20} color={reacted ? colors.marigoldOrange : colors.pureWhite} fill={reacted ? colors.marigoldOrange : "transparent"} />
|
||||
<PawPrint size={20} color={paw.reacted ? colors.marigoldOrange : colors.pureWhite} fill={paw.reacted ? colors.marigoldOrange : "transparent"} />
|
||||
</View>
|
||||
{count > 0 && <Text style={styles.count}>{count}</Text>}
|
||||
{paw.count > 0 && <Text style={styles.count}>{paw.count}</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState } from "react";
|
||||
|
||||
type PawState = { reacted: boolean; count: number };
|
||||
|
||||
/**
|
||||
* Optimistic paw toggle shared by PawButton (posts) and StoryPawButton:
|
||||
* state and count flip immediately, the server's `reacted` decides the final
|
||||
* count (state before the tap + actual change), errors roll back. Pass
|
||||
* `callbacks` into the tRPC `mutationOptions`.
|
||||
*/
|
||||
export function usePawToggle(initialReacted: boolean, initialCount: number) {
|
||||
const [state, setState] = useState<PawState>({ reacted: initialReacted, count: initialCount });
|
||||
|
||||
const callbacks = {
|
||||
onMutate: (): PawState => {
|
||||
setState({ reacted: !state.reacted, count: state.reacted ? Math.max(0, state.count - 1) : state.count + 1 });
|
||||
return state;
|
||||
},
|
||||
onSuccess: (data: { reacted: boolean }, _vars: unknown, previous: PawState | undefined) => {
|
||||
if (!previous) return;
|
||||
const delta = data.reacted === previous.reacted ? 0 : data.reacted ? 1 : -1;
|
||||
setState({ reacted: data.reacted, count: Math.max(0, previous.count + delta) });
|
||||
},
|
||||
onError: (_err: unknown, _vars: unknown, previous: PawState | undefined) => {
|
||||
if (previous) setState(previous);
|
||||
},
|
||||
};
|
||||
|
||||
return { ...state, callbacks };
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { useAuth } from "@clerk/expo";
|
||||
import { useTRPC } from "../api/trpc";
|
||||
|
||||
/** Token dieses Geräts, sobald registriert — zum Abmelden beim Logout (owner.clearPushToken). */
|
||||
let registeredPushToken: string | null = null;
|
||||
export function getRegisteredPushToken(): string | null {
|
||||
return registeredPushToken;
|
||||
}
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
@@ -72,7 +78,11 @@ export function usePushNotifications(): void {
|
||||
setTimeout(() => reject(new Error("Timed out after 15s")), 15000)
|
||||
),
|
||||
]);
|
||||
if (!cancelled) registerMutation.mutate({ expoPushToken: token });
|
||||
if (!cancelled) {
|
||||
registeredPushToken = token;
|
||||
// Push an mehrere Geräte: jedes Gerät wird zusätzlich eingetragen, nichts wird überschrieben.
|
||||
registerMutation.mutate({ expoPushToken: token, platform: Platform.OS === "ios" ? "ios" : "android" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[push] Failed to get Expo push token", err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AccessibilityInfo } from "react-native";
|
||||
|
||||
/** Follows the OS "Reduce motion" setting (iOS + Android), live. */
|
||||
export function useReducedMotion(): boolean {
|
||||
const [isReduced, setIsReduced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
AccessibilityInfo.isReduceMotionEnabled().then(setIsReduced).catch(() => {});
|
||||
const subscription = AccessibilityInfo.addEventListener("reduceMotionChanged", setIsReduced);
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
return isReduced;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Animated } from "react-native";
|
||||
import { useReducedMotion } from "./useReducedMotion";
|
||||
|
||||
const SHAKE_STEPS = [-8, 7, -5, 3, 0];
|
||||
const STEP_MS = 70;
|
||||
|
||||
/** Short sideways shake for a form that was rejected (wrong password etc.). */
|
||||
export function useShake() {
|
||||
const [offset] = useState(() => new Animated.Value(0));
|
||||
const isReducedMotion = useReducedMotion();
|
||||
|
||||
const shake = useCallback(() => {
|
||||
if (isReducedMotion) return;
|
||||
offset.setValue(0);
|
||||
Animated.sequence(
|
||||
SHAKE_STEPS.map((toValue) => Animated.timing(offset, { toValue, duration: STEP_MS, useNativeDriver: true })),
|
||||
).start();
|
||||
}, [isReducedMotion, offset]);
|
||||
|
||||
return { shakeStyle: { transform: [{ translateX: offset }] }, shake };
|
||||
}
|
||||
@@ -217,7 +217,8 @@
|
||||
"unknown": "Neue Mitteilung.",
|
||||
"petDeceased": "ist über die Regenbogenbrücke gegangen. In liebevoller Erinnerung 🤍",
|
||||
"petLost": "wird vermisst. Vielleicht kannst du helfen – tippe für alle Infos.",
|
||||
"petFound": "ist wieder sicher zu Hause. Danke fürs Mithelfen! 🎉"
|
||||
"petFound": "ist wieder sicher zu Hause. Danke fürs Mithelfen! 🎉",
|
||||
"caretakerRequest": "Jemand möchte dein Tier mitbetreuen – bitte prüfe die Anfrage."
|
||||
},
|
||||
"pets": {
|
||||
"empty": "Noch keine Tiere angelegt.",
|
||||
@@ -244,8 +245,6 @@
|
||||
"logoutConfirmMessage": "Du wirst von deinem Konto abgemeldet und zum Login zurückgeschickt."
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "PawFeed Login",
|
||||
"signUpTitle": "PawFeed Registrierung",
|
||||
"firstNamePlaceholder": "Vorname",
|
||||
"lastNamePlaceholder": "Nachname",
|
||||
"legalConsentLabel": "Ich akzeptiere die Nutzungsbedingungen und die Datenschutzerklärung.",
|
||||
@@ -255,8 +254,6 @@
|
||||
"signInButton": "Anmelden",
|
||||
"signUpButton": "Registrieren",
|
||||
"confirmButton": "Bestätigen",
|
||||
"noAccountLink": "Noch kein Account? Registrieren",
|
||||
"hasAccountLink": "Schon registriert? Anmelden",
|
||||
"verifyDeviceTitle": "Gerät bestätigen",
|
||||
"verifyDeviceHint": "Wir haben einen Code an {{email}} geschickt, um dieses Gerät zu bestätigen.",
|
||||
"verifyEmailTitle": "E-Mail bestätigen",
|
||||
@@ -264,37 +261,62 @@
|
||||
"signInIncompleteError": "Anmeldung unvollständig — Status: {{status}}",
|
||||
"signUpIncompleteError": "Registrierung unvollständig — Status: {{status}}",
|
||||
"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.",
|
||||
"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."
|
||||
"appleNeedsSignUpError": "Für diese Apple-ID existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen.",
|
||||
"tagline": "Das soziale Netzwerk für Tiere",
|
||||
"welcomeBack": "Willkommen zurück",
|
||||
"welcomeBackHint": "Schau, was dein Tier verpasst hat.",
|
||||
"signUpHeading": "Konto erstellen",
|
||||
"signUpHint": "Gleich bekommt dein Tier sein eigenes Profil.",
|
||||
"continueWith": "oder weiter mit",
|
||||
"noAccountPrompt": "Noch kein Account?",
|
||||
"hasAccountPrompt": "Schon registriert?",
|
||||
"consentFirstHint": "Bitte setz zuerst den Haken bei den Nutzungsbedingungen, dann kannst du dich auch so registrieren."
|
||||
},
|
||||
"onboarding": {
|
||||
"addPetTitle": "Tier hinzufügen",
|
||||
"guidelinesTitle": "Bevor es losgeht",
|
||||
"guidelinesSubtitle": "Ein kurzer Überblick über unsere Community-Richtlinien.",
|
||||
"termsLink": "Alle Details in unseren Nutzungsbedingungen",
|
||||
"privacyLink": "Datenschutzerklärung (inkl. Cookies)",
|
||||
"guidelinesContinue": "Verstanden, weiter",
|
||||
"consentTitle": "Zwei kurze Bestätigungen",
|
||||
"consentSubtitle": "Bevor du dein erstes Tier anlegst, brauchen wir deine ausdrückliche Zustimmung zu Folgendem.",
|
||||
"contentRightsLabel": "Ich bestätige, dass ich alle Rechte an den von mir hochgeladenen Bildern und Videos besitze und dass diese kein fremdes Urheberrecht verletzen.",
|
||||
"dataProcessingLabel": "Ich willige ein, dass meine Daten zur Bereitstellung des Dienstes an Clerk (Authentifizierung), Cloudflare (Medienspeicherung), Mux (Videoverarbeitung) und Supabase (Datenbank) übermittelt werden.",
|
||||
"consentPrivacyLink": "Details zu den eingesetzten Dienstleistern in unserer Datenschutzerklärung",
|
||||
"consentContinue": "Bestätigen und weiter",
|
||||
"arachnophobiaTitle": "Arachnophobie-Helfer",
|
||||
"arachnophobiaSubtitle": "PawFeed ist jetzt auch für Spinnen-Halter da. Wenn dir vor Spinnenbildern graut, blenden wir sie für dich komplett aus — im Feed, bei Explore und in der Suche.",
|
||||
"arachnophobiaCheckboxLabel": "Ja, bitte Spinnen-Inhalte für mich ausblenden.",
|
||||
"arachnophobiaHint": "Du kannst das jederzeit unter \"Meine Tiere\" wieder ändern.",
|
||||
"avatarTitle": "Profilfoto hinzufügen",
|
||||
"avatarSubtitle": "Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.",
|
||||
"galleryButton": "Galerie",
|
||||
"cameraButton": "Kamera",
|
||||
"skipLink": "Jetzt überspringen",
|
||||
"uploadError": "Upload fehlgeschlagen — bitte erneut versuchen.",
|
||||
"galleryPermissionDenied": "Zugriff auf Fotos wurde nicht erlaubt.",
|
||||
"cameraPermissionDenied": "Zugriff auf die Kamera wurde nicht erlaubt."
|
||||
"cameraPermissionDenied": "Zugriff auf die Kamera wurde nicht erlaubt.",
|
||||
"back": "Zurück",
|
||||
"close": "Schließen",
|
||||
"saveError": "Das hat nicht geklappt. Bitte versuch es noch einmal.",
|
||||
"welcomeTitle": "Willkommen bei PawFeed",
|
||||
"welcomeSubtitle": "Hier ist dein Tier der Star. Drei Regeln, dann geht's los.",
|
||||
"rule1Title": "Respektvoll bleiben",
|
||||
"rule1Body": "Keine Beleidigungen, kein Mobbing, keine Drohungen.",
|
||||
"rule2Title": "Tierwohl zuerst",
|
||||
"rule2Body": "Nichts, was Tieren schadet oder Tierquälerei zeigt.",
|
||||
"rule3Title": "Echt und sauber",
|
||||
"rule3Body": "Keine illegalen oder anstößigen Inhalte, kein Spam, keine Fake-Profile.",
|
||||
"termsShort": "Nutzungsbedingungen",
|
||||
"privacyShort": "Datenschutz",
|
||||
"welcomeContinue": "Verstanden, los geht's",
|
||||
"welcomeContinueConsent": "Zustimmen und loslegen",
|
||||
"nameTitle": "Wie heißt dein Tier?",
|
||||
"nameSubtitle": "So steht es auf seinem Profil. Ändern geht jederzeit.",
|
||||
"addNickname": "+ Spitzname hinzufügen",
|
||||
"speciesTitle": "Was für ein Tier ist {{name}}?",
|
||||
"speciesSubtitle": "Damit Tiere wie deins euch finden.",
|
||||
"breedOptional": "Rasse (optional)",
|
||||
"aboutTitle": "Erzähl uns von {{name}}",
|
||||
"aboutSubtitle": "Beides freiwillig. Das macht das Profil lebendig.",
|
||||
"aboutCreate": "Profil anlegen",
|
||||
"storyPlaceholder": "Wie ist {{name}} zu dir gekommen?",
|
||||
"photoTitle": "Ein Foto von {{name}}",
|
||||
"photoSubtitle": "Mit Foto erkennen andere {{name}} sofort im Feed. Ändern geht jederzeit.",
|
||||
"spiderTitle": "Spinnen ausblenden?",
|
||||
"spiderSubtitle": "PawFeed ist auch für Spinnen-Halter da. Wenn dir vor Spinnenbildern graut, siehst du sie nirgends.",
|
||||
"spiderToggle": "Spinnen ausblenden",
|
||||
"spiderWhere": "Feed · Explore · Suche",
|
||||
"doneTitle": "{{name}} ist jetzt auf PawFeed!",
|
||||
"doneSubtitle": "Schau dich um, folge anderen Tieren und poste das erste Foto.",
|
||||
"doneCta": "Zum Feed"
|
||||
},
|
||||
"petForm": {
|
||||
"createTitle": "Füge dein erstes Tier hinzu",
|
||||
@@ -337,7 +359,8 @@
|
||||
"newStory": "Neue Story",
|
||||
"newMilestone": "Neuer Meilenstein",
|
||||
"deleteAccount": "Konto löschen",
|
||||
"invite": "Freunde einladen"
|
||||
"invite": "Freunde einladen",
|
||||
"lost": "Vermisst"
|
||||
},
|
||||
"block": {
|
||||
"menuItem": "{{petName}} blockieren",
|
||||
@@ -369,7 +392,6 @@
|
||||
},
|
||||
"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",
|
||||
@@ -381,12 +403,8 @@
|
||||
"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",
|
||||
@@ -420,7 +438,18 @@
|
||||
"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"
|
||||
"source_OTHER": "Anders",
|
||||
"cardFirstRankWaiting": "Dein erster Rang wartet",
|
||||
"cardYourRank": "Dein Rang",
|
||||
"cardCodeLabel": "Dein Einladungscode",
|
||||
"cardFirstInvite_one": "Lade 1 Freund ein, der mit seinem Tier mitmacht",
|
||||
"cardFirstInvite_other": "Lade {{count}} Freunde ein, die mit ihrem Tier mitmachen",
|
||||
"cardProgress_one": "{{done}} von {{count}} Freund bis {{name}}",
|
||||
"cardProgress_other": "{{done}} von {{count}} Freunden bis {{name}}",
|
||||
"cardRanks": "Ränge",
|
||||
"ladderNext": "Als Nächstes",
|
||||
"ladderThreshold_one": "{{count}} Freund",
|
||||
"ladderThreshold_other": "{{count}} Freunde"
|
||||
},
|
||||
"storyViewer": {
|
||||
"paw": "Pfote geben",
|
||||
@@ -522,7 +551,7 @@
|
||||
"lastSeenPlacePlaceholder": "z. B. Stadtpark Nord, Nähe Spielplatz",
|
||||
"description": "Erkennungsmerkmale & Hinweise",
|
||||
"descriptionPlaceholder": "z. B. rotes Halsband, gechippt, eher scheu – bitte nicht rufen",
|
||||
"contactHint": "Kontaktdaten sind freiwillig und erscheinen nur auf der Teilen-Seite dieser Meldung. Eine Nachricht über PawFeed ist immer möglich.",
|
||||
"contactHint": "Kontaktdaten sind freiwillig – bitte nur deine eigenen eintragen. Sie erscheinen nur auf der Teilen-Seite dieser Meldung und werden gelöscht, sobald die Meldung beendet ist. Eine Nachricht über PawFeed ist immer möglich.",
|
||||
"contactPhone": "Telefonnummer (optional)",
|
||||
"contactEmail": "E-Mail (optional)",
|
||||
"allowIndexing": "Seite darf über Suchmaschinen gefunden werden",
|
||||
@@ -535,14 +564,73 @@
|
||||
"share": "Link teilen",
|
||||
"shareTitle": "Vermisst: {{name}}",
|
||||
"copied": "Link kopiert",
|
||||
"created": "Die Meldung ist raus. Wir drücken die Daumen, dass {{name}} schnell nach Hause findet.",
|
||||
"createdTitle": "Die Meldung ist raus",
|
||||
"created": "Wir drücken die Daumen, dass {{name}} schnell nach Hause findet.",
|
||||
"updated": "Angaben gespeichert.",
|
||||
"foundToast": "Wie schön – willkommen zurück, {{name}}! 🎉",
|
||||
"foundTitle": "Wie schön! 🎉",
|
||||
"foundToast": "Willkommen zurück, {{name}}!",
|
||||
"errorTitle": "Hoppla",
|
||||
"saveFailed": "Das hat leider nicht geklappt. Bitte versuch es noch einmal.",
|
||||
"bannerTitle": "{{name}} wird vermisst",
|
||||
"bannerText": "Zuletzt gesehen: {{place}} · {{date}}",
|
||||
"bannerLink": "Alle Infos & Seite teilen",
|
||||
"timePlaceholder": "HH:MM",
|
||||
"invalidForm": "Bitte gib Datum, Uhrzeit (z. B. 14:30) und den Ort an, wo du dein Tier zuletzt gesehen hast."
|
||||
},
|
||||
"lostView": {
|
||||
"badge": "VERMISST",
|
||||
"heading": "Hast du {{name}} gesehen?",
|
||||
"lastSeenPlace": "Zuletzt gesehen",
|
||||
"lastSeenAt": "Wann",
|
||||
"details": "Erkennungsmerkmale & Hinweise",
|
||||
"contactHeading": "Bitte melde dich, wenn du {{name}} siehst",
|
||||
"message": "Nachricht schreiben",
|
||||
"mailSubject": "Hinweis zu {{name}}",
|
||||
"approachHint": "Bitte das Tier nicht verfolgen oder einfangen wollen – verängstigte Tiere laufen sonst oft weiter weg. Ruhig beobachten, Ort und Zeit merken und Bescheid geben.",
|
||||
"photosHeading": "Weitere Fotos",
|
||||
"photoAlt": "Foto von {{name}}",
|
||||
"share": "Meldung teilen – jede Weitergabe hilft",
|
||||
"shareText": "Vermisst: {{name}} – hast du dieses Tier gesehen?",
|
||||
"toProfile": "Zum Profil",
|
||||
"notActiveTitle": "Keine aktive Vermisst-Meldung",
|
||||
"notActiveText": "Vielleicht ist das Tier schon wieder sicher zu Hause. 🎉"
|
||||
},
|
||||
"caretaker": {
|
||||
"heading": "Mitbetreuer",
|
||||
"intro": "Kümmert sich noch jemand um {{name}}? Lade eine zweite Person ein – sie nutzt ihr eigenes Konto und kann als {{name}} posten, Nachrichten beantworten und die Gesundheitsdaten sehen.",
|
||||
"createInvite": "Mitbetreuer einladen",
|
||||
"inviteHint": "Gib diesen Code an die Person weiter. Sie gibt ihn unter „Meine Tiere“ oder bei der Anmeldung ein. Danach entscheidest du, ob du die Anfrage freigibst.",
|
||||
"share": "Code teilen",
|
||||
"shareMessage": "Hilfst du mir, {{name}} auf PawFeed mitzubetreuen? Gib in der App unter „Meine Tiere“ diesen Code ein: {{code}} – oder öffne {{link}}",
|
||||
"validUntil": "Gültig bis {{date}}",
|
||||
"newCode": "Neuen Code erzeugen",
|
||||
"revokeInvite": "Einladung zurückziehen",
|
||||
"requestFrom": "Anfrage zur Mitbetreuung von {{who}}",
|
||||
"requestHint": "Gibst du frei, kann diese Person als {{name}} handeln. Löschen, Privatsphäre und Gedenkmodus bleiben bei dir.",
|
||||
"approve": "Freigeben",
|
||||
"reject": "Ablehnen",
|
||||
"approved": "Freigegeben – ihr kümmert euch jetzt gemeinsam.",
|
||||
"activeWith": "{{who}} betreut {{name}} mit dir.",
|
||||
"remove": "Mitbetreuer entfernen",
|
||||
"removeConfirm": "Soll die Person {{name}} nicht mehr mitbetreuen? Ihre bisherigen Beiträge als {{name}} bleiben erhalten.",
|
||||
"youCoCare": "Du betreust {{name}} mit.",
|
||||
"caretakerLimits": "Löschen, Privatsphäre und Gedenkmodus verwaltet der Haupt-Besitzer.",
|
||||
"leave": "Mitbetreuung beenden",
|
||||
"leaveConfirm": "Möchtest du {{name}} nicht mehr mitbetreuen? Du kannst später wieder eingeladen werden.",
|
||||
"unknownAccount": "einem anderen Konto",
|
||||
"failed": "Das hat leider nicht geklappt.",
|
||||
"codeLabel": "Einladungscode",
|
||||
"codeHint": "Den Code bekommst du vom Haupt-Besitzer des Tiers. Er ist 7 Tage gültig.",
|
||||
"redeem": "Einlösen",
|
||||
"requestSent": "Anfrage gesendet – sobald sie freigegeben ist, findest du {{name}} unter „Meine Tiere“.",
|
||||
"waiting": "Deine Anfrage für {{name}} wartet auf Freigabe.",
|
||||
"stillWaiting": "Noch nicht freigegeben – schau gern später noch einmal.",
|
||||
"checkAgain": "Erneut prüfen",
|
||||
"withdraw": "Zurückziehen",
|
||||
"redeemHeading": "Ich betreue ein Tier mit",
|
||||
"onboardingSwitch": "Ich betreue ein Tier mit",
|
||||
"onboardingTitle": "Ein Tier mitbetreuen",
|
||||
"onboardingDesc": "Gib den Einladungscode ein, den du vom Haupt-Besitzer bekommen hast. Sobald er deine Anfrage freigibt, kannst du loslegen.",
|
||||
"onboardingBack": "Doch ein eigenes Tier anlegen"
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,8 @@
|
||||
"unknown": "New notification.",
|
||||
"petDeceased": "has crossed the rainbow bridge. In loving memory 🤍",
|
||||
"petLost": "is missing. Maybe you can help – tap for all details.",
|
||||
"petFound": "is safely back home. Thanks for helping! 🎉"
|
||||
"petFound": "is safely back home. Thanks for helping! 🎉",
|
||||
"caretakerRequest": "Someone would like to co-care for your pet – please review the request."
|
||||
},
|
||||
"pets": {
|
||||
"empty": "No pets added yet.",
|
||||
@@ -244,8 +245,6 @@
|
||||
"logoutConfirmMessage": "You'll be signed out of your account and sent back to the login screen."
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "PawFeed Login",
|
||||
"signUpTitle": "PawFeed Sign Up",
|
||||
"firstNamePlaceholder": "First name",
|
||||
"lastNamePlaceholder": "Last name",
|
||||
"legalConsentLabel": "I accept the Terms of Service and the Privacy Policy.",
|
||||
@@ -255,8 +254,6 @@
|
||||
"signInButton": "Sign in",
|
||||
"signUpButton": "Sign up",
|
||||
"confirmButton": "Confirm",
|
||||
"noAccountLink": "No account yet? Sign up",
|
||||
"hasAccountLink": "Already have an account? Sign in",
|
||||
"verifyDeviceTitle": "Confirm device",
|
||||
"verifyDeviceHint": "We sent a code to {{email}} to confirm this device.",
|
||||
"verifyEmailTitle": "Confirm email",
|
||||
@@ -264,37 +261,62 @@
|
||||
"signInIncompleteError": "Sign-in incomplete — status: {{status}}",
|
||||
"signUpIncompleteError": "Sign-up incomplete — status: {{status}}",
|
||||
"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.",
|
||||
"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."
|
||||
"appleNeedsSignUpError": "There's no PawFeed account for this Apple ID yet. Please continue via \"Sign up\" to accept the Terms of Service.",
|
||||
"tagline": "The social network for pets",
|
||||
"welcomeBack": "Welcome back",
|
||||
"welcomeBackHint": "See what your pet has missed.",
|
||||
"signUpHeading": "Create account",
|
||||
"signUpHint": "Your pet gets its own profile in a minute.",
|
||||
"continueWith": "or continue with",
|
||||
"noAccountPrompt": "No account yet?",
|
||||
"hasAccountPrompt": "Already registered?",
|
||||
"consentFirstHint": "Please tick the Terms of Service box first, then you can sign up this way too."
|
||||
},
|
||||
"onboarding": {
|
||||
"addPetTitle": "Add pet",
|
||||
"guidelinesTitle": "Before you get started",
|
||||
"guidelinesSubtitle": "A quick overview of our community guidelines.",
|
||||
"termsLink": "See the full details in our Terms of Service",
|
||||
"privacyLink": "Privacy Policy (incl. cookies)",
|
||||
"guidelinesContinue": "Got it, continue",
|
||||
"consentTitle": "Two quick confirmations",
|
||||
"consentSubtitle": "Before you create your first pet, we need your explicit consent to the following.",
|
||||
"contentRightsLabel": "I confirm that I own all rights to the images and videos I upload and that they don't infringe on anyone else's copyright.",
|
||||
"dataProcessingLabel": "I consent to my data being shared with Clerk (authentication), Cloudflare (media storage), Mux (video processing), and Supabase (database) to provide the service.",
|
||||
"consentPrivacyLink": "Details on the service providers we use are in our Privacy Policy",
|
||||
"consentContinue": "Confirm and continue",
|
||||
"arachnophobiaTitle": "Arachnophobia helper",
|
||||
"arachnophobiaSubtitle": "PawFeed now welcomes spider owners too. If spider photos give you the creeps, we'll hide them completely for you — in the feed, on Explore, and in search.",
|
||||
"arachnophobiaCheckboxLabel": "Yes, please hide spider content for me.",
|
||||
"arachnophobiaHint": "You can change this anytime under \"My Pets\".",
|
||||
"avatarTitle": "Add a profile photo",
|
||||
"avatarSubtitle": "Give your pet a face on PawFeed. You can change this anytime.",
|
||||
"galleryButton": "Gallery",
|
||||
"cameraButton": "Camera",
|
||||
"skipLink": "Skip for now",
|
||||
"uploadError": "Upload failed — please try again.",
|
||||
"galleryPermissionDenied": "Access to photos was not granted.",
|
||||
"cameraPermissionDenied": "Access to the camera was not granted."
|
||||
"cameraPermissionDenied": "Access to the camera was not granted.",
|
||||
"back": "Back",
|
||||
"close": "Close",
|
||||
"saveError": "That didn't work. Please try again.",
|
||||
"welcomeTitle": "Welcome to PawFeed",
|
||||
"welcomeSubtitle": "Here your pet is the star. Three rules, then let's go.",
|
||||
"rule1Title": "Be respectful",
|
||||
"rule1Body": "No insults, bullying or threats.",
|
||||
"rule2Title": "Animal welfare first",
|
||||
"rule2Body": "Nothing that harms animals or shows animal cruelty.",
|
||||
"rule3Title": "Keep it real",
|
||||
"rule3Body": "No illegal or explicit content, no spam, no fake profiles.",
|
||||
"termsShort": "Terms of Service",
|
||||
"privacyShort": "Privacy Policy",
|
||||
"welcomeContinue": "Got it, let's go",
|
||||
"welcomeContinueConsent": "Agree and get started",
|
||||
"nameTitle": "What's your pet's name?",
|
||||
"nameSubtitle": "That's how it appears on its profile. You can change it anytime.",
|
||||
"addNickname": "+ Add a nickname",
|
||||
"speciesTitle": "What kind of animal is {{name}}?",
|
||||
"speciesSubtitle": "So pets like yours can find you.",
|
||||
"breedOptional": "Breed (optional)",
|
||||
"aboutTitle": "Tell us about {{name}}",
|
||||
"aboutSubtitle": "Both optional. It brings the profile to life.",
|
||||
"aboutCreate": "Create profile",
|
||||
"storyPlaceholder": "How did {{name}} come to you?",
|
||||
"photoTitle": "A photo of {{name}}",
|
||||
"photoSubtitle": "With a photo, others spot {{name}} right away in the feed. You can change it anytime.",
|
||||
"spiderTitle": "Hide spiders?",
|
||||
"spiderSubtitle": "PawFeed welcomes spider keepers too. If spider photos make you shudder, you won't see them anywhere.",
|
||||
"spiderToggle": "Hide spiders",
|
||||
"spiderWhere": "Feed · Explore · Search",
|
||||
"doneTitle": "{{name}} is now on PawFeed!",
|
||||
"doneSubtitle": "Look around, follow other pets and post the first photo.",
|
||||
"doneCta": "Go to feed"
|
||||
},
|
||||
"petForm": {
|
||||
"createTitle": "Add your first pet",
|
||||
@@ -337,7 +359,8 @@
|
||||
"newStory": "New story",
|
||||
"newMilestone": "New milestone",
|
||||
"deleteAccount": "Delete account",
|
||||
"invite": "Invite friends"
|
||||
"invite": "Invite friends",
|
||||
"lost": "Missing"
|
||||
},
|
||||
"block": {
|
||||
"menuItem": "Block {{petName}}",
|
||||
@@ -369,7 +392,6 @@
|
||||
},
|
||||
"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",
|
||||
@@ -381,12 +403,8 @@
|
||||
"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",
|
||||
@@ -420,7 +438,18 @@
|
||||
"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"
|
||||
"source_OTHER": "Other",
|
||||
"cardFirstRankWaiting": "Your first rank is waiting",
|
||||
"cardYourRank": "Your rank",
|
||||
"cardCodeLabel": "Your invite code",
|
||||
"cardFirstInvite_one": "Invite 1 friend who joins with their pet",
|
||||
"cardFirstInvite_other": "Invite {{count}} friends who join with their pets",
|
||||
"cardProgress_one": "{{done}} of {{count}} friend to {{name}}",
|
||||
"cardProgress_other": "{{done}} of {{count}} friends to {{name}}",
|
||||
"cardRanks": "Ranks",
|
||||
"ladderNext": "Up next",
|
||||
"ladderThreshold_one": "{{count}} friend",
|
||||
"ladderThreshold_other": "{{count}} friends"
|
||||
},
|
||||
"storyViewer": {
|
||||
"paw": "Give a paw",
|
||||
@@ -522,7 +551,7 @@
|
||||
"lastSeenPlacePlaceholder": "e.g. North City Park, near the playground",
|
||||
"description": "Distinguishing features & notes",
|
||||
"descriptionPlaceholder": "e.g. red collar, microchipped, rather shy – please don't call out",
|
||||
"contactHint": "Contact details are optional and only appear on this report's share page. A message via PawFeed is always possible.",
|
||||
"contactHint": "Contact details are optional – please only enter your own. They only appear on this report's share page and are deleted once the report ends. A message via PawFeed is always possible.",
|
||||
"contactPhone": "Phone number (optional)",
|
||||
"contactEmail": "Email (optional)",
|
||||
"allowIndexing": "Allow search engines to find this page",
|
||||
@@ -535,14 +564,73 @@
|
||||
"share": "Share link",
|
||||
"shareTitle": "Missing: {{name}}",
|
||||
"copied": "Link copied",
|
||||
"created": "Your report is out. We're keeping our fingers crossed that {{name}} finds the way home soon.",
|
||||
"createdTitle": "Your report is out",
|
||||
"created": "We're keeping our fingers crossed that {{name}} finds the way home soon.",
|
||||
"updated": "Details saved.",
|
||||
"foundToast": "How wonderful – welcome back, {{name}}! 🎉",
|
||||
"foundTitle": "How wonderful! 🎉",
|
||||
"foundToast": "Welcome back, {{name}}!",
|
||||
"errorTitle": "Oops",
|
||||
"saveFailed": "That didn't work, sorry. Please try again.",
|
||||
"bannerTitle": "{{name}} is missing",
|
||||
"bannerText": "Last seen: {{place}} · {{date}}",
|
||||
"bannerLink": "All details & share page",
|
||||
"timePlaceholder": "HH:MM",
|
||||
"invalidForm": "Please enter the date, time (e.g. 14:30) and the place where you last saw your pet."
|
||||
},
|
||||
"lostView": {
|
||||
"badge": "MISSING",
|
||||
"heading": "Have you seen {{name}}?",
|
||||
"lastSeenPlace": "Last seen",
|
||||
"lastSeenAt": "When",
|
||||
"details": "Distinguishing features & notes",
|
||||
"contactHeading": "Please get in touch if you see {{name}}",
|
||||
"message": "Send a message",
|
||||
"mailSubject": "A tip about {{name}}",
|
||||
"approachHint": "Please don't chase or try to catch the pet – frightened animals often run further away. Watch calmly, note the place and time and let us know.",
|
||||
"photosHeading": "More photos",
|
||||
"photoAlt": "Photo of {{name}}",
|
||||
"share": "Share report – every share helps",
|
||||
"shareText": "Missing: {{name}} – have you seen this pet?",
|
||||
"toProfile": "Go to profile",
|
||||
"notActiveTitle": "No active missing report",
|
||||
"notActiveText": "Maybe the pet is already safely back home. 🎉"
|
||||
},
|
||||
"caretaker": {
|
||||
"heading": "Co-caretaker",
|
||||
"intro": "Does someone else look after {{name}} too? Invite a second person – they use their own account and can post as {{name}}, answer messages and see the health records.",
|
||||
"createInvite": "Invite a co-caretaker",
|
||||
"inviteHint": "Share this code with that person. They enter it under “My pets” or during sign-up. You then decide whether to approve the request.",
|
||||
"share": "Share code",
|
||||
"shareMessage": "Would you help me co-care for {{name}} on PawFeed? Enter this code in the app under “My pets”: {{code}} – or open {{link}}",
|
||||
"validUntil": "Valid until {{date}}",
|
||||
"newCode": "Create new code",
|
||||
"revokeInvite": "Withdraw invitation",
|
||||
"requestFrom": "Co-care request from {{who}}",
|
||||
"requestHint": "If you approve, this person can act as {{name}}. Deleting, privacy and memorial mode stay with you.",
|
||||
"approve": "Approve",
|
||||
"reject": "Decline",
|
||||
"approved": "Approved – you now care for this pet together.",
|
||||
"activeWith": "{{who}} co-cares for {{name}} with you.",
|
||||
"remove": "Remove co-caretaker",
|
||||
"removeConfirm": "Should this person stop co-caring for {{name}}? Their posts as {{name}} stay.",
|
||||
"youCoCare": "You co-care for {{name}}.",
|
||||
"caretakerLimits": "Deleting, privacy and memorial mode are managed by the main owner.",
|
||||
"leave": "Stop co-caring",
|
||||
"leaveConfirm": "Do you want to stop co-caring for {{name}}? You can be invited again later.",
|
||||
"unknownAccount": "another account",
|
||||
"failed": "Sorry, that didn't work.",
|
||||
"codeLabel": "Invitation code",
|
||||
"codeHint": "You get the code from the pet's main owner. It is valid for 7 days.",
|
||||
"redeem": "Redeem",
|
||||
"requestSent": "Request sent – once approved, you'll find {{name}} under “My pets”.",
|
||||
"waiting": "Your request for {{name}} is waiting for approval.",
|
||||
"stillWaiting": "Not approved yet – feel free to check again later.",
|
||||
"checkAgain": "Check again",
|
||||
"withdraw": "Withdraw",
|
||||
"redeemHeading": "I co-care for a pet",
|
||||
"onboardingSwitch": "I co-care for a pet",
|
||||
"onboardingTitle": "Co-care for a pet",
|
||||
"onboardingDesc": "Enter the invitation code you got from the main owner. As soon as they approve your request, you're all set.",
|
||||
"onboardingBack": "Create my own pet instead"
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,22 @@ describe("notificationRoute", () => {
|
||||
});
|
||||
|
||||
it("opens the actor's profile for follows, suggestions, referrals and memorials", () => {
|
||||
for (const type of ["FOLLOW", "PET_SUGGESTION", "REFERRAL_JOINED", "PET_DECEASED", "PET_LOST", "PET_FOUND"]) {
|
||||
for (const type of ["FOLLOW", "PET_SUGGESTION", "REFERRAL_JOINED", "PET_DECEASED", "PET_FOUND"]) {
|
||||
expect(notificationRoute({ type, actorPetId: "a1" }, NOW)).toBe("/pets/a1");
|
||||
}
|
||||
expect(notificationRoute({ type: "REFERRAL_RANK_UP" }, NOW)).toBe("/invite");
|
||||
});
|
||||
|
||||
it("opens the in-app missing view for a missing pet", () => {
|
||||
expect(notificationRoute({ type: "PET_LOST", actorPetId: "lost1" }, NOW)).toBe("/lost/lost1");
|
||||
expect(notificationRoute({ type: "PET_LOST" }, NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the own pet's edit screen for a co-care request", () => {
|
||||
expect(notificationRoute({ type: "CARETAKER_REQUEST", recipientPetId: "me" }, NOW)).toBe("/pets/me/edit");
|
||||
expect(notificationRoute({ type: "CARETAKER_REQUEST" }, NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the own pet's health page for birthdays, adoption days and vaccines", () => {
|
||||
for (const type of ["BIRTHDAY", "ADOPTION_DAY", "VACCINE_DUE"]) {
|
||||
expect(notificationRoute({ type, recipientPetId: "me" }, NOW)).toBe("/pets/me/health");
|
||||
|
||||
@@ -52,11 +52,15 @@ export function notificationRoute(target: NotificationTarget, now: number): stri
|
||||
case "PET_SUGGESTION":
|
||||
case "REFERRAL_JOINED":
|
||||
case "PET_DECEASED":
|
||||
case "PET_LOST":
|
||||
case "PET_FOUND":
|
||||
return actorPetId ? `/pets/${actorPetId}` : null;
|
||||
case "PET_LOST":
|
||||
return actorPetId ? `/lost/${actorPetId}` : null;
|
||||
case "REFERRAL_RANK_UP":
|
||||
return "/invite";
|
||||
// Mitbetreuungs-Anfrage → Freigeben/Ablehnen in „Profil bearbeiten“
|
||||
case "CARETAKER_REQUEST":
|
||||
return recipientPetId ? `/pets/${recipientPetId}/edit` : null;
|
||||
case "BIRTHDAY":
|
||||
case "ADOPTION_DAY":
|
||||
case "VACCINE_DUE":
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Share } from "react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
import { env } from "./env";
|
||||
|
||||
export function referralLink(code: string): string {
|
||||
return `${env.apiUrl}/r/${code}`;
|
||||
}
|
||||
|
||||
/** Opens the native share sheet with the owner's invite text + link (no native module needed → OTA-safe). */
|
||||
export function shareReferralLink(code: string, t: TFunction): void {
|
||||
void Share.share({ message: `${t("referrals.shareText", { code })} ${referralLink(code)}` });
|
||||
}
|
||||
@@ -1,32 +1,13 @@
|
||||
import { File } from "expo-file-system";
|
||||
import { resizeForUpload, type ResizedImage } from "./resize-image";
|
||||
|
||||
/**
|
||||
* PUTs a local picked-image file directly to the Supabase Storage presigned
|
||||
* URL returned by `posts.getPresignedUrl` — mirrors web's XHR PUT in
|
||||
* `src/lib/upload-xhr.ts`, using expo-file-system's `File.upload` instead
|
||||
* since RN has no XHR-with-progress primitive by default. Content-Type must
|
||||
* exactly match what was passed to `getPresignedUrl` or Supabase responds
|
||||
* with 400 SignatureDoesNotMatch.
|
||||
*/
|
||||
export async function uploadImageToSupabase(uri: string, presignedUrl: string, mimeType: string): Promise<void> {
|
||||
const file = new File(uri);
|
||||
const result = await file.upload(presignedUrl, {
|
||||
httpMethod: "PUT",
|
||||
mimeType,
|
||||
headers: { "Content-Type": mimeType },
|
||||
});
|
||||
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
throw new Error(`Upload fehlgeschlagen (Status ${result.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUTs a local video file directly to a Mux direct-upload URL (from
|
||||
* `stories.createVideoUpload`). Separate function rather than reusing
|
||||
* `uploadImageToSupabase` above — same `File.upload` primitive, but a
|
||||
* different destination (Mux, not Supabase) and no signature-matching
|
||||
* requirement on Content-Type, so keeping the working photo path untouched.
|
||||
* PUTs a local file directly to a presigned upload URL — Supabase Storage
|
||||
* (photos, mirrors web's XHR PUT in `src/lib/upload-xhr.ts`) or Mux (story
|
||||
* videos). Uses expo-file-system's `File.upload` since RN has no
|
||||
* XHR-with-progress primitive by default. For Supabase, Content-Type must
|
||||
* exactly match what was passed to `getPresignedUrl` or it responds with
|
||||
* 400 SignatureDoesNotMatch.
|
||||
*/
|
||||
export async function uploadFileToUrl(
|
||||
uri: string,
|
||||
@@ -47,6 +28,21 @@ export async function uploadFileToUrl(
|
||||
});
|
||||
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
throw new Error(`Video-Upload fehlgeschlagen (Status ${result.status})`);
|
||||
throw new Error(`Upload fehlgeschlagen (Status ${result.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picked photo → downscaled JPEG → presigned URL → Supabase PUT. `getUploadUrl`
|
||||
* is the caller's `*.getPresignedUrl` mutation (posts, stories or media), with
|
||||
* the petId already bound. Returns the storage key and the uploaded file's uri.
|
||||
*/
|
||||
export async function uploadPickedImage(
|
||||
asset: { uri: string; width: number; height: number },
|
||||
getUploadUrl: (file: { contentType: ResizedImage["mimeType"]; fileSize: number }) => Promise<{ presignedUrl: string; key: string }>
|
||||
): Promise<{ key: string; uri: string }> {
|
||||
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
||||
const { presignedUrl, key } = await getUploadUrl({ contentType: resized.mimeType, fileSize: resized.fileSize });
|
||||
await uploadFileToUrl(resized.uri, presignedUrl, resized.mimeType);
|
||||
return { key, uri: resized.uri };
|
||||
}
|
||||
@@ -138,6 +138,13 @@ export function getTypography(theme: ColorTokens) {
|
||||
textTransform: "uppercase" as const,
|
||||
color: theme.quietGray,
|
||||
},
|
||||
/** Like `label`, but as written (no uppercase, no extra tracking). */
|
||||
caption: {
|
||||
fontFamily: fontFamily.semiBold,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
color: theme.quietGray,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 242 KiB |