Compare commits
108
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 | ||
|
|
9b52a20a15 | ||
|
|
1ea3a7172d | ||
|
|
9216f0c702 | ||
|
|
b244ef4c10 | ||
|
|
4cf28213ff | ||
|
|
9cf8369307 | ||
|
|
8aca1bc28e | ||
|
|
53d1382ac0 | ||
|
|
6d3136700b | ||
|
|
84a430a92a | ||
|
|
a907654de5 | ||
|
|
5676065400 | ||
|
|
5ffa472da0 | ||
|
|
0c448de4c1 | ||
|
|
795a39355e | ||
|
|
8c640e2837 | ||
|
|
66230a8695 | ||
|
|
0a0ff90745 | ||
|
|
c7c1473e2a | ||
|
|
0668459c1e | ||
|
|
dbc416c43c | ||
|
|
2bd4b2d973 | ||
|
|
361aba0d1d | ||
|
|
e60abbabc6 | ||
|
|
81e617ece5 | ||
|
|
c678c4cf27 | ||
|
|
f51b158989 | ||
|
|
01459c7e0e | ||
|
|
a2af4eda6a | ||
|
|
333ba16573 | ||
|
|
9136981af7 | ||
|
|
7ab74f8077 | ||
|
|
4214a26bd7 | ||
|
|
6a88214186 | ||
|
|
c74bbffccb | ||
|
|
7169840000 | ||
|
|
f112d36f4a | ||
|
|
80e27a7f0b | ||
|
|
5606a81dd2 | ||
|
|
6979447b6d | ||
|
|
69e2c1364e | ||
|
|
a9b6a57585 | ||
|
|
00fb0db168 | ||
|
|
f1ccc78005 | ||
|
|
9adba560a7 | ||
|
|
efc82d636d | ||
|
|
566a2eedd0 | ||
|
|
7a064d8a36 | ||
|
|
94c143e17d | ||
|
|
8143e7b667 | ||
|
|
307b76e8fd | ||
|
|
9f7acf1d98 | ||
|
|
2c0680cef9 | ||
|
|
a01c58e5f0 | ||
|
|
ac664d488d | ||
|
|
81d9c6da30 | ||
|
|
4ec5d8c5f4 | ||
|
|
ccebfa46a6 | ||
|
|
204d5f6831 | ||
|
|
1a24ebae89 | ||
|
|
1bf0f9282a | ||
|
|
8f8fc8d75e | ||
|
|
18f25ce316 | ||
|
|
2b285de09e |
No files matched your search
@@ -55,3 +55,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
# clerk configuration (can include secrets)
|
# clerk configuration (can include secrets)
|
||||||
/.clerk/
|
/.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
|
### 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
|
### Routing structure
|
||||||
|
|
||||||
@@ -84,13 +84,25 @@ All uploads are presigned/direct-to-storage — media bytes never pass through t
|
|||||||
|
|
||||||
### Admin / moderation panel
|
### Admin / moderation panel
|
||||||
|
|
||||||
Gated by path secret (see `proxy.ts` above) *and* by role at the tRPC layer: `src/trpc/routers/admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-configured super-admin bootstrap) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Actions are written to `ModerationLog`. Pages live under `src/app/p/[secret]/{users,moderators,posts,reports,ads,referrals,…}`.
|
Gated by path secret (see `proxy.ts` above) *and* by role at the tRPC layer: `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` (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
|
||||||
|
|
||||||
|
`pets.create` fires `welcomeNewPet()` (`src/lib/new-pet-welcome.ts`) in the background — it must never block pet creation. It (1) sets `Pet.isFoundingMember` via one guarded SQL update for the first 250 pets by `createdAt` (never revoked; badge = Sprout icon next to the name, web + mobile), (2) lets the curator pet "PawFeed Team" (`ensureTeamPet` in `src/lib/team-pet.ts`) follow the new pet, and (3) sends a welcome DM via `sendTeamMessage`. (2) and (3) are switchable and the DE/EN text (mandatory `{name}` placeholder) is editable in the admin panel under "Begrüßung" (`/p/[secret]/curator`, `CuratorSettings` id = 1, `curator-admin.ts`). Replies to the welcome DM land in the admin "Messages" team inbox.
|
||||||
|
|
||||||
### Referral program (Empfehlungsprogramm)
|
### Referral program (Empfehlungsprogramm)
|
||||||
|
|
||||||
Registration is open; invites are a **Clerk-independent** referral layer on top. Every owner has one permanent, unlimited `Owner.referralCode` (e.g. `BUBI-7K3M`, prefix from the first pet's name — `src/lib/referral-code.ts`). A code is never part of the Clerk sign-up: it is redeemed **after** sign-up, in the onboarding step "Wie hast du von PawFeed erfahren?" (`AcquisitionStep`, web + mobile) or later on `/einladen` (mobile: `/invite`) within `ReferralSettings.redeemWindowDays`. `/r/[code]` is the public landing page (sets a `pf_ref` cookie only to prefill the web onboarding); old `/join?code=` links redirect there. Logic lives in `src/lib/referrals.ts`: redeem sets `Owner.invitedById` and makes both first pets follow each other; an invite **counts** (`referralQualifiedAt`, inviter's denormalized `qualifiedReferralCount` +1) only once the invitee has a pet **and** a first real post — `maybeQualifyReferral()` is called from `posts.create` and the Mux webhook on `READY`; a new post-creating mutation must call it too.
|
Registration is open; invites are a **Clerk-independent** referral layer on top. Every owner has one permanent, unlimited `Owner.referralCode` (e.g. `BUBI-7K3M`, prefix from the first pet's name — `src/lib/referral-code.ts`). A code is never part of the Clerk sign-up: it is redeemed **after** sign-up, in the onboarding step "Wie hast du von PawFeed erfahren?" (`AcquisitionStep`, web + mobile) or later on `/einladen` (mobile: `/invite`) within `ReferralSettings.redeemWindowDays`. `/r/[code]` is the public landing page (sets a `pf_ref` cookie only to prefill the web onboarding); old `/join?code=` links redirect there. Logic lives in `src/lib/referrals.ts`: redeem sets `Owner.invitedById` and makes both first pets follow each other; an invite **counts** (`referralQualifiedAt`, inviter's denormalized `qualifiedReferralCount` +1) only once the invitee has a pet **and** a first real post — `maybeQualifyReferral()` is called from `posts.create` and the Mux webhook on `READY`; a new post-creating mutation must call it too.
|
||||||
|
|
||||||
Ranks (`ReferralRank`) are data, fully editable in the admin panel (`/p/[secret]/referrals`, `referral-admin.ts`); only the perk catalog (`ReferralPerk`, `IMPLEMENTED_REFERRAL_PERKS` in `src/lib/referral-ranks.ts`) needs code. Perks are cumulative. Reached ranks are **grandfathered** via `ReferralRankAchievement` — raising a threshold never removes a rank. `Owner.referralRankId`/`referralPerks` are a cache maintained only by `src/lib/referrals.ts` and the set-based `recomputeReferralRanks()` (`src/lib/referral-recompute.ts`, run after every admin rank change); feed cards read them via `petCardSelect`. The legacy `InviteCode` table is history only — nothing writes to it anymore. One-time setup after the schema push: `npx tsx prisma/scripts/referral-setup.ts` (+ `prisma/enable_rls_referrals.sql`).
|
Ranks (`ReferralRank`) are data, fully editable in the admin panel (`/p/[secret]/referrals`, `referral-admin.ts`); only the perk catalog (`ReferralPerk`, `IMPLEMENTED_REFERRAL_PERKS` in `src/lib/referral-ranks.ts`) needs code. Perks are cumulative. Reached ranks are **grandfathered** via `ReferralRankAchievement` — raising a threshold never removes a rank. An admin can revoke a rank permanently (`revokedAt` on the achievement row — the row stays so award/recompute never re-grant it; it is ignored for badges/perks until "Freigeben") or grant one manually (`grantedManually`). `Owner.referralRankId`/`referralPerks` are a cache maintained only by `src/lib/referrals.ts` and the set-based `recomputeReferralRanks()` (`src/lib/referral-recompute.ts`, run after every admin rank change); feed cards read them via `petCardSelect`. The legacy `InviteCode` table is history only — nothing writes to it anymore. One-time setup after the schema push: `npx tsx prisma/scripts/referral-setup.ts` (+ `prisma/enable_rls_referrals.sql`).
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
@@ -104,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.
|
**Runs as 3 load-balanced replicas, not a single container (since 2026-08-16, Gitea #26).** `docker/docker-compose.yml` defines `pawfeed-1`/`pawfeed-2`/`pawfeed-3` (via the `x-pawfeed-common` YAML anchor) instead of one `pawfeed` service — Next.js standalone output is single-process (`node server.js`), so one container only ever used one CPU core regardless of how many the NAS has. A load test on 2026-08-15 hit a hard ceiling at ~60–65 req/sec with one core pegged at 133% while 3 cores sat idle; splitting into 3 replicas fixed it (confirmed via a follow-up load test showing ~100% CPU spread evenly across all three). Nginx Proxy Manager (container `nginx-app-1` on this NAS, admin UI at `192.168.1.222:81`) load-balances across them via a `least_conn` upstream block — that block lives in `/Docker-Data/nginxproxymanager/data/nginx/custom/http_top.conf` on the NAS host (an nginx `upstream {}` is only valid in the `http{}` context, which NPM's per-host "Custom Nginx Configuration" field can't reach — that field only writes into the `server{}`/`location{}` context, and holds the `proxy_pass http://pawfeed_upstream;` override for the `pawfeed.org` Proxy Host instead). Full runbook, decisions, and risk notes: `docker/SCALING-ROADMAP.md`. **Deploying a config or code change means rebuilding all 3 replicas** — there is no separate single-container path anymore, and NPM's proxy target is `pawfeed_upstream`, not a bare `pawfeed:3000` hostname. Use `./docker/rolling-deploy.sh` for deploys against the live site — it rebuilds the shared `pawfeed:latest` image once, then restarts `pawfeed-1`/`2`/`3` one at a time, gated on each becoming `healthy` per the Docker `healthcheck` (`GET /api/health`, a dependency-free liveness check — see `src/app/api/health/route.ts`) before moving to the next, so at least 2 replicas always serve traffic. Verified zero-downtime on 2026-08-16 (continuous curl monitoring showed unbroken HTTP 200 through a full rolling deploy). `docker compose up -d --build` (`start.sh`) still works but restarts all 3 at once — only use it for first-time setup or when a brief outage is acceptable. `src/lib/prisma.ts`'s Prisma pg pool size is capped per-replica via `DB_POOL_MAX` (compose sets `10`) so 3 replicas stay well under the Supabase pooler's client-connection limit.
|
||||||
|
|
||||||
`src/app/api/cron/trim-feeds/route.ts` trims each pet's Redis feed sorted set to the 90-day retention window and expects a `CRON_SECRET` bearer token. `vercel.json`'s `crons` array that points at it is **inert** on this self-hosted deployment — nothing calls this route yet. It needs an external trigger (e.g. a NAS-side cron job hitting the route with the secret header) before it actually runs.
|
**Cron jobs run from the NAS crontab** (user `daniel`, `crontab -l`), (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
|
### 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)
|
### 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
|
```bash
|
||||||
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/anniversaries # daily 08:00
|
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/anniversaries # daily 08:00
|
||||||
|
|||||||
+157
-14
@@ -116,7 +116,8 @@
|
|||||||
"zoom": "Zoom",
|
"zoom": "Zoom",
|
||||||
"cropCancel": "Abbrechen",
|
"cropCancel": "Abbrechen",
|
||||||
"cropSave": "Übernehmen",
|
"cropSave": "Übernehmen",
|
||||||
"cropSaving": "Wird verarbeitet…"
|
"cropSaving": "Wird verarbeitet…",
|
||||||
|
"heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
|
||||||
},
|
},
|
||||||
"Onboarding": {
|
"Onboarding": {
|
||||||
"addFirstPet": "Füge dein erstes Tier hinzu",
|
"addFirstPet": "Füge dein erstes Tier hinzu",
|
||||||
@@ -125,7 +126,11 @@
|
|||||||
"addPhotoDesc": "Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.",
|
"addPhotoDesc": "Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.",
|
||||||
"done": "Fertig",
|
"done": "Fertig",
|
||||||
"skipForNow": "Jetzt überspringen",
|
"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": {
|
"Language": {
|
||||||
"switch": "Sprache",
|
"switch": "Sprache",
|
||||||
@@ -259,7 +264,8 @@
|
|||||||
"postError": "Kommentar konnte nicht gepostet werden. Bitte versuche es erneut.",
|
"postError": "Kommentar konnte nicht gepostet werden. Bitte versuche es erneut.",
|
||||||
"deleteError": "Kommentar konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
"deleteError": "Kommentar konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||||
"countSingular": "1 Kommentar",
|
"countSingular": "1 Kommentar",
|
||||||
"countPlural": "{count} Kommentare"
|
"countPlural": "{count} Kommentare",
|
||||||
|
"readOnlyExpired": "Die Story ist abgelaufen. Kommentare kannst du noch lesen, aber nicht mehr schreiben."
|
||||||
},
|
},
|
||||||
"RelativeTime": {
|
"RelativeTime": {
|
||||||
"justNow": "gerade eben",
|
"justNow": "gerade eben",
|
||||||
@@ -480,7 +486,16 @@
|
|||||||
"typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag.",
|
"typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag.",
|
||||||
"typePetSuggestion": "könnte dir gefallen",
|
"typePetSuggestion": "könnte dir gefallen",
|
||||||
"typeReferralJoined": "ist über deine Einladung zu PawFeed gekommen",
|
"typeReferralJoined": "ist über deine Einladung zu PawFeed gekommen",
|
||||||
"typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!"
|
"typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!",
|
||||||
|
"typeVaccineDue": "💉 Die Impfung „{name}“ ist am {date} fällig – Zeit, einen Termin zu vereinbaren.",
|
||||||
|
"typeStoryMention": "hat dich in einer Story markiert.",
|
||||||
|
"typeStoryComment": "hat deine Story kommentiert.",
|
||||||
|
"typeStoryReaction": "hat deiner Story eine Pfote gegeben.",
|
||||||
|
"typeUnknown": "hat eine neue Mitteilung für dich.",
|
||||||
|
"typePetDeceased": "ist ü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! 🎉",
|
||||||
|
"typeCaretakerRequest": "Jemand möchte dein Tier mitbetreuen – bitte prüfe die Anfrage."
|
||||||
},
|
},
|
||||||
"PhotoPostForm": {
|
"PhotoPostForm": {
|
||||||
"captionPlaceholder": "Bildunterschrift schreiben…",
|
"captionPlaceholder": "Bildunterschrift schreiben…",
|
||||||
@@ -510,7 +525,8 @@
|
|||||||
"videoTooLong": "Story-Videos dürfen höchstens {max} Sekunden lang sein.",
|
"videoTooLong": "Story-Videos dürfen höchstens {max} Sekunden lang sein.",
|
||||||
"videoUnreadable": "Videolänge konnte nicht gelesen werden — bitte versuch eine andere Datei.",
|
"videoUnreadable": "Videolänge konnte nicht gelesen werden — bitte versuch eine andere Datei.",
|
||||||
"musicLabel": "Musik hinzufügen (optional)",
|
"musicLabel": "Musik hinzufügen (optional)",
|
||||||
"muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen"
|
"muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen",
|
||||||
|
"heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
|
||||||
},
|
},
|
||||||
"MilestoneForm": {
|
"MilestoneForm": {
|
||||||
"questionLabel": "Was ist der Meilenstein?",
|
"questionLabel": "Was ist der Meilenstein?",
|
||||||
@@ -530,7 +546,8 @@
|
|||||||
"errorToast": "Meilenstein konnte nicht geteilt werden. Bitte versuch es erneut.",
|
"errorToast": "Meilenstein konnte nicht geteilt werden. Bitte versuch es erneut.",
|
||||||
"fileTooLarge": "Foto muss unter 10 MB sein.",
|
"fileTooLarge": "Foto muss unter 10 MB sein.",
|
||||||
"invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.",
|
"invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.",
|
||||||
"uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut."
|
"uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut.",
|
||||||
|
"heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
|
||||||
},
|
},
|
||||||
"MilestoneTypes": {
|
"MilestoneTypes": {
|
||||||
"BIRTHDAY": "Geburtstag",
|
"BIRTHDAY": "Geburtstag",
|
||||||
@@ -578,12 +595,22 @@
|
|||||||
"mute": "Stummschalten",
|
"mute": "Stummschalten",
|
||||||
"unmute": "Ton einschalten",
|
"unmute": "Ton einschalten",
|
||||||
"videoError": "Video konnte nicht verarbeitet werden.",
|
"videoError": "Video konnte nicht verarbeitet werden.",
|
||||||
"openComments": "Kommentare öffnen"
|
"openComments": "Kommentare öffnen",
|
||||||
|
"deleteStory": "Story löschen",
|
||||||
|
"deleteStoryTitle": "Story löschen?",
|
||||||
|
"deleteStoryDescription": "Die Story wird mit allen Aufrufen, Pfoten und Kommentaren endgültig gelöscht.",
|
||||||
|
"deleteStoryCancel": "Abbrechen",
|
||||||
|
"deleteStoryConfirm": "Löschen",
|
||||||
|
"storyDeleted": "Story gelöscht",
|
||||||
|
"storyDeleteError": "Die Story konnte nicht gelöscht werden. Bitte versuch es noch einmal.",
|
||||||
|
"expiredOwnerOnly": "Abgelaufen – nur für dich sichtbar",
|
||||||
|
"pawCount": "{count, plural, one {# Pfote} other {# Pfoten}}"
|
||||||
},
|
},
|
||||||
"StoryTray": {
|
"StoryTray": {
|
||||||
"storiesAriaLabel": "Storys",
|
"storiesAriaLabel": "Storys",
|
||||||
"addStory": "Story hinzufügen",
|
"addStory": "Story hinzufügen",
|
||||||
"newStoryTitle": "Neue Story"
|
"newStoryTitle": "Neue Story",
|
||||||
|
"yourStory": "Deine Story"
|
||||||
},
|
},
|
||||||
"RepostCard": {
|
"RepostCard": {
|
||||||
"pawedBackBy": "Paw-Back von"
|
"pawedBackBy": "Paw-Back von"
|
||||||
@@ -624,7 +651,8 @@
|
|||||||
"title": "Meine Tiere",
|
"title": "Meine Tiere",
|
||||||
"addPet": "Tier anlegen",
|
"addPet": "Tier anlegen",
|
||||||
"emptyTitle": "Füge dein erstes Tier hinzu",
|
"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": {
|
"PetProfile": {
|
||||||
"editProfile": "Profil bearbeiten",
|
"editProfile": "Profil bearbeiten",
|
||||||
@@ -654,9 +682,9 @@
|
|||||||
"onlyMe": "Nur ich",
|
"onlyMe": "Nur ich",
|
||||||
"followerVisibilitySaved": "Follower-Sichtbarkeit gespeichert",
|
"followerVisibilitySaved": "Follower-Sichtbarkeit gespeichert",
|
||||||
"followerVisibilitySaveFailed": "Follower-Sichtbarkeit konnte nicht gespeichert werden",
|
"followerVisibilitySaveFailed": "Follower-Sichtbarkeit konnte nicht gespeichert werden",
|
||||||
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {name} sehen.",
|
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {name} sehen und {name} in Storys markieren.",
|
||||||
"followerFollowersOnlyHint": "Nur Tiere, die {name} folgen, können die Follower- und Folgt-Listen sehen.",
|
"followerFollowersOnlyHint": "Nur Tiere, die {name} folgen, können die Follower- und Folgt-Listen sehen und {name} in Storys markieren.",
|
||||||
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {name} sehen.",
|
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {name} sehen — markieren in Storys kann {name} niemand.",
|
||||||
"verificationHeading": "Verifizierung",
|
"verificationHeading": "Verifizierung",
|
||||||
"verifiedStatus": "Dieses Tier ist verifiziert.",
|
"verifiedStatus": "Dieses Tier ist verifiziert.",
|
||||||
"pendingStatus": "Dein Verifizierungsantrag wird noch geprüft.",
|
"pendingStatus": "Dein Verifizierungsantrag wird noch geprüft.",
|
||||||
@@ -733,6 +761,7 @@
|
|||||||
"footerAgb": "AGB",
|
"footerAgb": "AGB",
|
||||||
"navHome": "Home",
|
"navHome": "Home",
|
||||||
"navAbout": "Über uns",
|
"navAbout": "Über uns",
|
||||||
|
"footerPress": "Presse",
|
||||||
"navLogin": "Anmelden",
|
"navLogin": "Anmelden",
|
||||||
"navSignUp": "Registrieren",
|
"navSignUp": "Registrieren",
|
||||||
"heroBadge1": "Neuer Beitrag von Bubi",
|
"heroBadge1": "Neuer Beitrag von Bubi",
|
||||||
@@ -747,10 +776,11 @@
|
|||||||
"footerTagline": "Wo Haustiere die Stars sind.",
|
"footerTagline": "Wo Haustiere die Stars sind.",
|
||||||
"footerDesignedBy": "Designed & Powered by PawFeed",
|
"footerDesignedBy": "Designed & Powered by PawFeed",
|
||||||
"footerSecurityBadge": "Sicherheits-Check: Note A+",
|
"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",
|
"playStoreAlt": "Jetzt bei Google Play",
|
||||||
|
"appStoreAlt": "Laden im App Store",
|
||||||
"appHeading": "PawFeed für die Hosentasche",
|
"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": {
|
"AboutPage": {
|
||||||
"metaTitle": "Über uns — PawFeed",
|
"metaTitle": "Über uns — PawFeed",
|
||||||
@@ -836,5 +866,118 @@
|
|||||||
"codeLabel": "Einladungscode",
|
"codeLabel": "Einladungscode",
|
||||||
"codeHint": "Gib den Code nach der Registrierung im Onboarding ein, dann seid ihr direkt verbunden.",
|
"codeHint": "Gib den Code nach der Registrierung im Onboarding ein, dann seid ihr direkt verbunden.",
|
||||||
"webSignUp": "Im Browser registrieren"
|
"webSignUp": "Im Browser registrieren"
|
||||||
|
},
|
||||||
|
"FoundingMember": {
|
||||||
|
"label": "Gründungsmitglied",
|
||||||
|
"tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed"
|
||||||
|
},
|
||||||
|
"InMemory": {
|
||||||
|
"label": "In Erinnerung",
|
||||||
|
"tooltip": "In liebevoller Erinnerung",
|
||||||
|
"banner": "In liebevoller Erinnerung an {name} · {date}",
|
||||||
|
"settingsHeading": "Gedenkmodus",
|
||||||
|
"settingsHint": "Wenn dein Tier über die Regenbogenbrücke gegangen ist, kannst du sein Profil als liebevolle Erinnerung bewahren. Alle Beiträge bleiben erhalten, und du kannst weiterhin Erinnerungen teilen. Geburtstags- und Impf-Erinnerungen pausieren dann, damit dich nichts unvermittelt trifft.",
|
||||||
|
"activeHint": "Das Profil von {name} wird als Erinnerung bewahrt. Du kannst das Datum jederzeit anpassen oder den Gedenkmodus wieder beenden.",
|
||||||
|
"dateLabel": "Tag des Abschieds",
|
||||||
|
"activate": "Profil als Erinnerung bewahren",
|
||||||
|
"saveDate": "Datum speichern",
|
||||||
|
"remove": "Gedenkmodus beenden",
|
||||||
|
"confirmTitle": "Das Profil von {name} als Erinnerung bewahren?",
|
||||||
|
"confirmDescription": "Es tut uns sehr leid. Das Profil von {name} bekommt eine Erinnerungsschleife, und alles, was ihr geteilt habt, bleibt erhalten. Wer {name} folgt, erfährt es einmal ganz in Ruhe über die Mitteilungen – ohne Push. Du kannst das jederzeit rückgängig machen.",
|
||||||
|
"confirm": "Ja, bewahren",
|
||||||
|
"cancel": "Noch nicht",
|
||||||
|
"saved": "Das Profil wird nun als Erinnerung bewahrt.",
|
||||||
|
"removed": "Gedenkmodus beendet.",
|
||||||
|
"saveFailed": "Das hat leider nicht geklappt. Bitte versuch es noch einmal."
|
||||||
|
},
|
||||||
|
"LostPublic": {
|
||||||
|
"badge": "Vermisst",
|
||||||
|
"heading": "Hast du {name} gesehen?",
|
||||||
|
"metaTitle": "Vermisst: {name} – hast du dieses Tier gesehen?",
|
||||||
|
"metaDescription": "Zuletzt gesehen: {place}. Jeder Hinweis hilft – bitte teilen.",
|
||||||
|
"lastSeenPlace": "Zuletzt gesehen",
|
||||||
|
"lastSeenAt": "Wann",
|
||||||
|
"details": "Erkennungsmerkmale & Hinweise",
|
||||||
|
"contactHeading": "Bitte melde dich, wenn du {name} siehst",
|
||||||
|
"messageInPawFeed": "Nachricht über PawFeed 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": "Seite teilen – jede Weitergabe hilft",
|
||||||
|
"copied": "Link kopiert",
|
||||||
|
"foundTitle": "{name} ist wieder sicher zu Hause 🎉",
|
||||||
|
"foundText": "Danke an alle, die geteilt und mitgesucht haben!",
|
||||||
|
"openInPawFeed": "{name} auf PawFeed besuchen",
|
||||||
|
"rateLimited": "Zu viele Anfragen – bitte versuch es in einer Minute erneut."
|
||||||
|
},
|
||||||
|
"LostReport": {
|
||||||
|
"badge": "Wird vermisst",
|
||||||
|
"teaser": "Ist {name} entlaufen? Hier kannst du sofort eine Vermisst-Meldung starten.",
|
||||||
|
"open": "Vermisst melden",
|
||||||
|
"formHeading": "{name} als vermisst melden",
|
||||||
|
"formHint": "Wir drücken dir fest die Daumen. Alle, die {name} folgen oder denen {name} folgt, bekommen sofort eine Push-Mitteilung, und du erhältst eine Seite zum Teilen – auch für Menschen ohne PawFeed.",
|
||||||
|
"activeHeading": "{name} wird vermisst",
|
||||||
|
"activeHint": "Teile den Link, so oft du kannst – zum Beispiel in WhatsApp-Gruppen aus deiner Nachbarschaft. Du kannst die Angaben jederzeit ergänzen.",
|
||||||
|
"lastSeenAt": "Zuletzt gesehen am",
|
||||||
|
"lastSeenPlace": "Wo zuletzt gesehen?",
|
||||||
|
"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 – 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",
|
||||||
|
"allowIndexingHint": "Mehr Reichweite, aber auch Fremde finden die Seite. Ohne Haken ist sie nur über den Link erreichbar.",
|
||||||
|
"submit": "Vermisst-Meldung starten",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"saveChanges": "Änderungen speichern",
|
||||||
|
"notifyFound": "Allen Bescheid geben, dass {name} wieder da ist",
|
||||||
|
"markFound": "{name} ist wieder da!",
|
||||||
|
"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.",
|
||||||
|
"updated": "Angaben gespeichert.",
|
||||||
|
"foundToast": "Wie schön – willkommen zurück, {name}! 🎉",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+157
-14
@@ -116,7 +116,8 @@
|
|||||||
"zoom": "Zoom",
|
"zoom": "Zoom",
|
||||||
"cropCancel": "Cancel",
|
"cropCancel": "Cancel",
|
||||||
"cropSave": "Apply",
|
"cropSave": "Apply",
|
||||||
"cropSaving": "Processing…"
|
"cropSaving": "Processing…",
|
||||||
|
"heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
|
||||||
},
|
},
|
||||||
"Onboarding": {
|
"Onboarding": {
|
||||||
"addFirstPet": "Add your first pet",
|
"addFirstPet": "Add your first pet",
|
||||||
@@ -125,7 +126,11 @@
|
|||||||
"addPhotoDesc": "Give your pet a face on PawFeed. You can always change this later.",
|
"addPhotoDesc": "Give your pet a face on PawFeed. You can always change this later.",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
"skipForNow": "Skip for now",
|
"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": {
|
"Language": {
|
||||||
"switch": "Language",
|
"switch": "Language",
|
||||||
@@ -259,7 +264,8 @@
|
|||||||
"postError": "Couldn't post comment. Please try again.",
|
"postError": "Couldn't post comment. Please try again.",
|
||||||
"deleteError": "Couldn't delete comment. Please try again.",
|
"deleteError": "Couldn't delete comment. Please try again.",
|
||||||
"countSingular": "1 comment",
|
"countSingular": "1 comment",
|
||||||
"countPlural": "{count} comments"
|
"countPlural": "{count} comments",
|
||||||
|
"readOnlyExpired": "This story has expired. You can still read comments but not add new ones."
|
||||||
},
|
},
|
||||||
"RelativeTime": {
|
"RelativeTime": {
|
||||||
"justNow": "now",
|
"justNow": "now",
|
||||||
@@ -480,7 +486,16 @@
|
|||||||
"typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary.",
|
"typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary.",
|
||||||
"typePetSuggestion": "might be a good match",
|
"typePetSuggestion": "might be a good match",
|
||||||
"typeReferralJoined": "joined PawFeed through your invite",
|
"typeReferralJoined": "joined PawFeed through your invite",
|
||||||
"typeReferralRankUp": "You reached a new referral rank. Take a look!"
|
"typeReferralRankUp": "You reached a new referral rank. Take a look!",
|
||||||
|
"typeVaccineDue": "💉 The \"{name}\" vaccination is due on {date} – time to book an appointment.",
|
||||||
|
"typeStoryMention": "tagged you in a story.",
|
||||||
|
"typeStoryComment": "commented on your story.",
|
||||||
|
"typeStoryReaction": "gave your story a paw.",
|
||||||
|
"typeUnknown": "has a new notification for you.",
|
||||||
|
"typePetDeceased": "has 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! 🎉",
|
||||||
|
"typeCaretakerRequest": "Someone would like to co-care for your pet – please review the request."
|
||||||
},
|
},
|
||||||
"PhotoPostForm": {
|
"PhotoPostForm": {
|
||||||
"captionPlaceholder": "Write a caption…",
|
"captionPlaceholder": "Write a caption…",
|
||||||
@@ -510,7 +525,8 @@
|
|||||||
"videoTooLong": "Story videos can be at most {max} seconds long.",
|
"videoTooLong": "Story videos can be at most {max} seconds long.",
|
||||||
"videoUnreadable": "Couldn't read the video length — please try a different file.",
|
"videoUnreadable": "Couldn't read the video length — please try a different file.",
|
||||||
"musicLabel": "Add music (optional)",
|
"musicLabel": "Add music (optional)",
|
||||||
"muteOriginalLabel": "Mute original audio, play this track instead"
|
"muteOriginalLabel": "Mute original audio, play this track instead",
|
||||||
|
"heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
|
||||||
},
|
},
|
||||||
"MilestoneForm": {
|
"MilestoneForm": {
|
||||||
"questionLabel": "What's the milestone?",
|
"questionLabel": "What's the milestone?",
|
||||||
@@ -530,7 +546,8 @@
|
|||||||
"errorToast": "Couldn't share this milestone. Please try again.",
|
"errorToast": "Couldn't share this milestone. Please try again.",
|
||||||
"fileTooLarge": "Photo must be under 10 MB.",
|
"fileTooLarge": "Photo must be under 10 MB.",
|
||||||
"invalidType": "Upload a JPG, PNG, or WebP image.",
|
"invalidType": "Upload a JPG, PNG, or WebP image.",
|
||||||
"uploadFailed": "Upload failed — please try again."
|
"uploadFailed": "Upload failed — please try again.",
|
||||||
|
"heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
|
||||||
},
|
},
|
||||||
"MilestoneTypes": {
|
"MilestoneTypes": {
|
||||||
"BIRTHDAY": "Birthday",
|
"BIRTHDAY": "Birthday",
|
||||||
@@ -578,12 +595,22 @@
|
|||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
"unmute": "Unmute",
|
"unmute": "Unmute",
|
||||||
"videoError": "Video could not be processed.",
|
"videoError": "Video could not be processed.",
|
||||||
"openComments": "Open comments"
|
"openComments": "Open comments",
|
||||||
|
"deleteStory": "Delete story",
|
||||||
|
"deleteStoryTitle": "Delete story?",
|
||||||
|
"deleteStoryDescription": "The story and all its views, paws and comments will be deleted permanently.",
|
||||||
|
"deleteStoryCancel": "Cancel",
|
||||||
|
"deleteStoryConfirm": "Delete",
|
||||||
|
"storyDeleted": "Story deleted",
|
||||||
|
"storyDeleteError": "The story could not be deleted. Please try again.",
|
||||||
|
"expiredOwnerOnly": "Expired – only visible to you",
|
||||||
|
"pawCount": "{count, plural, one {# paw} other {# paws}}"
|
||||||
},
|
},
|
||||||
"StoryTray": {
|
"StoryTray": {
|
||||||
"storiesAriaLabel": "Stories",
|
"storiesAriaLabel": "Stories",
|
||||||
"addStory": "Add story",
|
"addStory": "Add story",
|
||||||
"newStoryTitle": "New Story"
|
"newStoryTitle": "New Story",
|
||||||
|
"yourStory": "Your story"
|
||||||
},
|
},
|
||||||
"RepostCard": {
|
"RepostCard": {
|
||||||
"pawedBackBy": "Pawed-back by"
|
"pawedBackBy": "Pawed-back by"
|
||||||
@@ -624,7 +651,8 @@
|
|||||||
"title": "My Pets",
|
"title": "My Pets",
|
||||||
"addPet": "Add Pet",
|
"addPet": "Add Pet",
|
||||||
"emptyTitle": "Add your first 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": {
|
"PetProfile": {
|
||||||
"editProfile": "Edit profile",
|
"editProfile": "Edit profile",
|
||||||
@@ -654,9 +682,9 @@
|
|||||||
"onlyMe": "Only me",
|
"onlyMe": "Only me",
|
||||||
"followerVisibilitySaved": "Follower privacy setting saved",
|
"followerVisibilitySaved": "Follower privacy setting saved",
|
||||||
"followerVisibilitySaveFailed": "Failed to save follower privacy setting",
|
"followerVisibilitySaveFailed": "Failed to save follower privacy setting",
|
||||||
"followerEveryoneHint": "Anyone can see {name}'s followers and following lists.",
|
"followerEveryoneHint": "Anyone can see {name}'s followers and following lists and tag {name} in stories.",
|
||||||
"followerFollowersOnlyHint": "Only pets that follow {name} can see the followers and following lists.",
|
"followerFollowersOnlyHint": "Only pets that follow {name} can see the followers and following lists and tag {name} in stories.",
|
||||||
"followerNobodyHint": "Only you can see {name}'s followers and following lists.",
|
"followerNobodyHint": "Only you can see {name}'s followers and following lists — nobody can tag {name} in stories.",
|
||||||
"verificationHeading": "Verification",
|
"verificationHeading": "Verification",
|
||||||
"verifiedStatus": "This pet is verified.",
|
"verifiedStatus": "This pet is verified.",
|
||||||
"pendingStatus": "Your verification request is pending review.",
|
"pendingStatus": "Your verification request is pending review.",
|
||||||
@@ -733,6 +761,7 @@
|
|||||||
"footerAgb": "Terms",
|
"footerAgb": "Terms",
|
||||||
"navHome": "Home",
|
"navHome": "Home",
|
||||||
"navAbout": "About",
|
"navAbout": "About",
|
||||||
|
"footerPress": "Press",
|
||||||
"navLogin": "Log in",
|
"navLogin": "Log in",
|
||||||
"navSignUp": "Sign up",
|
"navSignUp": "Sign up",
|
||||||
"heroBadge1": "New post from Bubi",
|
"heroBadge1": "New post from Bubi",
|
||||||
@@ -747,10 +776,11 @@
|
|||||||
"footerTagline": "Where pets are the stars.",
|
"footerTagline": "Where pets are the stars.",
|
||||||
"footerDesignedBy": "Designed & Powered by PawFeed",
|
"footerDesignedBy": "Designed & Powered by PawFeed",
|
||||||
"footerSecurityBadge": "Security check: Grade A+",
|
"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",
|
"playStoreAlt": "Get it on Google Play",
|
||||||
|
"appStoreAlt": "Download on the App Store",
|
||||||
"appHeading": "PawFeed in your pocket",
|
"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": {
|
"AboutPage": {
|
||||||
"metaTitle": "About us — PawFeed",
|
"metaTitle": "About us — PawFeed",
|
||||||
@@ -836,5 +866,118 @@
|
|||||||
"codeLabel": "Invite code",
|
"codeLabel": "Invite code",
|
||||||
"codeHint": "Enter the code during onboarding after signing up and you are connected right away.",
|
"codeHint": "Enter the code during onboarding after signing up and you are connected right away.",
|
||||||
"webSignUp": "Sign up in the browser"
|
"webSignUp": "Sign up in the browser"
|
||||||
|
},
|
||||||
|
"FoundingMember": {
|
||||||
|
"label": "Founding member",
|
||||||
|
"tooltip": "Founding member – one of the first 250 pets on PawFeed"
|
||||||
|
},
|
||||||
|
"InMemory": {
|
||||||
|
"label": "In memory",
|
||||||
|
"tooltip": "In loving memory",
|
||||||
|
"banner": "In loving memory of {name} · {date}",
|
||||||
|
"settingsHeading": "Memorial mode",
|
||||||
|
"settingsHint": "If your pet has crossed the rainbow bridge, you can keep their profile as a loving memorial. All posts stay, and you can keep sharing memories. Birthday and vaccine reminders pause so nothing catches you off guard.",
|
||||||
|
"activeHint": "{name}'s profile is kept as a memorial. You can adjust the date or end memorial mode at any time.",
|
||||||
|
"dateLabel": "Day of farewell",
|
||||||
|
"activate": "Keep profile as a memorial",
|
||||||
|
"saveDate": "Save date",
|
||||||
|
"remove": "End memorial mode",
|
||||||
|
"confirmTitle": "Keep {name}'s profile as a memorial?",
|
||||||
|
"confirmDescription": "We're so sorry. {name}'s profile gets a memorial ribbon, and everything you've shared stays. Those who follow {name} will be told once, quietly in their notifications – no push. You can undo this at any time.",
|
||||||
|
"confirm": "Yes, keep it",
|
||||||
|
"cancel": "Not yet",
|
||||||
|
"saved": "The profile is now kept as a memorial.",
|
||||||
|
"removed": "Memorial mode ended.",
|
||||||
|
"saveFailed": "That didn't work, sorry. Please try again."
|
||||||
|
},
|
||||||
|
"LostPublic": {
|
||||||
|
"badge": "Missing",
|
||||||
|
"heading": "Have you seen {name}?",
|
||||||
|
"metaTitle": "Missing: {name} – have you seen this pet?",
|
||||||
|
"metaDescription": "Last seen: {place}. Every tip helps – please share.",
|
||||||
|
"lastSeenPlace": "Last seen",
|
||||||
|
"lastSeenAt": "When",
|
||||||
|
"details": "Distinguishing features & notes",
|
||||||
|
"contactHeading": "Please get in touch if you see {name}",
|
||||||
|
"messageInPawFeed": "Send a message on PawFeed",
|
||||||
|
"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 this page – every share helps",
|
||||||
|
"copied": "Link copied",
|
||||||
|
"foundTitle": "{name} is safely back home 🎉",
|
||||||
|
"foundText": "Thank you to everyone who shared and helped search!",
|
||||||
|
"openInPawFeed": "Visit {name} on PawFeed",
|
||||||
|
"rateLimited": "Too many requests – please try again in a minute."
|
||||||
|
},
|
||||||
|
"LostReport": {
|
||||||
|
"badge": "Missing",
|
||||||
|
"teaser": "Has {name} run away? Start a missing report right here.",
|
||||||
|
"open": "Report missing",
|
||||||
|
"formHeading": "Report {name} as missing",
|
||||||
|
"formHint": "We're keeping our fingers crossed. Everyone who follows {name} or whom {name} follows gets a push notification right away, and you get a page to share – even with people who aren't on PawFeed.",
|
||||||
|
"activeHeading": "{name} is missing",
|
||||||
|
"activeHint": "Share the link as often as you can – for example in neighbourhood WhatsApp groups. You can add details at any time.",
|
||||||
|
"lastSeenAt": "Last seen on",
|
||||||
|
"lastSeenPlace": "Where last seen?",
|
||||||
|
"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 – 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",
|
||||||
|
"allowIndexingHint": "More reach, but strangers can find it too. Unchecked, the page is only reachable via the link.",
|
||||||
|
"submit": "Start missing report",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"saveChanges": "Save changes",
|
||||||
|
"notifyFound": "Let everyone know {name} is back",
|
||||||
|
"markFound": "{name} is back!",
|
||||||
|
"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.",
|
||||||
|
"updated": "Details saved.",
|
||||||
|
"foundToast": "How wonderful – welcome back, {name}! 🎉",
|
||||||
|
"saveFailed": "That didn't work, sorry. Please try again.",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "org.pawfeed.app",
|
"bundleIdentifier": "org.pawfeed.app",
|
||||||
"buildNumber": "3",
|
"buildNumber": "4",
|
||||||
"usesAppleSignIn": true,
|
"usesAppleSignIn": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"ITSAppUsesNonExemptEncryption": false
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
|
|||||||
@@ -152,13 +152,28 @@ function SettingsHeaderButton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// found.md: a push for a new message arrived but the speech bubble showed
|
||||||
|
// nothing. Polling keeps the badge current while the app stays open; reading
|
||||||
|
// a conversation invalidates conversations.* (see messages/[conversationId]).
|
||||||
|
const UNREAD_MESSAGES_POLL_MS = 30 * 1000;
|
||||||
|
|
||||||
function MessagesHeaderButton() {
|
function MessagesHeaderButton() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const { activePetId } = useActivePet();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
|
const { data } = useQuery({
|
||||||
|
...trpc.conversations.unreadCount.queryOptions({ petId: activePetId ?? "" }),
|
||||||
|
enabled: !!activePetId,
|
||||||
|
refetchInterval: UNREAD_MESSAGES_POLL_MS,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={() => router.push("/messages")} hitSlop={8} style={styles.headerButton}>
|
<Pressable onPress={() => router.push("/messages")} hitSlop={8} style={styles.headerButton}>
|
||||||
<MessageCircle color={colors.ink} size={22} />
|
<View>
|
||||||
|
<MessageCircle color={colors.ink} size={22} />
|
||||||
|
<UnreadBadge count={data?.count ?? 0} styles={styles} />
|
||||||
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -172,16 +187,20 @@ function NotificationsTabIcon({ color, size }: { color: ColorValue; size: number
|
|||||||
...trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" }),
|
...trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" }),
|
||||||
enabled: !!activePetId,
|
enabled: !!activePetId,
|
||||||
});
|
});
|
||||||
const count = data?.count ?? 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Bell color={color} size={size} />
|
<Bell color={color} size={size} />
|
||||||
{count > 0 && (
|
<UnreadBadge count={data?.count ?? 0} styles={styles} />
|
||||||
<View style={styles.badge}>
|
</View>
|
||||||
<Text style={styles.badgeText}>{count > 9 ? "9+" : count}</Text>
|
);
|
||||||
</View>
|
}
|
||||||
)}
|
|
||||||
|
function UnreadBadge({ count, styles }: { count: number; styles: ReturnType<typeof makeStyles> }) {
|
||||||
|
if (count <= 0) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>{count > 9 ? "9+" : count}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ function ExplorePetCard({ pet }: { pet: ExplorePet }) {
|
|||||||
return (
|
return (
|
||||||
<Pressable style={styles.cardPress} onPress={() => router.push(`/pets/${pet.id}`)}>
|
<Pressable style={styles.cardPress} onPress={() => router.push(`/pets/${pet.id}`)}>
|
||||||
<Card style={styles.card}>
|
<Card style={styles.card}>
|
||||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" owner={pet.owner} />
|
||||||
<Text style={styles.name} numberOfLines={1}>
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
{formatPetName(pet)}
|
{formatPetName(pet)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -184,13 +184,13 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite },
|
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite },
|
||||||
errorText: { ...typography.body, color: colors.alertRed },
|
errorText: { ...typography.body, color: colors.alertRed },
|
||||||
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||||
followers: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
followers: { ...typography.caption },
|
||||||
footer: { marginVertical: 16 },
|
footer: { marginVertical: 16 },
|
||||||
filters: { gap: 10, marginBottom: 12 },
|
filters: { gap: 10, marginBottom: 12 },
|
||||||
speciesTabs: { gap: 8 },
|
speciesTabs: { gap: 8 },
|
||||||
tab: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray },
|
tab: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray },
|
||||||
tabActive: { backgroundColor: colors.marigoldOrange },
|
tabActive: { backgroundColor: colors.marigoldOrange },
|
||||||
tabText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
|
tabText: { ...typography.caption, color: colors.ink },
|
||||||
tabTextActive: { color: colors.pureWhite },
|
tabTextActive: { color: colors.pureWhite },
|
||||||
breedFilter: { width: 220 },
|
breedFilter: { width: 220 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useActivePet } from "../../../src/context/ActivePetContext";
|
|||||||
import { useTheme } from "../../../src/context/ThemeContext";
|
import { useTheme } from "../../../src/context/ThemeContext";
|
||||||
import { PetAvatar } from "../../../src/components/PetAvatar";
|
import { PetAvatar } from "../../../src/components/PetAvatar";
|
||||||
import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
|
import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
|
||||||
|
import { isoToDisplayDate } from "../../../src/lib/date-format";
|
||||||
|
import { notificationRoute } from "../../../src/lib/notification-route";
|
||||||
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
|
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
|
||||||
import type { NotificationItem } from "../../../src/api/types";
|
import type { NotificationItem } from "../../../src/api/types";
|
||||||
|
|
||||||
@@ -21,6 +23,12 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
|
|||||||
PAW_BACK: "notifications.pawBack",
|
PAW_BACK: "notifications.pawBack",
|
||||||
PET_SUGGESTION: "notifications.petSuggestion",
|
PET_SUGGESTION: "notifications.petSuggestion",
|
||||||
REFERRAL_JOINED: "notifications.referralJoined",
|
REFERRAL_JOINED: "notifications.referralJoined",
|
||||||
|
STORY_MENTION: "notifications.storyMention",
|
||||||
|
STORY_COMMENT: "notifications.storyComment",
|
||||||
|
STORY_REACTION: "notifications.storyReaction",
|
||||||
|
PET_DECEASED: "notifications.petDeceased",
|
||||||
|
PET_LOST: "notifications.petLost",
|
||||||
|
PET_FOUND: "notifications.petFound",
|
||||||
};
|
};
|
||||||
|
|
||||||
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
|
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
|
||||||
@@ -30,6 +38,7 @@ const SELF_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
|
|||||||
BIRTHDAY: "notifications.birthday",
|
BIRTHDAY: "notifications.birthday",
|
||||||
ADOPTION_DAY: "notifications.adoptionDay",
|
ADOPTION_DAY: "notifications.adoptionDay",
|
||||||
REFERRAL_RANK_UP: "notifications.referralRankUp",
|
REFERRAL_RANK_UP: "notifications.referralRankUp",
|
||||||
|
CARETAKER_REQUEST: "notifications.caretakerRequest",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Suffix only — the actor's name is rendered separately as its own tappable
|
// Suffix only — the actor's name is rendered separately as its own tappable
|
||||||
@@ -37,44 +46,51 @@ const SELF_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
|
|||||||
// independently of the row's own tap target, ported from web's
|
// independently of the row's own tap target, ported from web's
|
||||||
// notifications/page.tsx (name as a <Link>, rest as plain text).
|
// notifications/page.tsx (name as a <Link>, rest as plain text).
|
||||||
function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
|
function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
|
||||||
|
if (item.type === "VACCINE_DUE") {
|
||||||
|
if (!item.vaccine?.nextDueDate) return t("notifications.vaccineDueGeneric");
|
||||||
|
const iso = new Date(item.vaccine.nextDueDate).toISOString().slice(0, 10);
|
||||||
|
return t("notifications.vaccineDue", { name: item.vaccine.name, date: isoToDisplayDate(iso) });
|
||||||
|
}
|
||||||
const selfKey = SELF_MESSAGE_KEYS[item.type];
|
const selfKey = SELF_MESSAGE_KEYS[item.type];
|
||||||
if (selfKey) return t(selfKey);
|
if (selfKey) return t(selfKey);
|
||||||
const actorKey = ACTOR_MESSAGE_KEYS[item.type];
|
const actorKey = ACTOR_MESSAGE_KEYS[item.type];
|
||||||
if (actorKey && item.actorPet) return t(actorKey);
|
if (actorKey && item.actorPet) return t(actorKey);
|
||||||
return item.type;
|
// Nie den Code-Namen zeigen (z. B. neuer Typ vom Server, den diese App-Version noch nicht kennt).
|
||||||
|
return t("notifications.unknown");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SELF_ICONS: Partial<Record<NotificationItem["type"], string>> = {
|
||||||
|
BIRTHDAY: "🎂",
|
||||||
|
VACCINE_DUE: "💉",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port of web's notifications/page.tsx getHref()/getPostId(): FOLLOW goes to
|
* Ziel einer Mitteilung — gemeinsame Regel mit dem Antippen von Push-Mitteilungen
|
||||||
* the actor's profile, BIRTHDAY/ADOPTION_DAY to this pet's own health page
|
* (src/lib/notification-route.ts): Nachricht → Gespräch, Kommentar/Pfote/Erwähnung
|
||||||
* (the anniversary is about *this* pet, there's no other actor to visit),
|
* → Beitrag, Story-Mitteilungen → die Story, Follow/Vorschlag → Profil,
|
||||||
* and REACTION/COMMENT/MENTION/PAW_BACK go to the post. Web opens the post
|
* Geburtstag/Impfung → Gesundheit. Nur in Event-Handlern aufrufen (Date.now()).
|
||||||
* in an inline dialog; mobile has no such dialog, so this goes to the
|
|
||||||
* comments screen instead — the closest existing route to "the post".
|
|
||||||
* MESSAGE has no target here either, matching web (it doesn't handle it
|
|
||||||
* there so this doesn't try to be smarter about it).
|
|
||||||
*/
|
*/
|
||||||
function getNotificationRoute(item: NotificationItem): string | null {
|
function getNotificationRoute(item: NotificationItem): string | null {
|
||||||
switch (item.type) {
|
return notificationRoute(
|
||||||
case "FOLLOW":
|
{
|
||||||
case "PET_SUGGESTION":
|
type: item.type,
|
||||||
case "REFERRAL_JOINED":
|
recipientPetId: item.recipientPetId,
|
||||||
return item.actorPet ? `/pets/${item.actorPet.id}` : null;
|
actorPetId: item.actorPet?.id ?? null,
|
||||||
case "REFERRAL_RANK_UP":
|
postId: item.post?.id ?? null,
|
||||||
return "/invite";
|
conversationId: item.conversationId,
|
||||||
case "BIRTHDAY":
|
storyId: item.story?.id ?? null,
|
||||||
case "ADOPTION_DAY":
|
storyPetId: item.story?.petId ?? null,
|
||||||
return item.recipientPetId ? `/pets/${item.recipientPetId}/health` : null;
|
storyPetName: item.story?.pet.name ?? null,
|
||||||
case "REACTION":
|
storyPetAvatarKey: item.story?.pet.avatarKey ?? null,
|
||||||
case "COMMENT":
|
storyExpiresAt: item.story?.expiresAt ?? null,
|
||||||
case "MENTION":
|
},
|
||||||
case "PAW_BACK":
|
Date.now()
|
||||||
return item.post?.id ? `/posts/${item.post.id}/comments` : null;
|
);
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bei diesen Typen führt auch der Name direkt zum Ziel (nicht zum Profil-Umweg). */
|
||||||
|
const NAME_OPENS_TARGET = new Set(["MESSAGE", "STORY_MENTION", "STORY_COMMENT", "STORY_REACTION"]);
|
||||||
|
|
||||||
/** Port of web's notifications list — tapping a row marks it read and navigates to its target (post/pet). */
|
/** Port of web's notifications list — tapping a row marks it read and navigates to its target (post/pet). */
|
||||||
export default function NotificationsScreen() {
|
export default function NotificationsScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -157,16 +173,28 @@ export default function NotificationsScreen() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.actorPet ? (
|
{item.actorPet ? (
|
||||||
<PetAvatar name={item.actorPet.name} avatarKey={item.actorPet.avatarKey} />
|
<PetAvatar name={item.actorPet.name} avatarKey={item.actorPet.avatarKey} owner={item.actorPet.owner} />
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.selfIcon}>
|
<View style={styles.selfIcon}>
|
||||||
<Text style={styles.selfIconText}>{item.type === "BIRTHDAY" ? "🎂" : "🏠"}</Text>
|
<Text style={styles.selfIconText}>{SELF_ICONS[item.type] ?? "🏠"}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View style={styles.rowText}>
|
<View style={styles.rowText}>
|
||||||
<Text style={styles.bodyText}>
|
<Text style={styles.bodyText}>
|
||||||
{item.actorPet && (
|
{item.actorPet && (
|
||||||
<Text style={styles.actorName} onPress={() => router.push(`/pets/${item.actorPet!.id}`)}>
|
<Text
|
||||||
|
style={styles.actorName}
|
||||||
|
onPress={() => {
|
||||||
|
// Bei Nachrichten und Storys führt auch der Name direkt zum Ziel.
|
||||||
|
const targetRoute = NAME_OPENS_TARGET.has(item.type) ? getNotificationRoute(item) : null;
|
||||||
|
if (targetRoute) {
|
||||||
|
if (!item.read) markRead.mutate({ notificationId: item.id });
|
||||||
|
router.push(targetRoute);
|
||||||
|
} else {
|
||||||
|
router.push(`/pets/${item.actorPet!.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formatPetName(item.actorPet)}{" "}
|
{formatPetName(item.actorPet)}{" "}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -201,7 +229,7 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
row: { flexDirection: "row", gap: 12, padding: 12, borderRadius: 12 },
|
row: { flexDirection: "row", gap: 12, padding: 12, borderRadius: 12 },
|
||||||
rowUnread: { backgroundColor: colors.marigoldTint },
|
rowUnread: { backgroundColor: colors.marigoldTint },
|
||||||
rowText: { flex: 1, gap: 2 },
|
rowText: { flex: 1, gap: 2 },
|
||||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
timestamp: { ...typography.caption },
|
||||||
footer: { marginVertical: 16 },
|
footer: { marginVertical: 16 },
|
||||||
selfIcon: {
|
selfIcon: {
|
||||||
width: 40,
|
width: 40,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Card } from "../../../src/components/Card";
|
|||||||
import { formatPetName } from "../../../src/lib/format";
|
import { formatPetName } from "../../../src/lib/format";
|
||||||
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
|
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
|
||||||
import type { Pet } from "../../../src/api/types";
|
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
|
* 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>
|
<Text style={getTypography(colors).body}>{t("pets.empty")}</Text>
|
||||||
</View>
|
</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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,11 +90,11 @@ function PetRow({
|
|||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress}>
|
<Pressable onPress={onPress}>
|
||||||
<Card style={styles.row}>
|
<Card style={styles.row}>
|
||||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" owner={pet.owner} />
|
||||||
<View style={styles.rowText}>
|
<View style={styles.rowText}>
|
||||||
<View style={styles.nameRow}>
|
<View style={styles.nameRow}>
|
||||||
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
||||||
<PetIdentityBadges isVerified={pet.isVerified} />
|
<PetIdentityBadges isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.breed}>{pet.breed?.name ?? pet.species.name}</Text>
|
<Text style={styles.breed}>{pet.breed?.name ?? pet.species.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -112,6 +122,8 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
|
list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
|
||||||
separator: { height: 12 },
|
separator: { height: 12 },
|
||||||
|
caretaker: { marginTop: 32, gap: 8 },
|
||||||
|
caretakerHeading: { ...typography.title, fontSize: 16 },
|
||||||
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite },
|
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite },
|
||||||
row: { flexDirection: "row", alignItems: "center", gap: 12 },
|
row: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||||
rowText: { flex: 1, gap: 2 },
|
rowText: { flex: 1, gap: 2 },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { X } from "lucide-react-native";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "../../src/context/ThemeContext";
|
import { useTheme } from "../../src/context/ThemeContext";
|
||||||
import { fontFamily } from "../../src/theme/tokens";
|
import { fontFamily } from "../../src/theme/tokens";
|
||||||
|
import { useNotificationTapRouting } from "../../src/hooks/useNotificationTapRouting";
|
||||||
|
|
||||||
function CloseModalButton() {
|
function CloseModalButton() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -17,6 +18,8 @@ function CloseModalButton() {
|
|||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Push-Mitteilung angetippt → direkt zum Ziel (Gespräch, Beitrag, Story …)
|
||||||
|
useNotificationTapRouting();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
// Plain prop objects, not StyleSheet.create — safe to recompute on every
|
// Plain prop objects, not StyleSheet.create — safe to recompute on every
|
||||||
// render, unlike the module-level StyleSheet.create calls most other
|
// render, unlike the module-level StyleSheet.create calls most other
|
||||||
@@ -46,6 +49,7 @@ export default function AppLayout() {
|
|||||||
<Stack.Screen name="pets/[petId]/following" options={{ ...headerOptions, title: t("screens.following") }} />
|
<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]/health/index" options={{ ...headerOptions, title: t("screens.health") }} />
|
||||||
<Stack.Screen name="pets/[petId]/milestones" options={{ ...headerOptions, title: t("screens.milestones") }} />
|
<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="posts/[postId]/comments" options={{ ...headerOptions, title: t("screens.comments") }} />
|
||||||
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: t("screens.conversation") }} />
|
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: t("screens.conversation") }} />
|
||||||
<Stack.Screen name="search" options={{ ...headerOptions, title: t("screens.search") }} />
|
<Stack.Screen name="search" options={{ ...headerOptions, title: t("screens.search") }} />
|
||||||
@@ -70,6 +74,11 @@ export default function AppLayout() {
|
|||||||
headerLeft: () => <CloseModalButton />,
|
headerLeft: () => <CloseModalButton />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* Story-Editor: eigener Vollbild-Screen, ohne Wisch-zum-Schließen (Ziehgesten der Overlays). */}
|
||||||
|
<Stack.Screen
|
||||||
|
name="story-editor"
|
||||||
|
options={{ headerShown: false, presentation: "fullScreenModal", animation: "fade", gestureEnabled: false }}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="milestone-new"
|
name="milestone-new"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
+91
-65
@@ -1,30 +1,33 @@
|
|||||||
import { useMemo } from "react";
|
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 { useRouter } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { useTRPC } from "../../src/api/trpc";
|
||||||
import { useTheme } from "../../src/context/ThemeContext";
|
import { useTheme } from "../../src/context/ThemeContext";
|
||||||
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
|
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
|
||||||
import { useReferralRanks } from "../../src/hooks/useReferralRanks";
|
import { useReferralRanks } from "../../src/hooks/useReferralRanks";
|
||||||
import { env } from "../../src/lib/env";
|
|
||||||
import { PetAvatar } from "../../src/components/PetAvatar";
|
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 { RankLadder } from "../../src/components/referrals/RankLadder";
|
||||||
import { RedeemCodeForm } from "../../src/components/referrals/RedeemCodeForm";
|
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",
|
counted: "referrals.statusCounted",
|
||||||
waiting: "referrals.statusWaiting",
|
waiting: "referrals.statusWaiting",
|
||||||
"in-review": "referrals.statusInReview",
|
"in-review": "referrals.statusInReview",
|
||||||
revoked: "referrals.statusRevoked",
|
revoked: "referrals.statusRevoked",
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port of web's /einladen — eigener Link mit nativem Teilen-Dialog (React
|
* Port of web's /einladen in the same look as the settings card: InviteCard
|
||||||
* Natives eingebautes Share, kein zusätzliches natives Modul → per OTA
|
* on top (rank, code, progress, native share — no extra native module, OTA-
|
||||||
* auslieferbar), Rangfortschritt, eingeladene Tiere, nachträgliche Code-Eingabe.
|
* safe), rank timeline, invited pets with status pills, late code entry.
|
||||||
*/
|
*/
|
||||||
export default function InviteScreen() {
|
export default function InviteScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -44,60 +47,81 @@ export default function InviteScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = `${env.apiUrl}/r/${data.code}`;
|
const statusColor: Record<InviteeStatus, string> = {
|
||||||
const achieved = new Set(data.achievedRankIds);
|
counted: "#059669",
|
||||||
|
waiting: colors.marigoldOrange,
|
||||||
function handleShare() {
|
"in-review": colors.quietGray,
|
||||||
void Share.share({ message: `${t("referrals.shareText", { code: data!.code })} ${link}` });
|
revoked: colors.alertRed,
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView contentContainerStyle={[styles.content, bottomPadding]}>
|
<ScrollView
|
||||||
<Text style={styles.subtitle}>{t("referrals.subtitle")}</Text>
|
contentContainerStyle={[styles.content, bottomPadding]}
|
||||||
|
automaticallyAdjustKeyboardInsets
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Text style={styles.intro}>{t("referrals.subtitle")}</Text>
|
||||||
|
|
||||||
{!data.programActive && <Text style={styles.notice}>{t("referrals.programPaused")}</Text>}
|
{!data.programActive && <Text style={styles.notice}>{t("referrals.programPaused")}</Text>}
|
||||||
|
|
||||||
<View style={styles.card}>
|
<InviteCard />
|
||||||
<Text style={styles.heading}>{t("referrals.shareHeading")}</Text>
|
|
||||||
<Text style={styles.muted}>{t("referrals.shareSubtitle")}</Text>
|
<Reveal index={2}>
|
||||||
<View style={styles.codeBox}>
|
<RankLadder
|
||||||
<Text style={styles.codeLabel}>{t("referrals.yourCode")}</Text>
|
ranks={ranks}
|
||||||
<Text style={styles.code} selectable>{data.code}</Text>
|
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>
|
</View>
|
||||||
<Pressable style={styles.shareButton} onPress={handleShare} accessibilityRole="button">
|
</Reveal>
|
||||||
<Share2 size={18} color="#ffffff" />
|
|
||||||
<Text style={styles.shareText}>{t("referrals.share")}</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<RankLadder ranks={ranks} achievedRankIds={achieved} qualifiedCount={data.qualifiedCount} nextRankId={data.nextRankId} />
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.heading}>{t("referrals.inviteesHeading")}</Text>
|
|
||||||
{data.invitees.length === 0 ? (
|
|
||||||
<Text style={styles.muted}>{t("referrals.inviteesEmpty")}</Text>
|
|
||||||
) : (
|
|
||||||
data.invitees.map((invitee, index) => (
|
|
||||||
<Pressable
|
|
||||||
key={invitee.pet?.id ?? `invitee-${index}`}
|
|
||||||
style={styles.inviteeRow}
|
|
||||||
disabled={!invitee.pet}
|
|
||||||
onPress={() => invitee.pet && router.push(`/pets/${invitee.pet.id}`)}
|
|
||||||
>
|
|
||||||
{invitee.pet && <PetAvatar name={invitee.pet.name} avatarKey={invitee.pet.avatarKey} size="sm" />}
|
|
||||||
<Text style={styles.inviteeName} numberOfLines={1}>{invitee.pet?.name ?? t("referrals.inviteeNoPet")}</Text>
|
|
||||||
<Text style={styles.muted}>{t(STATUS_KEYS[invitee.status])}</Text>
|
|
||||||
</Pressable>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{data.canRedeem && (
|
{data.canRedeem && (
|
||||||
<View style={styles.card}>
|
<Reveal index={4}>
|
||||||
<Text style={styles.heading}>{t("referrals.redeemHeading")}</Text>
|
<View style={styles.card}>
|
||||||
<Text style={styles.muted}>{t("referrals.redeemSubtitle")}</Text>
|
<Text style={styles.heading}>{t("referrals.redeemHeading")}</Text>
|
||||||
<RedeemCodeForm />
|
<Text style={styles.muted}>{t("referrals.redeemSubtitle")}</Text>
|
||||||
</View>
|
<RedeemCodeForm />
|
||||||
|
</View>
|
||||||
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text style={styles.footnote}>{t("referrals.rulesNote")}</Text>
|
<Text style={styles.footnote}>{t("referrals.rulesNote")}</Text>
|
||||||
@@ -110,18 +134,20 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite },
|
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite },
|
||||||
content: { padding: 16, gap: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
|
content: { padding: 16, gap: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
|
||||||
subtitle: { ...typography.body, color: colors.quietGray },
|
intro: { ...typography.body, fontSize: 15, lineHeight: 22, color: colors.quietGray },
|
||||||
notice: { ...typography.body, fontSize: 13, padding: 12, borderRadius: radii.xl, backgroundColor: colors.marigoldTint, color: colors.ink },
|
notice: { ...typography.body, fontSize: 13, padding: 12, borderRadius: 14, backgroundColor: colors.marigoldTint, color: colors.ink },
|
||||||
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 },
|
||||||
heading: { ...typography.title, fontSize: 16 },
|
heading: { ...typography.title, fontSize: 16 },
|
||||||
muted: { ...typography.body, fontSize: 13, color: colors.quietGray },
|
muted: { ...typography.body, fontSize: 13, color: colors.quietGray },
|
||||||
codeBox: { alignItems: "center", paddingVertical: 12, borderRadius: radii.xl, backgroundColor: colors.whisperGray },
|
empty: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||||
codeLabel: { ...typography.body, fontSize: 11, letterSpacing: 1, textTransform: "uppercase", color: colors.quietGray },
|
emptyIcon: { width: 44, height: 44, borderRadius: 13, backgroundColor: colors.marigoldTint, alignItems: "center", justifyContent: "center" },
|
||||||
code: { ...typography.title, fontSize: 24, letterSpacing: 2 },
|
emptyText: { ...typography.body, flex: 1, fontSize: 13.5, lineHeight: 19, color: colors.quietGray },
|
||||||
shareButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, height: 48, borderRadius: radii.lg, backgroundColor: colors.marigoldOrange },
|
|
||||||
shareText: { ...typography.body, fontWeight: "600", color: "#ffffff" },
|
|
||||||
inviteeRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
|
inviteeRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
|
||||||
inviteeName: { ...typography.body, flex: 1, fontWeight: "600" },
|
pressed: { opacity: 0.7 },
|
||||||
footnote: { ...typography.body, fontSize: 12, color: colors.quietGray },
|
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 },
|
bubbleOwn: { backgroundColor: colors.marigoldOrange, borderBottomRightRadius: 4 },
|
||||||
bubbleText: { ...typography.body },
|
bubbleText: { ...typography.body },
|
||||||
bubbleTextOwn: { color: colors.pureWhite },
|
bubbleTextOwn: { color: colors.pureWhite },
|
||||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 10 },
|
timestamp: { ...typography.caption, fontSize: 10 },
|
||||||
inputRow: {
|
inputRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function ConversationRow({ conversation, onPress }: { conversation: Conversation
|
|||||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
return (
|
return (
|
||||||
<Pressable style={styles.row} onPress={onPress}>
|
<Pressable style={styles.row} onPress={onPress}>
|
||||||
<PetAvatar name={conversation.otherPet.name} avatarKey={conversation.otherPet.avatarKey} />
|
<PetAvatar name={conversation.otherPet.name} avatarKey={conversation.otherPet.avatarKey} owner={conversation.otherPet.owner} />
|
||||||
<View style={styles.rowText}>
|
<View style={styles.rowText}>
|
||||||
<View style={styles.nameRow}>
|
<View style={styles.nameRow}>
|
||||||
<Text style={styles.name}>{formatPetName(conversation.otherPet)}</Text>
|
<Text style={styles.name}>{formatPetName(conversation.otherPet)}</Text>
|
||||||
@@ -100,7 +100,7 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
nameRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
nameRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||||
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||||
preview: { ...typography.body, color: colors.quietGray },
|
preview: { ...typography.body, color: colors.quietGray },
|
||||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
timestamp: { ...typography.caption },
|
||||||
unreadDot: {
|
unreadDot: {
|
||||||
backgroundColor: colors.marigoldOrange,
|
backgroundColor: colors.marigoldOrange,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
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 { useRouter } from "expo-router";
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -8,8 +8,7 @@ import { ImagePlus } from "lucide-react-native";
|
|||||||
import { useTRPC } from "../../src/api/trpc";
|
import { useTRPC } from "../../src/api/trpc";
|
||||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||||
import { useTheme } from "../../src/context/ThemeContext";
|
import { useTheme } from "../../src/context/ThemeContext";
|
||||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
import { uploadPickedImage } from "../../src/lib/upload";
|
||||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
|
||||||
import { MILESTONE_META, USER_INITIATED_MILESTONE_TYPES, type CreatableMilestoneType } from "../../src/lib/milestone-meta";
|
import { MILESTONE_META, USER_INITIATED_MILESTONE_TYPES, type CreatableMilestoneType } from "../../src/lib/milestone-meta";
|
||||||
import { TextField } from "../../src/components/TextField";
|
import { TextField } from "../../src/components/TextField";
|
||||||
import { Button } from "../../src/components/Button";
|
import { Button } from "../../src/components/Button";
|
||||||
@@ -49,11 +48,8 @@ export default function NewMilestoneScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pickImage = useCallback(async () => {
|
const pickImage = useCallback(async () => {
|
||||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
// Kein Berechtigungsdialog: der System-Bildwähler (iOS PHPicker, Android Photo Picker)
|
||||||
if (!permission.granted) {
|
// braucht keine Freigabe der Fotomediathek (Apple 5.1.1(iv), 07.10.).
|
||||||
Alert.alert(t("onboarding.galleryPermissionDenied"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 });
|
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 });
|
||||||
if (!result.canceled) setAsset(result.assets[0]);
|
if (!result.canceled) setAsset(result.assets[0]);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -65,13 +61,7 @@ export default function NewMilestoneScreen() {
|
|||||||
try {
|
try {
|
||||||
let imageKey: string | undefined;
|
let imageKey: string | undefined;
|
||||||
if (asset) {
|
if (asset) {
|
||||||
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
const { key } = await uploadPickedImage(asset, (file) => getPresignedUrlMutation.mutateAsync({ petId: activePetId, ...file }));
|
||||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
|
||||||
petId: activePetId,
|
|
||||||
contentType: resized.mimeType,
|
|
||||||
fileSize: resized.fileSize,
|
|
||||||
});
|
|
||||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
|
||||||
imageKey = key;
|
imageKey = key;
|
||||||
}
|
}
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
@@ -88,7 +78,11 @@ export default function NewMilestoneScreen() {
|
|||||||
const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending;
|
const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView contentContainerStyle={styles.container}>
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
automaticallyAdjustKeyboardInsets
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
<Text style={styles.label}>{t("milestones.typeLabel")}</Text>
|
<Text style={styles.label}>{t("milestones.typeLabel")}</Text>
|
||||||
<View style={styles.chipRow}>
|
<View style={styles.chipRow}>
|
||||||
{USER_INITIATED_MILESTONE_TYPES.map((type) => {
|
{USER_INITIATED_MILESTONE_TYPES.map((type) => {
|
||||||
@@ -141,7 +135,7 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
const typography = getTypography(colors);
|
const typography = getTypography(colors);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: { padding: 16, gap: 14, backgroundColor: colors.porchWhite, flexGrow: 1 },
|
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 },
|
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||||
chip: {
|
chip: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -167,7 +161,7 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
},
|
},
|
||||||
pickerText: { ...typography.body, color: colors.ink },
|
pickerText: { ...typography.body, color: colors.ink },
|
||||||
preview: { width: "100%", height: 220, borderRadius: radii.md, backgroundColor: colors.hairlineGray },
|
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 },
|
notes: { height: 88, textAlignVertical: "top", paddingTop: 12 },
|
||||||
error: { ...typography.body, color: colors.alertRed },
|
error: { ...typography.body, color: colors.alertRed },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ import { useTRPC } from "../../../../src/api/trpc";
|
|||||||
import { useActivePet } from "../../../../src/context/ActivePetContext";
|
import { useActivePet } from "../../../../src/context/ActivePetContext";
|
||||||
import { useTheme } from "../../../../src/context/ThemeContext";
|
import { useTheme } from "../../../../src/context/ThemeContext";
|
||||||
import { TextField } from "../../../../src/components/TextField";
|
import { TextField } from "../../../../src/components/TextField";
|
||||||
|
import { DateField } from "../../../../src/components/DateField";
|
||||||
import { Button } from "../../../../src/components/Button";
|
import { Button } from "../../../../src/components/Button";
|
||||||
import { BreedPicker } from "../../../../src/components/pet/BreedPicker";
|
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 { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
|
||||||
import { displayDateToIso, isoToDisplayDate } from "../../../../src/lib/date-format";
|
import { displayDateToIso, isoToDisplayDate } from "../../../../src/lib/date-format";
|
||||||
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
||||||
@@ -17,7 +22,8 @@ import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding
|
|||||||
/**
|
/**
|
||||||
* Edit form (name, nickname, breed, bio, adoption story, birthday, adoption
|
* Edit form (name, nickname, breed, bio, adoption story, birthday, adoption
|
||||||
* date) — a subset of web's src/app/(app)/pets/[petId]/edit/page.tsx.
|
* date) — a subset of web's src/app/(app)/pets/[petId]/edit/page.tsx.
|
||||||
* DM-policy/follower-visibility/avatar upload aren't editable here yet.
|
* Privacy (DM policy, follower visibility) lives in PrivacySettings and saves
|
||||||
|
* on its own; avatar upload isn't editable here yet.
|
||||||
* Species itself stays fixed (only its breed is re-selectable) — found.md
|
* Species itself stays fixed (only its breed is re-selectable) — found.md
|
||||||
* asked specifically for breed correction, not a full species change.
|
* asked specifically for breed correction, not a full species change.
|
||||||
*/
|
*/
|
||||||
@@ -116,6 +122,9 @@ export default function EditPetScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ältere Server ohne Mitbetreuer liefern keine Rolle → wie bisher Haupt-Besitzer.
|
||||||
|
const isMainOwner = pet.role !== "CARETAKER";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// FlatList, not ScrollView — kept from the original nested-ScrollView
|
// FlatList, not ScrollView — kept from the original nested-ScrollView
|
||||||
// fix (see onboarding/pet.tsx's comment); OptionPicker's dropdown now
|
// fix (see onboarding/pet.tsx's comment); OptionPicker's dropdown now
|
||||||
@@ -125,8 +134,13 @@ export default function EditPetScreen() {
|
|||||||
data={EMPTY_DATA}
|
data={EMPTY_DATA}
|
||||||
renderItem={null}
|
renderItem={null}
|
||||||
contentContainerStyle={[styles.container, bottomPadding]}
|
contentContainerStyle={[styles.container, bottomPadding]}
|
||||||
|
automaticallyAdjustKeyboardInsets
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<>
|
<>
|
||||||
|
{/* Vermisst-Meldung ganz oben: im Ernstfall sofort auffindbar */}
|
||||||
|
<LostReportSettings petId={pet.id} petName={pet.name} isDeceased={pet.deceasedAt != null} />
|
||||||
|
|
||||||
<Text style={styles.label}>{t("editPet.nameLabel")}</Text>
|
<Text style={styles.label}>{t("editPet.nameLabel")}</Text>
|
||||||
<TextField value={name} onChangeText={setName} maxLength={30} />
|
<TextField value={name} onChangeText={setName} maxLength={30} />
|
||||||
|
|
||||||
@@ -156,19 +170,37 @@ export default function EditPetScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>{t("editPet.birthdayLabel")}</Text>
|
<Text style={styles.label}>{t("editPet.birthdayLabel")}</Text>
|
||||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
|
<DateField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
|
||||||
|
|
||||||
<Text style={styles.label}>{t("editPet.adoptedAtLabel")}</Text>
|
<Text style={styles.label}>{t("editPet.adoptedAtLabel")}</Text>
|
||||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
|
<DateField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
|
||||||
|
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
<Button title={t("common.save")} disabled={updateMutation.isPending} onPress={handleSave} />
|
<Button title={t("common.save")} disabled={updateMutation.isPending} onPress={handleSave} />
|
||||||
|
|
||||||
<Pressable style={styles.deleteRow} onPress={confirmDelete} disabled={deleteMutation.isPending}>
|
{/* Privatsphäre, Gedenkmodus, Löschen: nur der Haupt-Besitzer */}
|
||||||
<Trash2 color={colors.alertRed} size={18} />
|
{isMainOwner && (
|
||||||
<Text style={styles.deleteText}>{t("editPet.deletePet")}</Text>
|
<PrivacySettings
|
||||||
</Pressable>
|
petId={pet.id}
|
||||||
|
petName={pet.name}
|
||||||
|
dmPolicy={pet.dmPolicy}
|
||||||
|
followerVisibility={pet.followerVisibility}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CaretakerSettings petId={pet.id} petName={pet.name} />
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -182,7 +214,7 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: { padding: 16, gap: 8, backgroundColor: colors.porchWhite, flexGrow: 1 },
|
container: { padding: 16, gap: 8, backgroundColor: colors.porchWhite, flexGrow: 1 },
|
||||||
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite },
|
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 },
|
multiline: { height: 88, textAlignVertical: "top", paddingTop: 12 },
|
||||||
error: { ...typography.body, color: colors.alertRed, marginTop: 8 },
|
error: { ...typography.body, color: colors.alertRed, marginTop: 8 },
|
||||||
deleteRow: {
|
deleteRow: {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function FollowersScreen() {
|
|||||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<Pressable style={styles.row} onPress={() => router.push(`/pets/${item.follower.id}`)}>
|
<Pressable style={styles.row} onPress={() => router.push(`/pets/${item.follower.id}`)}>
|
||||||
<PetAvatar name={item.follower.name} avatarKey={item.follower.avatarKey} />
|
<PetAvatar name={item.follower.name} avatarKey={item.follower.avatarKey} owner={item.follower.owner} />
|
||||||
<Text style={typography.body}>{formatPetName(item.follower)}</Text>
|
<Text style={typography.body}>{formatPetName(item.follower)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function FollowingScreen() {
|
|||||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<Pressable style={styles.row} onPress={() => router.push(`/pets/${item.followee.id}`)}>
|
<Pressable style={styles.row} onPress={() => router.push(`/pets/${item.followee.id}`)}>
|
||||||
<PetAvatar name={item.followee.name} avatarKey={item.followee.avatarKey} />
|
<PetAvatar name={item.followee.name} avatarKey={item.followee.avatarKey} owner={item.followee.owner} />
|
||||||
<Text style={typography.body}>{formatPetName(item.followee)}</Text>
|
<Text style={typography.body}>{formatPetName(item.followee)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ export default function PetHealthScreen() {
|
|||||||
const bottomPadding = useBottomSafePadding();
|
const bottomPadding = useBottomSafePadding();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView contentContainerStyle={[styles.container, bottomPadding]}>
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.container, bottomPadding]}
|
||||||
|
automaticallyAdjustKeyboardInsets
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
<HealthConsentGate petId={petId}>
|
<HealthConsentGate petId={petId}>
|
||||||
<WeightLogSection petId={petId} />
|
<WeightLogSection petId={petId} />
|
||||||
<VetVisitSection petId={petId} />
|
<VetVisitSection petId={petId} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { View, Text, FlatList, Pressable, ActivityIndicator, Alert, StyleSheet }
|
|||||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { HeartPulse, Trophy, MessageCircle, MoreHorizontal } from "lucide-react-native";
|
import { HeartPulse, Trophy, MessageCircle, MoreHorizontal, Ribbon, Siren } from "lucide-react-native";
|
||||||
import { useTRPC } from "../../../../src/api/trpc";
|
import { useTRPC } from "../../../../src/api/trpc";
|
||||||
import { useActivePet } from "../../../../src/context/ActivePetContext";
|
import { useActivePet } from "../../../../src/context/ActivePetContext";
|
||||||
import { useTheme } from "../../../../src/context/ThemeContext";
|
import { useTheme } from "../../../../src/context/ThemeContext";
|
||||||
@@ -17,6 +17,7 @@ import { ReportSheet } from "../../../../src/components/ReportSheet";
|
|||||||
import { useBlockPet } from "../../../../src/hooks/useBlockPet";
|
import { useBlockPet } from "../../../../src/hooks/useBlockPet";
|
||||||
import { useVisiblePostIds } from "../../../../src/hooks/useVisiblePostIds";
|
import { useVisiblePostIds } from "../../../../src/hooks/useVisiblePostIds";
|
||||||
import { formatPetName } from "../../../../src/lib/format";
|
import { formatPetName } from "../../../../src/lib/format";
|
||||||
|
import { isoToDisplayDate } from "../../../../src/lib/date-format";
|
||||||
import { getAvatarUrl } from "../../../../src/lib/media-url";
|
import { getAvatarUrl } from "../../../../src/lib/media-url";
|
||||||
import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
|
import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
|
||||||
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
||||||
@@ -145,8 +146,39 @@ export default function PetProfileScreen() {
|
|||||||
</ReferralAvatarRing>
|
</ReferralAvatarRing>
|
||||||
<View style={styles.nameRow}>
|
<View style={styles.nameRow}>
|
||||||
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
||||||
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} referral={pet.owner} />
|
<PetIdentityBadges isLost={!!pet.lostSince} isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
|
||||||
</View>
|
</View>
|
||||||
|
{pet.lostReport && (
|
||||||
|
<Pressable
|
||||||
|
style={styles.lostBanner}
|
||||||
|
accessibilityRole="link"
|
||||||
|
onPress={() => router.push(`/lost/${pet.id}`)}
|
||||||
|
>
|
||||||
|
<View style={styles.lostTitleRow}>
|
||||||
|
<Siren size={15} color="#dc2626" />
|
||||||
|
<Text style={styles.lostTitle}>{t("lostReport.bannerTitle", { name: pet.name })}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.lostText}>
|
||||||
|
{t("lostReport.bannerText", {
|
||||||
|
place: pet.lostReport.lastSeenPlace,
|
||||||
|
date: isoToDisplayDate(new Date(pet.lostReport.lastSeenAt).toISOString().slice(0, 10)),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.lostLink}>{t("lostReport.bannerLink")}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
{pet.deceasedAt && (
|
||||||
|
<View style={styles.memorialBanner}>
|
||||||
|
<Ribbon size={13} color={colors.ink} fill={colors.ink} />
|
||||||
|
<Text style={styles.memorialText}>
|
||||||
|
{t("inMemory.banner", {
|
||||||
|
name: pet.name,
|
||||||
|
date: isoToDisplayDate(new Date(pet.deceasedAt).toISOString().slice(0, 10)),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{pet.isFoundingMember && <Text style={styles.foundingHint}>{t("foundingMember.tooltip")}</Text>}
|
||||||
<Text style={styles.species}>{pet.species.name}</Text>
|
<Text style={styles.species}>{pet.species.name}</Text>
|
||||||
{pet.invitedByPet && (
|
{pet.invitedByPet && (
|
||||||
<Text style={styles.invitedBy}>
|
<Text style={styles.invitedBy}>
|
||||||
@@ -228,14 +260,41 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
name: { ...typography.title },
|
name: { ...typography.title },
|
||||||
species: { ...typography.body, color: colors.quietGray },
|
species: { ...typography.body, color: colors.quietGray },
|
||||||
invitedBy: { ...typography.body, fontSize: 12, color: colors.quietGray },
|
invitedBy: { ...typography.body, fontSize: 12, color: colors.quietGray },
|
||||||
|
foundingHint: { ...typography.body, fontSize: 12, color: "#059669" },
|
||||||
|
memorialBanner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.hairlineGray,
|
||||||
|
backgroundColor: colors.whisperGray,
|
||||||
|
},
|
||||||
|
memorialText: { ...typography.body, fontSize: 12, color: colors.quietGray },
|
||||||
|
lostBanner: {
|
||||||
|
alignSelf: "stretch",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.alertRedTint,
|
||||||
|
backgroundColor: colors.alertRedTint,
|
||||||
|
},
|
||||||
|
lostTitleRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||||
|
lostTitle: { ...typography.body, fontFamily: typography.title.fontFamily, color: "#b91c1c" },
|
||||||
|
lostText: { ...typography.body, fontSize: 13, color: colors.ink, textAlign: "center" },
|
||||||
|
lostLink: { ...typography.body, fontSize: 12, textDecorationLine: "underline", color: colors.ink },
|
||||||
invitedByName: { color: colors.ink, fontWeight: "600" },
|
invitedByName: { color: colors.ink, fontWeight: "600" },
|
||||||
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
|
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
|
||||||
stat: { alignItems: "center" },
|
stat: { alignItems: "center" },
|
||||||
statCount: { ...typography.title, fontSize: 18 },
|
statCount: { ...typography.title, fontSize: 18 },
|
||||||
statLabel: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
statLabel: { ...typography.caption },
|
||||||
iconRow: { flexDirection: "row", gap: 20, marginTop: 14 },
|
iconRow: { flexDirection: "row", gap: 20, marginTop: 14 },
|
||||||
iconButton: { alignItems: "center", gap: 4 },
|
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 },
|
section: { ...typography.body, alignSelf: "stretch", marginTop: 12 },
|
||||||
sectionTitle: { ...typography.title, fontSize: 15, alignSelf: "stretch", marginTop: 16 },
|
sectionTitle: { ...typography.title, fontSize: 15, alignSelf: "stretch", marginTop: 16 },
|
||||||
divider: { height: 1, backgroundColor: colors.hairlineGray, 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 },
|
rowText: { flex: 1, gap: 2 },
|
||||||
rowTitle: { ...typography.body, fontFamily: typography.title.fontFamily },
|
rowTitle: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||||
rowDate: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
rowDate: { ...typography.caption },
|
||||||
rowNotes: { ...typography.body, fontSize: 13 },
|
rowNotes: { ...typography.body, fontSize: 13 },
|
||||||
thumb: { width: 48, height: 48, borderRadius: radii.md },
|
thumb: { width: 48, height: 48, borderRadius: radii.md },
|
||||||
addButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, height: 48, marginTop: 8 },
|
addButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, height: 48, marginTop: 8 },
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import { X, ImagePlus, Camera } from "lucide-react-native";
|
|||||||
import { useTRPC } from "../../src/api/trpc";
|
import { useTRPC } from "../../src/api/trpc";
|
||||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||||
import { useTheme } from "../../src/context/ThemeContext";
|
import { useTheme } from "../../src/context/ThemeContext";
|
||||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
import { uploadPickedImage } from "../../src/lib/upload";
|
||||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
|
||||||
import { TextField } from "../../src/components/TextField";
|
import { TextField } from "../../src/components/TextField";
|
||||||
import { Button } from "../../src/components/Button";
|
import { Button } from "../../src/components/Button";
|
||||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||||
|
|
||||||
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
// HEIC/HEIF (iPhone, manche Android-Kameras) ist hier ausdrücklich erlaubt:
|
||||||
|
// resizeForUpload() wandelt jedes Bild vor dem Upload in JPEG um, der Server
|
||||||
|
// sieht also nie HEIC. Vorher wurden solche Fotos hier still aussortiert.
|
||||||
|
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"];
|
||||||
const MAX_IMAGES = 10;
|
const MAX_IMAGES = 10;
|
||||||
|
|
||||||
type PickedImage = {
|
type PickedImage = {
|
||||||
@@ -61,13 +63,7 @@ export default function NewPostScreen() {
|
|||||||
const uploadAsset = useCallback(
|
const uploadAsset = useCallback(
|
||||||
async (asset: ImagePicker.ImagePickerAsset, index: number) => {
|
async (asset: ImagePicker.ImagePickerAsset, index: number) => {
|
||||||
try {
|
try {
|
||||||
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
const { key } = await uploadPickedImage(asset, (file) => getPresignedUrlMutation.mutateAsync({ petId: activePetId!, ...file }));
|
||||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
|
||||||
petId: activePetId!,
|
|
||||||
contentType: resized.mimeType,
|
|
||||||
fileSize: resized.fileSize,
|
|
||||||
});
|
|
||||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
|
||||||
setImages((prev) => {
|
setImages((prev) => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
updated[index] = { ...updated[index], status: "done", key };
|
updated[index] = { ...updated[index], status: "done", key };
|
||||||
@@ -92,9 +88,13 @@ export default function NewPostScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accepted = assets
|
const supported = assets.filter((asset) =>
|
||||||
.filter((asset) => ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "image/jpeg"))
|
ALLOWED_MIME_TYPES.includes((asset.mimeType ?? "image/jpeg").toLowerCase())
|
||||||
.slice(0, available);
|
);
|
||||||
|
if (supported.length < assets.length) {
|
||||||
|
Alert.alert(t("post.unsupportedFormat"));
|
||||||
|
}
|
||||||
|
const accepted = supported.slice(0, available);
|
||||||
|
|
||||||
if (accepted.length === 0) return;
|
if (accepted.length === 0) return;
|
||||||
|
|
||||||
@@ -112,11 +112,8 @@ export default function NewPostScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pickFromGallery = useCallback(async () => {
|
const pickFromGallery = useCallback(async () => {
|
||||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
// Kein Berechtigungsdialog: der System-Bildwähler (iOS PHPicker, Android Photo Picker)
|
||||||
if (!permission.granted) {
|
// braucht keine Freigabe der Fotomediathek (Apple 5.1.1(iv), 07.10.).
|
||||||
Alert.alert(t("onboarding.galleryPermissionDenied"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
mediaTypes: ["images"],
|
mediaTypes: ["images"],
|
||||||
allowsMultipleSelection: true,
|
allowsMultipleSelection: true,
|
||||||
@@ -155,7 +152,11 @@ export default function NewPostScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView contentContainerStyle={styles.container}>
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
automaticallyAdjustKeyboardInsets
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
{images.length > 0 && (
|
{images.length > 0 && (
|
||||||
<View style={styles.grid}>
|
<View style={styles.grid}>
|
||||||
{images.map((img, i) => (
|
{images.map((img, i) => (
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function CommentRow({ comment, isOwn, onDelete, onPressPet }: CommentRowProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Pressable onPress={onPressPet}>
|
<Pressable onPress={onPressPet}>
|
||||||
<PetAvatar name={comment.pet.name} avatarKey={comment.pet.avatarKey} size="sm" />
|
<PetAvatar name={comment.pet.name} avatarKey={comment.pet.avatarKey} size="sm" owner={comment.pet.owner} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.rowBody}>
|
<View style={styles.rowBody}>
|
||||||
<View style={styles.rowHeader}>
|
<View style={styles.rowHeader}>
|
||||||
@@ -187,8 +187,8 @@ function makeStyles(colors: ColorTokens, insetsBottom: number) {
|
|||||||
rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 },
|
rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 },
|
||||||
bodyText: { ...typography.body },
|
bodyText: { ...typography.body },
|
||||||
petName: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 },
|
petName: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 },
|
||||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
timestamp: { ...typography.caption, fontSize: 11 },
|
||||||
pendingLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
pendingLabel: { ...typography.caption, fontSize: 11 },
|
||||||
deleteButton: { alignSelf: "flex-start", padding: 4 },
|
deleteButton: { alignSelf: "flex-start", padding: 4 },
|
||||||
inputRow: {
|
inputRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { FollowButton } from "../../src/components/FollowButton";
|
|||||||
import { getMediaUrl } from "../../src/lib/media-url";
|
import { getMediaUrl } from "../../src/lib/media-url";
|
||||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||||
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
|
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
|
||||||
|
import type { ReferralOwnerFields } from "../../src/hooks/useReferralRanks";
|
||||||
|
|
||||||
type Tab = "pets" | "people" | "tags";
|
type Tab = "pets" | "people" | "tags";
|
||||||
type Styles = ReturnType<typeof makeStyles>;
|
type Styles = ReturnType<typeof makeStyles>;
|
||||||
@@ -25,6 +26,7 @@ type SearchPet = {
|
|||||||
avatarKey: string | null;
|
avatarKey: string | null;
|
||||||
species: { name: string };
|
species: { name: string };
|
||||||
breed: { name: string } | null;
|
breed: { name: string } | null;
|
||||||
|
owner?: ReferralOwnerFields | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,7 +209,7 @@ function PetRow({
|
|||||||
return (
|
return (
|
||||||
<View style={[styles.petRow, indented && styles.petRowIndented]}>
|
<View style={[styles.petRow, indented && styles.petRowIndented]}>
|
||||||
<Pressable style={styles.petRowMain} onPress={onPress}>
|
<Pressable style={styles.petRowMain} onPress={onPress}>
|
||||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="sm" />
|
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="sm" owner={pet.owner} />
|
||||||
<View style={styles.petRowText}>
|
<View style={styles.petRowText}>
|
||||||
<Text style={styles.petName}>{pet.name}</Text>
|
<Text style={styles.petName}>{pet.name}</Text>
|
||||||
<Text style={styles.petSpecies}>{pet.breed?.name ?? pet.species.name}</Text>
|
<Text style={styles.petSpecies}>{pet.breed?.name ?? pet.species.name}</Text>
|
||||||
@@ -246,7 +248,7 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
tabs: { flexDirection: "row", gap: 8, paddingHorizontal: 16, paddingVertical: 12 },
|
tabs: { flexDirection: "row", gap: 8, paddingHorizontal: 16, paddingVertical: 12 },
|
||||||
tabButton: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray },
|
tabButton: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray },
|
||||||
tabButtonActive: { backgroundColor: colors.marigoldOrange },
|
tabButtonActive: { backgroundColor: colors.marigoldOrange },
|
||||||
tabButtonText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
|
tabButtonText: { ...typography.caption, color: colors.ink },
|
||||||
tabButtonTextActive: { color: colors.pureWhite },
|
tabButtonTextActive: { color: colors.pureWhite },
|
||||||
list: { padding: 16, flexGrow: 1 },
|
list: { padding: 16, flexGrow: 1 },
|
||||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||||
@@ -256,7 +258,7 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
petRowMain: { flex: 1, flexDirection: "row", alignItems: "center", gap: 10 },
|
petRowMain: { flex: 1, flexDirection: "row", alignItems: "center", gap: 10 },
|
||||||
petRowText: { flex: 1 },
|
petRowText: { flex: 1 },
|
||||||
petName: { ...typography.body, fontFamily: typography.title.fontFamily },
|
petName: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||||
petSpecies: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
petSpecies: { ...typography.caption },
|
||||||
ownerBlock: { marginBottom: 16 },
|
ownerBlock: { marginBottom: 16 },
|
||||||
ownerName: { ...typography.body, fontFamily: typography.title.fontFamily, marginBottom: 4 },
|
ownerName: { ...typography.body, fontFamily: typography.title.fontFamily, marginBottom: 4 },
|
||||||
tagGrid: { padding: 2 },
|
tagGrid: { padding: 2 },
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import { View, Text, Image as RNImage, StyleSheet } from "react-native";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTRPC } from "../../src/api/trpc";
|
||||||
|
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||||
|
import { uploadFileToUrl, uploadPickedImage } from "../../src/lib/upload";
|
||||||
|
import { StoryEditor, type EditorMedia, type EditorResult } from "../../src/components/story-editor/StoryEditor";
|
||||||
|
import { fontFamily } from "../../src/theme/tokens";
|
||||||
|
|
||||||
|
type Params = { kind: "image" | "video" | "text"; uri?: string; width?: string; height?: string; mimeType?: string };
|
||||||
|
|
||||||
|
/** Echte Pixelmaße der (verkleinerten) Datei — der Picker meldet bei gedrehten Handyfotos teils vertauschte Werte. */
|
||||||
|
function imageSize(uri: string): Promise<{ width: number; height: number }> {
|
||||||
|
return new Promise((resolve, reject) => RNImage.getSize(uri, (width, height) => resolve({ width, height }), reject));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaFromParams(params: Params): EditorMedia | null {
|
||||||
|
if (params.kind === "text") return { kind: "text" };
|
||||||
|
const width = Number(params.width);
|
||||||
|
const height = Number(params.height);
|
||||||
|
if (!params.uri || !width || !height) return null;
|
||||||
|
return params.kind === "video"
|
||||||
|
? { kind: "video", uri: params.uri, width, height, mimeType: params.mimeType || undefined }
|
||||||
|
: { kind: "image", uri: params.uri, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Story-Editor als eigener Vollbild-Screen (fullScreenModal, ohne Kopfzeile,
|
||||||
|
* ohne Wisch-zum-Schließen). Vorher lief er innerhalb des Auswahl-Screens
|
||||||
|
* story-new, der auf iOS ein Sheet ist — dort das Umschalten der Kopfzeile und
|
||||||
|
* ein weiteres Modal führten dazu, dass alles verschwand und nur das X kurz
|
||||||
|
* aufleuchtete (Tester-Feedback 04.10.). Das Medium kommt als Route-Parameter.
|
||||||
|
*
|
||||||
|
* Upload wie bisher: Foto verkleinert zu Supabase, Video direkt zu Mux
|
||||||
|
* (createVideoUpload legt die PROCESSING-Story an), Text-Story ohne Datei.
|
||||||
|
*/
|
||||||
|
export default function StoryEditorScreen() {
|
||||||
|
const params = useLocalSearchParams<Params>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { activePetId } = useActivePet();
|
||||||
|
const media = useMemo(() => mediaFromParams(params), [params]);
|
||||||
|
|
||||||
|
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadPercent, setUploadPercent] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions());
|
||||||
|
const createMutation = useMutation(trpc.stories.create.mutationOptions());
|
||||||
|
const createTextMutation = useMutation(trpc.stories.createText.mutationOptions());
|
||||||
|
const createVideoUploadMutation = useMutation(trpc.stories.createVideoUpload.mutationOptions());
|
||||||
|
|
||||||
|
const finish = useCallback(async () => {
|
||||||
|
await queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||||
|
await queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||||
|
// Editor + Auswahl-Screen schließen → zurück dorthin, wo die Story gestartet wurde.
|
||||||
|
router.dismissAll();
|
||||||
|
}, [queryClient, trpc, router]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async ({ overlays, background }: EditorResult) => {
|
||||||
|
if (!media || !activePetId) return;
|
||||||
|
setError(null);
|
||||||
|
const editorFields = overlays.length > 0 ? { overlays } : {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (media.kind === "text") {
|
||||||
|
await createTextMutation.mutateAsync({ petId: activePetId, background: background ?? "sunset", overlays });
|
||||||
|
await finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.kind === "video") {
|
||||||
|
const { uploadUrl } = await createVideoUploadMutation.mutateAsync({
|
||||||
|
petId: activePetId,
|
||||||
|
aiDisclosure: "NONE",
|
||||||
|
...editorFields,
|
||||||
|
mediaWidth: media.width,
|
||||||
|
mediaHeight: media.height,
|
||||||
|
});
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadPercent(0);
|
||||||
|
await uploadFileToUrl(media.uri, uploadUrl ?? "", media.mimeType ?? "video/mp4", (fraction) => {
|
||||||
|
const pct = Math.round(fraction * 100);
|
||||||
|
setUploadPercent((prev) => (prev === pct ? prev : pct));
|
||||||
|
});
|
||||||
|
await finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
const 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: uploaded.key,
|
||||||
|
blurhash: null,
|
||||||
|
...editorFields,
|
||||||
|
mediaWidth: size.width,
|
||||||
|
mediaHeight: size.height,
|
||||||
|
});
|
||||||
|
await finish();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error && err.message ? err.message : t("onboarding.uploadError"));
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadPercent(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[media, activePetId, createTextMutation, createVideoUploadMutation, getPresignedUrlMutation, createMutation, finish, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSubmitting =
|
||||||
|
isUploading ||
|
||||||
|
getPresignedUrlMutation.isPending ||
|
||||||
|
createMutation.isPending ||
|
||||||
|
createTextMutation.isPending ||
|
||||||
|
createVideoUploadMutation.isPending;
|
||||||
|
|
||||||
|
if (!media || !activePetId) {
|
||||||
|
return (
|
||||||
|
<View style={styles.missing}>
|
||||||
|
<Text style={styles.missingText}>{t("onboarding.uploadError")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StoryEditor
|
||||||
|
media={media}
|
||||||
|
petId={activePetId}
|
||||||
|
toolsEnabled={flags?.storyEditor ?? false}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
submitLabel={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => {
|
||||||
|
if (!isSubmitting) router.back();
|
||||||
|
}}
|
||||||
|
progress={
|
||||||
|
<>
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
{isSubmitting && media.kind === "video" ? <UploadProgress percent={uploadPercent} /> : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Video-Upload zu Mux: erst „wird vorbereitet“, dann echter Fortschritt (expo-file-system onProgress). */
|
||||||
|
function UploadProgress({ percent }: { percent: number | null }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const label = percent === null ? t("story.preparingUpload") : t("story.uploadingVideo", { percent });
|
||||||
|
return (
|
||||||
|
<View style={styles.progressWrap} accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: 100, now: percent ?? 0 }} accessibilityLabel={label}>
|
||||||
|
<View style={styles.progressTrack}>
|
||||||
|
<View style={[styles.progressFill, { width: `${percent ?? 0}%` }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.progressLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
missing: { flex: 1, backgroundColor: "#111111", alignItems: "center", justifyContent: "center", padding: 24 },
|
||||||
|
missingText: { color: "#ffffff", fontFamily: fontFamily.regular, fontSize: 15, textAlign: "center" },
|
||||||
|
error: { color: "#f87171", fontFamily: fontFamily.regular, fontSize: 14, textAlign: "center" },
|
||||||
|
progressWrap: { gap: 6 },
|
||||||
|
progressTrack: { height: 6, borderRadius: 3, backgroundColor: "rgba(255,255,255,0.2)", overflow: "hidden" },
|
||||||
|
progressFill: { height: "100%", borderRadius: 3, backgroundColor: "#f97316" },
|
||||||
|
progressLabel: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 12, textAlign: "center" },
|
||||||
|
});
|
||||||
@@ -1,58 +1,70 @@
|
|||||||
import { useState, useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
|
import { View, Text, Pressable, StyleSheet, Alert } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ImagePlus, Camera } from "lucide-react-native";
|
import { ImagePlus, Camera, Type } from "lucide-react-native";
|
||||||
import { useTRPC } from "../../src/api/trpc";
|
import { useTRPC } from "../../src/api/trpc";
|
||||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
|
||||||
import { useTheme } from "../../src/context/ThemeContext";
|
import { useTheme } from "../../src/context/ThemeContext";
|
||||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
import { MAX_STORY_VIDEO_DURATION_SECS } from "../../src/lib/story-video-limits";
|
||||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
|
||||||
import { Button } from "../../src/components/Button";
|
|
||||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Photo-only story creation (matches web's own "Phase 2" note in
|
* Story erstellen — Schritt 1: Foto, kurzes Video oder Text-Story wählen.
|
||||||
* stories.ts: "Photo-only in Phase 2" — video stories are a Mux upload
|
* Bearbeiten und Teilen passieren im eigenen Vollbild-Screen story-editor.tsx
|
||||||
* flow web itself only added later, not carried over here). Single image,
|
* (dieser Screen ist auf iOS ein Sheet; den Editor darin einzublenden führte
|
||||||
* unlike post-new.tsx's multi-image picker — a story is one photo.
|
* dazu, dass alles verschwand — siehe story-editor.tsx). Ist der Notschalter
|
||||||
|
* (Admin) aus, gibt es keine Text-Story und der Editor zeigt nur Vorschau + Teilen.
|
||||||
*/
|
*/
|
||||||
export default function NewStoryScreen() {
|
export default function NewStoryScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { activePetId } = useActivePet();
|
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
const insets = useSafeAreaInsets();
|
||||||
|
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
|
||||||
|
|
||||||
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
|
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
|
||||||
const [error, setError] = useState<string | null>(null);
|
const editorEnabled = flags?.storyEditor ?? false;
|
||||||
|
|
||||||
const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions());
|
const openEditor = useCallback(
|
||||||
|
(params: { kind: "image" | "video" | "text"; uri?: string; width?: number; height?: number; mimeType?: string | null }) => {
|
||||||
const createMutation = useMutation(
|
router.push({
|
||||||
trpc.stories.create.mutationOptions({
|
pathname: "/story-editor",
|
||||||
onSuccess: async () => {
|
params: {
|
||||||
await queryClient.invalidateQueries(trpc.follows.pathFilter());
|
kind: params.kind,
|
||||||
await queryClient.invalidateQueries(trpc.stories.pathFilter());
|
uri: params.uri ?? "",
|
||||||
router.back();
|
width: String(params.width ?? 0),
|
||||||
},
|
height: String(params.height ?? 0),
|
||||||
onError: (err) => setError(err.message),
|
mimeType: params.mimeType ?? "",
|
||||||
})
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const pickFromGallery = useCallback(async () => {
|
const pickFromGallery = useCallback(async () => {
|
||||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
// Kein Berechtigungsdialog: der System-Bildwähler (iOS PHPicker, Android Photo Picker)
|
||||||
if (!permission.granted) {
|
// braucht keine Freigabe der Fotomediathek (Apple 5.1.1(iv), 07.10.).
|
||||||
Alert.alert(t("onboarding.galleryPermissionDenied"));
|
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], quality: 0.8 });
|
||||||
|
if (result.canceled) return;
|
||||||
|
|
||||||
|
// expo-image-picker meldet `duration` (ms) nur bei Videos — verlässlicher als ein MIME-Typ.
|
||||||
|
const picked = result.assets[0];
|
||||||
|
if (picked.duration != null && picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) {
|
||||||
|
Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 });
|
openEditor({
|
||||||
if (!result.canceled) setAsset(result.assets[0]);
|
kind: picked.duration != null ? "video" : "image",
|
||||||
}, []);
|
uri: picked.uri,
|
||||||
|
width: picked.width,
|
||||||
|
height: picked.height,
|
||||||
|
mimeType: picked.mimeType,
|
||||||
|
});
|
||||||
|
}, [t, openEditor]);
|
||||||
|
|
||||||
const takePhoto = useCallback(async () => {
|
const takePhoto = useCallback(async () => {
|
||||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
@@ -61,65 +73,44 @@ export default function NewStoryScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await ImagePicker.launchCameraAsync({ quality: 0.8 });
|
const result = await ImagePicker.launchCameraAsync({ quality: 0.8 });
|
||||||
if (!result.canceled) setAsset(result.assets[0]);
|
if (!result.canceled) {
|
||||||
}, []);
|
const photo = result.assets[0];
|
||||||
|
openEditor({ kind: "image", uri: photo.uri, width: photo.width, height: photo.height });
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
if (!asset || !activePetId) return;
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
|
||||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
|
||||||
petId: activePetId,
|
|
||||||
contentType: resized.mimeType,
|
|
||||||
fileSize: resized.fileSize,
|
|
||||||
});
|
|
||||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
|
||||||
createMutation.mutate({ petId: activePetId, storageKey: key, blurhash: null });
|
|
||||||
} catch {
|
|
||||||
setError(t("onboarding.uploadError"));
|
|
||||||
}
|
}
|
||||||
}, [asset, activePetId, getPresignedUrlMutation, createMutation]);
|
}, [t, openEditor]);
|
||||||
|
|
||||||
const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{asset ? (
|
<View style={styles.pickerRow}>
|
||||||
<Pressable style={styles.preview} onPress={() => setAsset(null)}>
|
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
|
||||||
<RNImage source={{ uri: asset.uri }} style={styles.previewImage} />
|
<ImagePlus color={colors.marigoldOrange} size={22} />
|
||||||
<Text style={styles.previewHint}>{t("story.changePhotoHint")}</Text>
|
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.pickerButton} onPress={takePhoto}>
|
||||||
|
<Camera color={colors.marigoldOrange} size={22} />
|
||||||
|
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
{editorEnabled && (
|
||||||
|
<Pressable style={styles.textStoryButton} onPress={() => openEditor({ kind: "text" })}>
|
||||||
|
<Type color={colors.pureWhite} size={22} />
|
||||||
|
<Text style={styles.textStoryText}>{t("storyEditor.textStory")}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
|
||||||
<View style={styles.pickerRow}>
|
|
||||||
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
|
|
||||||
<ImagePlus color={colors.marigoldOrange} size={22} />
|
|
||||||
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable style={styles.pickerButton} onPress={takePhoto}>
|
|
||||||
<Camera color={colors.marigoldOrange} size={22} />
|
|
||||||
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
title={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
|
|
||||||
onPress={handleSubmit}
|
|
||||||
disabled={!asset || isSubmitting}
|
|
||||||
/>
|
|
||||||
{isSubmitting && <ActivityIndicator color={colors.marigoldOrange} />}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens, bottomInset: number) {
|
||||||
const typography = getTypography(colors);
|
const typography = getTypography(colors);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: { flex: 1, padding: 16, gap: 12, backgroundColor: colors.porchWhite },
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: Math.max(16, bottomInset + 12),
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: colors.porchWhite,
|
||||||
|
},
|
||||||
pickerRow: { flexDirection: "row", gap: 12 },
|
pickerRow: { flexDirection: "row", gap: 12 },
|
||||||
pickerButton: {
|
pickerButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -133,9 +124,15 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
borderColor: colors.hairlineGray,
|
borderColor: colors.hairlineGray,
|
||||||
},
|
},
|
||||||
pickerText: { ...typography.body, color: colors.ink },
|
pickerText: { ...typography.body, color: colors.ink },
|
||||||
preview: { flex: 1, borderRadius: radii.md, overflow: "hidden", backgroundColor: colors.hairlineGray, gap: 8 },
|
textStoryButton: {
|
||||||
previewImage: { width: "100%", flex: 1 },
|
flexDirection: "row",
|
||||||
previewHint: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", paddingVertical: 6 },
|
alignItems: "center",
|
||||||
error: { ...typography.body, color: colors.alertRed },
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
backgroundColor: colors.marigoldOrange,
|
||||||
|
},
|
||||||
|
textStoryText: { ...typography.body, color: colors.pureWhite, fontFamily: typography.title.fontFamily },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,52 +1,151 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator, type LayoutChangeEvent } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { X } from "lucide-react-native";
|
import { useVideoPlayer, VideoView } from "expo-video";
|
||||||
|
import { useEventListener } from "expo";
|
||||||
|
import { AlertCircle, Clock, Eye, MessageCircle, PawPrint, Trash2, X } from "lucide-react-native";
|
||||||
import { useTRPC } from "../../src/api/trpc";
|
import { useTRPC } from "../../src/api/trpc";
|
||||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||||
import { PetAvatar } from "../../src/components/PetAvatar";
|
import { PetAvatar } from "../../src/components/PetAvatar";
|
||||||
|
import { StoryPawButton } from "../../src/components/stories/StoryPawButton";
|
||||||
|
import { StoryCommentsSheet } from "../../src/components/stories/StoryCommentsSheet";
|
||||||
|
import { StoryViewersSheet } from "../../src/components/stories/StoryViewersSheet";
|
||||||
import { getMediaUrl } from "../../src/lib/media-url";
|
import { getMediaUrl } from "../../src/lib/media-url";
|
||||||
|
import { StoryOverlayLayer } from "../../src/components/story-editor/StoryOverlayLayer";
|
||||||
|
import { StoryBackground } from "../../src/components/story-editor/StoryBackground";
|
||||||
|
import { containRect, storyMediaSize } from "../../src/lib/story-overlays";
|
||||||
import { colors } from "../../src/theme/tokens";
|
import { colors } from "../../src/theme/tokens";
|
||||||
|
|
||||||
const STORY_DURATION_MS = 5000;
|
const IMAGE_STORY_DURATION_MS = 5000;
|
||||||
|
|
||||||
|
// "away" = über eine Markierung ins Tierprofil gewechselt — Story pausiert bis zur Rückkehr.
|
||||||
|
type Sheet = "comments" | "viewers" | "confirm-delete" | "away" | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port of web's src/components/stories/StoryViewer.tsx, scoped to photo
|
* Port of web's src/components/stories/StoryViewer.tsx. Tap the left third
|
||||||
* stories only (no Mux video story playback — see story-new.tsx). Also
|
* to go back a story, the rest to skip ahead. Foto-Storys laufen über einen
|
||||||
* drops the owner-only viewer-count/list Sheet and the paw-reaction/comment
|
* festen 5-s-Timer; Video-Storys folgen der echten Wiedergabe (expo-video
|
||||||
* overlay (Gitea #29 additions on web) — fast-follows, not core to "view a
|
* `timeUpdate`) und springen bei `playToEnd` weiter, wie im Web. Pfote, Kommentare (Bottom-Sheet) und — nur für die eigene
|
||||||
* story". Tap the left third to go back a story, the rest to skip ahead;
|
* Story — die Zuschauerliste und Löschen wie im Web; solange ein Sheet oder
|
||||||
* a fixed timer still auto-advances either way.
|
* die Lösch-Rückfrage offen ist, hält der Timer an.
|
||||||
*/
|
*/
|
||||||
export default function StoryViewerScreen() {
|
export default function StoryViewerScreen() {
|
||||||
const { petId, petName, petAvatarKey } = useLocalSearchParams<{
|
const { petId, petName: petNameParam, petAvatarKey: petAvatarKeyParam, storyId: initialStoryId } = useLocalSearchParams<{
|
||||||
petId: string;
|
petId: string;
|
||||||
petName: string;
|
petName: string;
|
||||||
petAvatarKey?: string;
|
petAvatarKey?: string;
|
||||||
|
/** Aus einer Mitteilung: direkt diese Story zeigen statt der ersten. */
|
||||||
|
storyId?: string;
|
||||||
}>();
|
}>();
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { activePetId } = useActivePet();
|
const { activePetId } = useActivePet();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const isOwnStory = activePetId === petId;
|
||||||
|
// Über eine Push-Mitteilung geöffnet → nur petId bekannt, Name/Bild nachladen.
|
||||||
|
const { data: storyPet } = useQuery({ ...trpc.pets.getProfile.queryOptions({ petId }), enabled: !petNameParam });
|
||||||
|
const petName = petNameParam || storyPet?.name || "";
|
||||||
|
const petAvatarKey = petAvatarKeyParam || storyPet?.avatarKey || undefined;
|
||||||
|
|
||||||
const { data: stories = [] } = useQuery(trpc.stories.listActive.queryOptions({ petId }));
|
const listQuery = trpc.stories.listActive.queryOptions({ petId, viewerPetId: activePetId ?? undefined });
|
||||||
|
const { data: stories = [], isSuccess: storiesLoaded } = useQuery(listQuery);
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
|
const jumpedToInitialRef = useRef(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [openSheet, setOpenSheet] = useState<Sheet>(null);
|
||||||
const advancedRef = useRef(false);
|
const advancedRef = useRef(false);
|
||||||
|
const elapsedRef = useRef(0); // bereits abgelaufene Zeit der aktuellen Story (für Pause/Weiter)
|
||||||
|
|
||||||
const currentStory = stories[index];
|
const currentStory = stories[index];
|
||||||
|
const isPaused = openSheet !== null;
|
||||||
|
const isVideo = currentStory?.mediaType === "VIDEO";
|
||||||
|
const isText = currentStory?.mediaType === "TEXT";
|
||||||
|
const [screen, setScreen] = useState({ width: 0, height: 0 });
|
||||||
|
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
|
||||||
|
// Notschalter: Overlays nur zeigen, wenn der Story-Editor im Admin an ist.
|
||||||
|
const showOverlays = flags?.storyEditor ?? false;
|
||||||
|
|
||||||
|
// Zurück aus dem Tierprofil (über eine Markierung) → Story läuft weiter.
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setOpenSheet((sheet) => (sheet === "away" ? null : sheet));
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions());
|
const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions());
|
||||||
|
|
||||||
|
const deleteMutation = useMutation(
|
||||||
|
trpc.stories.delete.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
const remaining = stories.length - 1;
|
||||||
|
setOpenSheet(null);
|
||||||
|
void queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||||
|
void queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||||
|
if (remaining <= 0) router.back();
|
||||||
|
else setIndex((i) => Math.min(i, remaining - 1));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setOpenSheet(null);
|
||||||
|
Alert.alert(t("storyViewer.deleteError"));
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Story-ID beim Antippen festhalten: gelöscht wird genau die angezeigte Story.
|
||||||
|
const confirmDelete = (storyId: string) => {
|
||||||
|
setOpenSheet("confirm-delete");
|
||||||
|
Alert.alert(
|
||||||
|
t("storyViewer.deleteTitle"),
|
||||||
|
t("storyViewer.deleteDescription"),
|
||||||
|
[
|
||||||
|
{ text: t("common.cancel"), style: "cancel", onPress: () => setOpenSheet(null) },
|
||||||
|
{ text: t("storyViewer.deleteConfirm"), style: "destructive", onPress: () => deleteMutation.mutate({ storyId }) },
|
||||||
|
],
|
||||||
|
{ cancelable: true, onDismiss: () => setOpenSheet(null) }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Solange ein Video noch verarbeitet wird, Status abfragen (Self-Heal wie VideoPlayer.tsx).
|
||||||
|
const { data: videoStatusData } = useQuery({
|
||||||
|
...trpc.stories.getVideoStatus.queryOptions({ storyId: currentStory?.id ?? "" }),
|
||||||
|
enabled: !!currentStory && isVideo && currentStory.videoStatus === "PROCESSING",
|
||||||
|
refetchInterval: (query) => (query.state.data?.videoStatus === "PROCESSING" ? 3000 : false),
|
||||||
|
});
|
||||||
|
const videoStatus = videoStatusData?.videoStatus ?? currentStory?.videoStatus;
|
||||||
|
const videoPlaybackId = videoStatusData?.muxPlaybackId ?? currentStory?.muxPlaybackId;
|
||||||
|
|
||||||
|
const { data: viewers = [] } = useQuery({
|
||||||
|
...trpc.stories.getViewers.queryOptions({ storyId: currentStory?.id ?? "" }),
|
||||||
|
enabled: isOwnStory && !!currentStory,
|
||||||
|
});
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
queryClient.invalidateQueries(trpc.stories.hasActiveStory.queryOptions({ petId }));
|
queryClient.invalidateQueries(trpc.stories.hasActiveStory.queryOptions({ petId }));
|
||||||
queryClient.invalidateQueries(trpc.follows.pathFilter());
|
queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||||
router.back();
|
router.back();
|
||||||
}, [queryClient, trpc, petId, router]);
|
}, [queryClient, trpc, petId, router]);
|
||||||
|
|
||||||
|
const openPet = useCallback(
|
||||||
|
(targetPetId: string) => {
|
||||||
|
setOpenSheet(null);
|
||||||
|
router.push(`/pets/${targetPetId}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openMentionedPet = useCallback(
|
||||||
|
(targetPetId: string) => {
|
||||||
|
setOpenSheet("away");
|
||||||
|
router.push(`/pets/${targetPetId}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
const advance = useCallback(() => {
|
const advance = useCallback(() => {
|
||||||
if (advancedRef.current) return;
|
if (advancedRef.current) return;
|
||||||
advancedRef.current = true;
|
advancedRef.current = true;
|
||||||
@@ -70,16 +169,25 @@ export default function StoryViewerScreen() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentStory?.id, activePetId]);
|
}, [currentStory?.id, activePetId]);
|
||||||
|
|
||||||
// Fixed-duration progress bar, auto-advances/closes when it fills.
|
// Neue Story → Fortschritt zurücksetzen.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentStory) return;
|
elapsedRef.current = 0;
|
||||||
setProgress(0);
|
|
||||||
advancedRef.current = false;
|
advancedRef.current = false;
|
||||||
const startTime = Date.now();
|
setProgress(0);
|
||||||
|
}, [currentStory?.id]);
|
||||||
|
|
||||||
|
// Fester Timer nur für Foto-Storys; pausiert, solange ein Sheet offen ist.
|
||||||
|
// Video-Storys steuern Fortschritt und Weiterspringen selbst (ReadyVideoStory).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentStory || isVideo || isPaused) return;
|
||||||
|
const resumedAt = Date.now();
|
||||||
|
const elapsedBefore = elapsedRef.current;
|
||||||
let rafId: number;
|
let rafId: number;
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
const pct = Math.min(((Date.now() - startTime) / STORY_DURATION_MS) * 100, 100);
|
const elapsed = elapsedBefore + (Date.now() - resumedAt);
|
||||||
|
elapsedRef.current = elapsed;
|
||||||
|
const pct = Math.min((elapsed / IMAGE_STORY_DURATION_MS) * 100, 100);
|
||||||
setProgress(pct);
|
setProgress(pct);
|
||||||
if (pct < 100) rafId = requestAnimationFrame(tick);
|
if (pct < 100) rafId = requestAnimationFrame(tick);
|
||||||
else advance();
|
else advance();
|
||||||
@@ -87,14 +195,36 @@ export default function StoryViewerScreen() {
|
|||||||
|
|
||||||
rafId = requestAnimationFrame(tick);
|
rafId = requestAnimationFrame(tick);
|
||||||
return () => cancelAnimationFrame(rafId);
|
return () => cancelAnimationFrame(rafId);
|
||||||
}, [currentStory, advance]);
|
}, [currentStory, isVideo, isPaused, advance]);
|
||||||
|
|
||||||
|
// Aus einer Mitteilung geöffnet → einmalig zur genannten Story springen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (jumpedToInitialRef.current || !initialStoryId || stories.length === 0) return;
|
||||||
|
jumpedToInitialRef.current = true;
|
||||||
|
const target = stories.findIndex((s) => s.id === initialStoryId);
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmaliger Sprung, sobald die Liste da ist
|
||||||
|
if (target > 0) setIndex(target);
|
||||||
|
}, [initialStoryId, stories]);
|
||||||
|
|
||||||
|
// Keine (laufende) Story mehr, z. B. über eine alte Mitteilung geöffnet → zurück statt schwarzem Bildschirm.
|
||||||
|
useEffect(() => {
|
||||||
|
if (storiesLoaded && stories.length === 0) router.back();
|
||||||
|
}, [storiesLoaded, stories.length, router]);
|
||||||
|
|
||||||
if (!currentStory) return null;
|
if (!currentStory) return null;
|
||||||
|
|
||||||
const mediaUrl = currentStory.storageKey ? getMediaUrl(currentStory.storageKey) : null;
|
const mediaUrl = currentStory.storageKey ? getMediaUrl(currentStory.storageKey) : null;
|
||||||
|
const refreshStories = () => void queryClient.invalidateQueries({ queryKey: listQuery.queryKey });
|
||||||
|
// Medienfläche wie bei contentFit="contain" — darauf beziehen sich die Overlays.
|
||||||
|
const mediaSize = storyMediaSize(currentStory);
|
||||||
|
const rect = mediaSize ? containRect(screen.width, screen.height, mediaSize.width, mediaSize.height) : null;
|
||||||
|
const onLayout = (e: LayoutChangeEvent) => {
|
||||||
|
const { width, height } = e.nativeEvent.layout;
|
||||||
|
setScreen((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.backdrop}>
|
<View style={styles.backdrop} onLayout={onLayout}>
|
||||||
<View style={[styles.progressRow, { top: insets.top + 12 }]}>
|
<View style={[styles.progressRow, { top: insets.top + 12 }]}>
|
||||||
{stories.map((s, i) => (
|
{stories.map((s, i) => (
|
||||||
<View key={s.id} style={styles.progressTrack}>
|
<View key={s.id} style={styles.progressTrack}>
|
||||||
@@ -108,28 +238,210 @@ export default function StoryViewerScreen() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />}
|
{isVideo ? (
|
||||||
|
<VideoStory
|
||||||
|
key={currentStory.id}
|
||||||
|
status={videoStatus}
|
||||||
|
playbackId={videoPlaybackId}
|
||||||
|
paused={isPaused}
|
||||||
|
onProgress={setProgress}
|
||||||
|
onEnded={advance}
|
||||||
|
/>
|
||||||
|
) : isText ? (
|
||||||
|
rect && (
|
||||||
|
<View style={{ position: "absolute", left: rect.x, top: rect.y }}>
|
||||||
|
<StoryBackground backgroundKey={currentStory.background ?? "sunset"} width={rect.width} height={rect.height} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.tapZones}>
|
<View style={styles.tapZones}>
|
||||||
<Pressable style={styles.tapZoneLeft} onPress={goBack} />
|
<Pressable style={styles.tapZoneLeft} onPress={goBack} />
|
||||||
<Pressable style={styles.tapZoneRight} onPress={advance} />
|
<Pressable style={styles.tapZoneRight} onPress={advance} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{showOverlays && rect && (
|
||||||
|
<StoryOverlayLayer
|
||||||
|
overlays={currentStory.overlays}
|
||||||
|
rect={rect}
|
||||||
|
mentionedPets={currentStory.mentionedPets}
|
||||||
|
onPressMention={openMentionedPet}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={[styles.petInfo, { bottom: insets.bottom + 24 }]}>
|
<View style={[styles.petInfo, { bottom: insets.bottom + 24 }]}>
|
||||||
<PetAvatar name={petName} avatarKey={petAvatarKey || null} size="sm" />
|
<PetAvatar name={petName} avatarKey={petAvatarKey || null} size="sm" />
|
||||||
<Text style={styles.petName}>{petName}</Text>
|
<Text style={styles.petName}>{petName}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{currentStory.isExpired && (
|
||||||
|
<View style={[styles.expiredRow, { top: insets.top + 56 }]} pointerEvents="none">
|
||||||
|
<View style={styles.expiredBadge}>
|
||||||
|
<Clock size={14} color={colors.pureWhite} />
|
||||||
|
<Text style={styles.expiredText}>{t("storyViewer.expiredOwnerOnly")}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOwnStory && (
|
||||||
|
<View style={[styles.ownerActions, { top: insets.top + 8 }]}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.viewerCount}
|
||||||
|
onPress={() => setOpenSheet("viewers")}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t("storyViewer.viewersTitle", { count: viewers.length })}
|
||||||
|
>
|
||||||
|
<Eye size={16} color={colors.pureWhite} />
|
||||||
|
<Text style={styles.viewerCountText}>{viewers.length}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={styles.deleteButton}
|
||||||
|
onPress={() => confirmDelete(currentStory.id)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t("storyViewer.deleteStory")}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? <ActivityIndicator color={colors.pureWhite} size="small" /> : <Trash2 size={16} color={colors.pureWhite} />}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activePetId && (
|
||||||
|
<View style={[styles.actions, { bottom: insets.bottom + 24 }]}>
|
||||||
|
{currentStory.isExpired ? (
|
||||||
|
<View style={styles.actionWrap} accessibilityLabel={t("storyViewer.pawCount", { count: currentStory.reactionCount })}>
|
||||||
|
<View style={styles.actionCircle}>
|
||||||
|
<PawPrint size={20} color={colors.pureWhite} />
|
||||||
|
</View>
|
||||||
|
{currentStory.reactionCount > 0 && <Text style={styles.actionCount}>{currentStory.reactionCount}</Text>}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<StoryPawButton
|
||||||
|
key={currentStory.id}
|
||||||
|
storyId={currentStory.id}
|
||||||
|
petId={activePetId}
|
||||||
|
initialCount={currentStory.reactionCount}
|
||||||
|
initialReacted={currentStory.reactedByViewer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setOpenSheet("comments")}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t("storyViewer.openComments")}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.actionWrap}
|
||||||
|
>
|
||||||
|
<View style={styles.actionCircle}>
|
||||||
|
<MessageCircle size={20} color={colors.pureWhite} />
|
||||||
|
</View>
|
||||||
|
{currentStory.commentCount > 0 && <Text style={styles.actionCount}>{currentStory.commentCount}</Text>}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<Pressable style={[styles.closeButton, { top: insets.top + 8 }]} onPress={close} hitSlop={12}>
|
<Pressable style={[styles.closeButton, { top: insets.top + 8 }]} onPress={close} hitSlop={12}>
|
||||||
<X color={colors.pureWhite} size={22} />
|
<X color={colors.pureWhite} size={22} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
|
{activePetId && (
|
||||||
|
<StoryCommentsSheet
|
||||||
|
storyId={currentStory.id}
|
||||||
|
activePetId={activePetId}
|
||||||
|
visible={openSheet === "comments"}
|
||||||
|
onClose={() => setOpenSheet(null)}
|
||||||
|
onChanged={refreshStories}
|
||||||
|
onPressPet={openPet}
|
||||||
|
readOnly={currentStory.isExpired}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isOwnStory && (
|
||||||
|
<StoryViewersSheet
|
||||||
|
storyId={currentStory.id}
|
||||||
|
visible={openSheet === "viewers"}
|
||||||
|
onClose={() => setOpenSheet(null)}
|
||||||
|
onPressPet={openPet}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VideoStatusValue = "PROCESSING" | "READY" | "ERROR";
|
||||||
|
|
||||||
|
interface VideoStoryProps {
|
||||||
|
status: VideoStatusValue | null | undefined;
|
||||||
|
playbackId: string | null | undefined;
|
||||||
|
paused: boolean;
|
||||||
|
onProgress: (pct: number) => void;
|
||||||
|
onEnded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt den Verarbeitungs-/Fehlerzustand; der Player wird erst bei READY gemountet (wie VideoPlayer.tsx). */
|
||||||
|
function VideoStory({ status, playbackId, ...playerProps }: VideoStoryProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (status === "READY" && playbackId) {
|
||||||
|
return <ReadyVideoStory playbackId={playbackId} {...playerProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.statusBox}>
|
||||||
|
{status === "ERROR" ? (
|
||||||
|
<>
|
||||||
|
<AlertCircle color={colors.alertRed} size={28} />
|
||||||
|
<Text style={styles.statusLabel}>{t("video.error")}</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ActivityIndicator color={colors.marigoldOrange} />
|
||||||
|
<Text style={styles.statusLabel}>{t("video.processing")}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spielt einmal ab (kein Loop) und springt bei `playToEnd` weiter.
|
||||||
|
* `play()` erst bei `readyToPlay` — vorher ist `duration` 0 und `playToEnd`
|
||||||
|
* feuerte sofort (Viewer schloss sich nach < 1 s, auf Emulator und Gerät
|
||||||
|
* beobachtet). Offenes Sheet pausiert das Video wie den Foto-Timer.
|
||||||
|
*/
|
||||||
|
function ReadyVideoStory({ playbackId, paused, onProgress, onEnded }: Omit<VideoStoryProps, "status" | "playbackId"> & { playbackId: string }) {
|
||||||
|
const player = useVideoPlayer(`https://stream.mux.com/${playbackId}.m3u8`, (p) => {
|
||||||
|
p.loop = false;
|
||||||
|
p.timeUpdateEventInterval = 0.25;
|
||||||
|
});
|
||||||
|
const pausedRef = useRef(paused);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pausedRef.current = paused;
|
||||||
|
if (paused) player.pause();
|
||||||
|
else if (player.status === "readyToPlay") player.play();
|
||||||
|
}, [player, paused]);
|
||||||
|
|
||||||
|
useEventListener(player, "statusChange", ({ status }) => {
|
||||||
|
if (status === "readyToPlay" && !pausedRef.current) player.play();
|
||||||
|
});
|
||||||
|
useEventListener(player, "timeUpdate", ({ currentTime }) => {
|
||||||
|
if (player.duration > 0) onProgress((currentTime / player.duration) * 100);
|
||||||
|
});
|
||||||
|
useEventListener(player, "playToEnd", () => {
|
||||||
|
onProgress(100);
|
||||||
|
onEnded();
|
||||||
|
});
|
||||||
|
|
||||||
|
return <VideoView player={player} style={styles.media} contentFit="contain" nativeControls={false} />;
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
backdrop: { flex: 1, backgroundColor: colors.ink },
|
backdrop: { flex: 1, backgroundColor: colors.ink },
|
||||||
media: { flex: 1, width: "100%" },
|
media: { flex: 1, width: "100%" },
|
||||||
|
statusBox: { flex: 1, width: "100%", alignItems: "center", justifyContent: "center", gap: 8 },
|
||||||
|
statusLabel: { color: colors.pureWhite, fontSize: 14 },
|
||||||
tapZones: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, flexDirection: "row" },
|
tapZones: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, flexDirection: "row" },
|
||||||
tapZoneLeft: { flex: 1 },
|
tapZoneLeft: { flex: 1 },
|
||||||
tapZoneRight: { flex: 2 },
|
tapZoneRight: { flex: 2 },
|
||||||
@@ -145,6 +457,33 @@ const styles = StyleSheet.create({
|
|||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
},
|
},
|
||||||
petName: { color: colors.pureWhite, fontWeight: "600", fontSize: 14 },
|
petName: { color: colors.pureWhite, fontWeight: "600", fontSize: 14 },
|
||||||
|
actions: { position: "absolute", right: 16, alignItems: "center", gap: 16, zIndex: 2 },
|
||||||
|
actionWrap: { alignItems: "center", gap: 2 },
|
||||||
|
actionCircle: { width: 44, height: 44, borderRadius: 22, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
|
||||||
|
actionCount: { color: colors.pureWhite, fontSize: 12, fontWeight: "600", textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 3 },
|
||||||
|
expiredRow: { position: "absolute", left: 0, right: 0, alignItems: "center", zIndex: 2 },
|
||||||
|
expiredBadge: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
},
|
||||||
|
expiredText: { color: colors.pureWhite, fontSize: 12, fontWeight: "600" },
|
||||||
|
ownerActions: { position: "absolute", left: 16, flexDirection: "row", alignItems: "center", gap: 8, zIndex: 2 },
|
||||||
|
deleteButton: { width: 36, height: 36, borderRadius: 18, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
|
||||||
|
viewerCount: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.4)",
|
||||||
|
},
|
||||||
|
viewerCountText: { color: colors.pureWhite, fontSize: 13, fontWeight: "600" },
|
||||||
closeButton: {
|
closeButton: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: 16,
|
right: 16,
|
||||||
|
|||||||
+102
-73
@@ -1,14 +1,16 @@
|
|||||||
import { useMemo, useState } from "react";
|
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 { useSignIn } from "@clerk/expo";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "../../src/components/Button";
|
import { Button } from "../../src/components/Button";
|
||||||
import { TextField } from "../../src/components/TextField";
|
import { KeyRound, Lock, Mail } from "lucide-react-native";
|
||||||
import { PasswordField } from "../../src/components/PasswordField";
|
|
||||||
import { LanguageSwitcher } from "../../src/components/LanguageSwitcher";
|
|
||||||
import { GoogleSignInButton } from "../../src/components/GoogleSignInButton";
|
import { GoogleSignInButton } from "../../src/components/GoogleSignInButton";
|
||||||
import { AppleSignInButton } from "../../src/components/AppleSignInButton";
|
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 { useTheme } from "../../src/context/ThemeContext";
|
||||||
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
|
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
|
||||||
|
|
||||||
@@ -35,6 +37,9 @@ export default function SignInScreen() {
|
|||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [step, setStep] = useState<"credentials" | "verify-device">("credentials");
|
const [step, setStep] = useState<"credentials" | "verify-device">("credentials");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isCredentialError, setIsCredentialError] = useState(false);
|
||||||
|
const { shakeStyle, shake } = useShake();
|
||||||
|
const isBusy = fetchStatus === "fetching";
|
||||||
|
|
||||||
const finalizeIfComplete = async () => {
|
const finalizeIfComplete = async () => {
|
||||||
if (signIn.status !== "complete") {
|
if (signIn.status !== "complete") {
|
||||||
@@ -50,6 +55,8 @@ export default function SignInScreen() {
|
|||||||
const { error: signInError } = await signIn.password({ identifier: emailAddress, password });
|
const { error: signInError } = await signIn.password({ identifier: emailAddress, password });
|
||||||
if (signInError) {
|
if (signInError) {
|
||||||
setError(signInError.longMessage ?? signInError.message);
|
setError(signInError.longMessage ?? signInError.message);
|
||||||
|
setIsCredentialError(true);
|
||||||
|
shake();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +78,7 @@ export default function SignInScreen() {
|
|||||||
const { error: verifyError } = await signIn.mfa.verifyEmailCode({ code });
|
const { error: verifyError } = await signIn.mfa.verifyEmailCode({ code });
|
||||||
if (verifyError) {
|
if (verifyError) {
|
||||||
setError(verifyError.longMessage ?? verifyError.message);
|
setError(verifyError.longMessage ?? verifyError.message);
|
||||||
|
shake();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await finalizeIfComplete();
|
await finalizeIfComplete();
|
||||||
@@ -78,89 +86,110 @@ export default function SignInScreen() {
|
|||||||
|
|
||||||
if (step === "verify-device") {
|
if (step === "verify-device") {
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
<AuthShell>
|
||||||
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
|
<Reveal index={3}>
|
||||||
<View style={styles.form}>
|
<Text style={styles.title}>{t("auth.verifyDeviceTitle")}</Text>
|
||||||
<Text style={styles.title}>{t("auth.verifyDeviceTitle")}</Text>
|
</Reveal>
|
||||||
<Text style={styles.hint}>{t("auth.verifyDeviceHint", { email: emailAddress })}</Text>
|
<Reveal index={4}>
|
||||||
<TextField
|
<Text style={styles.hint}>{t("auth.verifyDeviceHint", { email: emailAddress })}</Text>
|
||||||
autoCapitalize="none"
|
</Reveal>
|
||||||
keyboardType="number-pad"
|
<Animated.View style={shakeStyle}>
|
||||||
placeholder={t("auth.codePlaceholder")}
|
<AuthField
|
||||||
value={code}
|
icon={KeyRound}
|
||||||
onChangeText={setCode}
|
autoCapitalize="none"
|
||||||
/>
|
keyboardType="number-pad"
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
placeholder={t("auth.codePlaceholder")}
|
||||||
<Button title={t("auth.confirmButton")} disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
|
value={code}
|
||||||
</View>
|
onChangeText={setCode}
|
||||||
</ScrollView>
|
/>
|
||||||
</KeyboardAvoidingView>
|
</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 (
|
return (
|
||||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
<AuthShell
|
||||||
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
|
footer={
|
||||||
<View style={styles.form}>
|
<Reveal index={11}>
|
||||||
<LanguageSwitcher />
|
<AuthFooterLink
|
||||||
<Text style={styles.title}>{t("auth.signInTitle")}</Text>
|
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
|
<GoogleSignInButton
|
||||||
onError={setError}
|
onError={setError}
|
||||||
onNeedsConsent={() => setError(t("auth.googleNeedsSignUpError"))}
|
onNeedsConsent={() => setError(t("auth.googleNeedsSignUpError"))}
|
||||||
/>
|
/>
|
||||||
<AppleSignInButton
|
</Reveal>
|
||||||
onError={setError}
|
</AuthShell>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
const typography: Typography = getTypography(colors);
|
const typography: Typography = getTypography(colors);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
flex: { flex: 1, backgroundColor: colors.porchWhite },
|
title: { ...typography.headline, fontSize: 22 },
|
||||||
container: {
|
hint: { ...typography.body, color: colors.quietGray, marginTop: -6, marginBottom: 6 },
|
||||||
flexGrow: 1,
|
fields: { gap: 10 },
|
||||||
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 },
|
|
||||||
error: { ...typography.body, color: colors.alertRed },
|
error: { ...typography.body, color: colors.alertRed },
|
||||||
link: { ...typography.body, color: colors.marigoldDeep, textAlign: "center", marginTop: 8 },
|
submit: { height: 52, borderRadius: 14 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+154
-89
@@ -1,15 +1,16 @@
|
|||||||
import { useMemo, useState } from "react";
|
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 { useSignUp } from "@clerk/expo";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { 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 { GoogleSignInButton } from "../../src/components/GoogleSignInButton";
|
||||||
import { AppleSignInButton } from "../../src/components/AppleSignInButton";
|
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 { useTheme } from "../../src/context/ThemeContext";
|
||||||
import { env } from "../../src/lib/env";
|
import { env } from "../../src/lib/env";
|
||||||
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
|
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
|
||||||
@@ -50,6 +51,12 @@ export default function SignUpScreen() {
|
|||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [step, setStep] = useState<"credentials" | "verify-email">("credentials");
|
const [step, setStep] = useState<"credentials" | "verify-email">("credentials");
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 =
|
const canSubmit =
|
||||||
firstName.trim().length > 0 && lastName.trim().length > 0 && emailAddress.trim().length > 0 && password.length > 0 && legalAccepted;
|
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) {
|
if (signUpError) {
|
||||||
setError(signUpError.longMessage ?? signUpError.message);
|
setError(signUpError.longMessage ?? signUpError.message);
|
||||||
|
shake();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +103,7 @@ export default function SignUpScreen() {
|
|||||||
const { error: verifyError } = await signUp.verifications.verifyEmailCode({ code });
|
const { error: verifyError } = await signUp.verifications.verifyEmailCode({ code });
|
||||||
if (verifyError) {
|
if (verifyError) {
|
||||||
setError(verifyError.longMessage ?? verifyError.message);
|
setError(verifyError.longMessage ?? verifyError.message);
|
||||||
|
shake();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await finalizeIfComplete();
|
await finalizeIfComplete();
|
||||||
@@ -102,112 +111,165 @@ export default function SignUpScreen() {
|
|||||||
|
|
||||||
if (step === "verify-email") {
|
if (step === "verify-email") {
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
<AuthShell>
|
||||||
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
|
<Reveal index={3}>
|
||||||
<View style={styles.form}>
|
<Text style={styles.title}>{t("auth.verifyEmailTitle")}</Text>
|
||||||
<Text style={styles.title}>{t("auth.verifyEmailTitle")}</Text>
|
</Reveal>
|
||||||
<Text style={styles.hint}>{t("auth.verifyEmailHint", { email: emailAddress })}</Text>
|
<Reveal index={4}>
|
||||||
<TextField
|
<Text style={styles.hint}>{t("auth.verifyEmailHint", { email: emailAddress })}</Text>
|
||||||
autoCapitalize="none"
|
</Reveal>
|
||||||
keyboardType="number-pad"
|
<Animated.View style={shakeStyle}>
|
||||||
placeholder={t("auth.codePlaceholder")}
|
<AuthField
|
||||||
value={code}
|
icon={KeyRound}
|
||||||
onChangeText={setCode}
|
autoCapitalize="none"
|
||||||
/>
|
keyboardType="number-pad"
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
placeholder={t("auth.codePlaceholder")}
|
||||||
<Button title={t("auth.confirmButton")} disabled={fetchStatus === "fetching"} onPress={onVerifyPress} />
|
value={code}
|
||||||
</View>
|
onChangeText={setCode}
|
||||||
</ScrollView>
|
/>
|
||||||
</KeyboardAvoidingView>
|
</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 (
|
return (
|
||||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
<AuthShell
|
||||||
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
|
footer={
|
||||||
<View style={styles.form}>
|
<Reveal index={13}>
|
||||||
<LanguageSwitcher />
|
<AuthFooterLink
|
||||||
<Text style={styles.title}>{t("auth.signUpTitle")}</Text>
|
prompt={t("auth.hasAccountPrompt")}
|
||||||
<GoogleSignInButton onError={setError} legalAccepted={legalAccepted} disabled={!legalAccepted} />
|
action={t("auth.signInButton")}
|
||||||
<AppleSignInButton onError={setError} legalAccepted={legalAccepted} disabled={!legalAccepted} />
|
onPress={() => router.replace("/(auth)/sign-in")}
|
||||||
<Text style={styles.divider}>{t("auth.orDivider")}</Text>
|
/>
|
||||||
<TextField
|
</Reveal>
|
||||||
placeholder={t("auth.firstNamePlaceholder")}
|
}
|
||||||
value={firstName}
|
>
|
||||||
onChangeText={setFirstName}
|
<Reveal index={3}>
|
||||||
/>
|
<Text style={styles.title}>{t("auth.signUpHeading")}</Text>
|
||||||
<TextField
|
</Reveal>
|
||||||
placeholder={t("auth.lastNamePlaceholder")}
|
<Reveal index={4}>
|
||||||
value={lastName}
|
<Text style={styles.hint}>{t("auth.signUpHint")}</Text>
|
||||||
onChangeText={setLastName}
|
</Reveal>
|
||||||
/>
|
<Animated.View style={[styles.fields, shakeStyle]}>
|
||||||
<TextField
|
<Reveal index={5} style={styles.nameRow}>
|
||||||
autoCapitalize="none"
|
<View style={styles.nameField}>
|
||||||
keyboardType="email-address"
|
<AuthField
|
||||||
placeholder={t("auth.emailPlaceholder")}
|
icon={User}
|
||||||
value={emailAddress}
|
autoComplete="given-name"
|
||||||
onChangeText={setEmailAddress}
|
placeholder={t("auth.firstNamePlaceholder")}
|
||||||
/>
|
value={firstName}
|
||||||
<PasswordField
|
onChangeText={setFirstName}
|
||||||
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} />}
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.checkboxLabel}>{t("auth.legalConsentLabel")}</Text>
|
<View style={styles.nameField}>
|
||||||
</Pressable>
|
<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}>
|
<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>
|
<Text style={styles.link}>{t("pets.legalTerms")}</Text>
|
||||||
</Pressable>
|
</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>
|
<Text style={styles.link}>{t("pets.legalPrivacy")}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
</Reveal>
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
<Reveal index={9}>
|
||||||
<Button
|
<Button
|
||||||
title={t("auth.signUpButton")}
|
title={t("auth.signUpButton")}
|
||||||
disabled={fetchStatus === "fetching" || !canSubmit}
|
disabled={!canSubmit}
|
||||||
|
isLoading={isBusy}
|
||||||
onPress={onSignUpPress}
|
onPress={onSignUpPress}
|
||||||
|
style={styles.submit}
|
||||||
/>
|
/>
|
||||||
<Pressable onPress={() => router.replace("/(auth)/sign-in")} hitSlop={8}>
|
</Reveal>
|
||||||
<Text style={styles.link}>{t("auth.hasAccountLink")}</Text>
|
<Reveal index={10}>
|
||||||
</Pressable>
|
<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>
|
</View>
|
||||||
</ScrollView>
|
</AuthShell>
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
const typography: Typography = getTypography(colors);
|
const typography: Typography = getTypography(colors);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
flex: { flex: 1, backgroundColor: colors.porchWhite },
|
title: { ...typography.headline, fontSize: 22 },
|
||||||
// flexGrow (not flex) on a ScrollView's contentContainerStyle — still
|
hint: { ...typography.body, color: colors.quietGray, marginTop: -6, marginBottom: 6 },
|
||||||
// centers short content but lets the view actually scroll once the
|
fields: { gap: 10 },
|
||||||
// keyboard pushes content taller than the screen (found.md: password
|
nameRow: { flexDirection: "row", gap: 10 },
|
||||||
// field and submit button were getting hidden behind the keyboard).
|
nameField: { flex: 1 },
|
||||||
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 },
|
|
||||||
error: { ...typography.body, color: colors.alertRed },
|
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 },
|
checkboxRow: { flexDirection: "row", alignItems: "flex-start", gap: 10, marginTop: 4 },
|
||||||
checkbox: {
|
checkbox: {
|
||||||
width: 20,
|
width: 20,
|
||||||
@@ -220,7 +282,10 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
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 },
|
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 } from "expo-router";
|
||||||
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";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Always closes back to /(app), never router.back() — this screen is
|
* No native header: the onboarding draws its own top bar (back, progress,
|
||||||
* reached two ways: OnboardingRedirect's replace() (no meaningful back
|
* close) in pet.tsx. Close always goes to /(app), never router.back() — this
|
||||||
* history) for a zero-pet owner, and the Pets tab's "+" button (real back
|
* screen is reached via OnboardingRedirect's replace() (zero pets, no back
|
||||||
* history) for an existing owner adding another pet. A zero-pet owner who
|
* history) and via the Pets tab's "+" (another pet). A zero-pet owner who
|
||||||
* closes gets bounced right back here by OnboardingRedirect — that's
|
* closes gets bounced right back by OnboardingRedirect — intended: they can't
|
||||||
* correct, not a bug: they can't use the app without a first pet, but they
|
* use the app without a first pet.
|
||||||
* get an obvious "why am I back here" instead of a dead-end screen with no
|
|
||||||
* way to leave at all.
|
|
||||||
*/
|
*/
|
||||||
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() {
|
export default function OnboardingLayout() {
|
||||||
const { t } = useTranslation();
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
+222
-58
@@ -1,87 +1,251 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { ActivityIndicator, FlatList, View } from "react-native";
|
import { ActivityIndicator, BackHandler, KeyboardAvoidingView, StyleSheet, View } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
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 { 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 { useTRPC } from "../../src/api/trpc";
|
||||||
import { useTheme } from "../../src/context/ThemeContext";
|
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:
|
* Onboarding as short steps (web's order kept where it matters): welcome
|
||||||
* community guidelines -> upload-rights/data-processing consent ->
|
* (rules + both declarations) -> name -> species/breed -> bio/story (creates
|
||||||
* Arachnophobie-Helfer opt-in -> pet profile -> "Wie hast du von PawFeed
|
* the pet) -> photo -> spider filter -> "Wie hast du von PawFeed erfahren?"
|
||||||
* erfahren?" + referral code (once per owner) -> avatar photo. Lives as
|
* -> closing moment. Lives as its own top-level route (not under (auth) or
|
||||||
* its own top-level route (not under (auth) or (app)) so app/_layout.tsx's
|
* (app)) so app/_layout.tsx's "zero pets -> redirect here" check doesn't loop.
|
||||||
* "zero pets -> redirect here" check (see OnboardingRedirect) 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() {
|
export default function OnboardingPetScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const { setActivePetId } = useActivePet();
|
const { pets, 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 trpc = useTRPC();
|
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({
|
const { data: referralState } = useQuery({
|
||||||
...trpc.referrals.onboardingState.queryOptions(),
|
...trpc.referrals.onboardingState.queryOptions(),
|
||||||
|
// Only after the pet exists — before that the Owner row may not exist yet.
|
||||||
enabled: !!createdPet,
|
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;
|
const steps: StepId[] = [
|
||||||
if (!guidelinesAcknowledged) {
|
...(needsLegal || needsConsent ? (["welcome"] as const) : []),
|
||||||
content = <OnboardingGuidelines onContinue={() => setGuidelinesAcknowledged(true)} />;
|
"name",
|
||||||
} else if (!uploadConsentGiven) {
|
"species",
|
||||||
content = <UploadConsent onContinue={() => setUploadConsentGiven(true)} />;
|
"about",
|
||||||
} else if (!arachnophobiaStepDone) {
|
"photo",
|
||||||
content = <ArachnophobiaOptIn onContinue={() => setArachnophobiaStepDone(true)} />;
|
// Shown for every new pet: the app has no other place to change it (web: /pets).
|
||||||
} else if (isReferralStateLoading) {
|
...(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 = (
|
content = (
|
||||||
<View style={{ padding: 48, alignItems: "center" }}>
|
<NameStep
|
||||||
<ActivityIndicator color={colors.marigoldOrange} />
|
name={name}
|
||||||
</View>
|
nickname={nickname}
|
||||||
|
onChangeName={setName}
|
||||||
|
onChangeNickname={setNickname}
|
||||||
|
onNext={next}
|
||||||
|
onCaretaker={() => setIsCaretakerPath(true)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
} else if (showAcquisitionStep) {
|
} else if (current === "species") {
|
||||||
content = <AcquisitionStep canRedeem={referralState?.canRedeem ?? false} onDone={() => setAcquisitionStepDone(true)} />;
|
|
||||||
} else if (createdPet) {
|
|
||||||
content = <AvatarStep petId={createdPet.id} petName={createdPet.name} onDone={finish} />;
|
|
||||||
} else {
|
|
||||||
content = (
|
content = (
|
||||||
<PetCreateForm
|
<SpeciesStep
|
||||||
onSuccess={(pet) => {
|
petName={petName}
|
||||||
setActivePetId(pet.id);
|
speciesId={species?.id ?? null}
|
||||||
setCreatedPet(pet);
|
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 (
|
return (
|
||||||
// FlatList, not ScrollView, as the scroll container — kept from the
|
// "padding" on Android too: edge-to-edge means the window no longer resizes for the keyboard.
|
||||||
// original nested-ScrollView fix (see git history) even though
|
<KeyboardAvoidingView style={[styles.flex, { backgroundColor: colors.porchWhite }]} behavior="padding">
|
||||||
// OptionPicker's dropdown now renders in a Modal instead of nesting a
|
<View style={[styles.flex, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
||||||
// ScrollView here, so it's no longer strictly required for that
|
{current !== "done" && !isCaretakerPath ? (
|
||||||
// specific reason; still the established, working scroll container for
|
<OnboardingTopBar count={steps.length} filled={index + 1} onBack={back} onClose={leave} />
|
||||||
// this screen.
|
) : null}
|
||||||
<FlatList
|
<StepSlide key={isCaretakerPath ? "caretaker" : current} direction={direction}>
|
||||||
data={EMPTY_DATA}
|
{content}
|
||||||
renderItem={null}
|
</StepSlide>
|
||||||
contentContainerStyle={{ flexGrow: 1, backgroundColor: colors.porchWhite }}
|
</View>
|
||||||
ListHeaderComponent={<>{content}</>}
|
</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" },
|
||||||
|
});
|
||||||
+2
-1
@@ -43,7 +43,8 @@
|
|||||||
"submit": {
|
"submit": {
|
||||||
"production": {
|
"production": {
|
||||||
"ios": {
|
"ios": {
|
||||||
"appleTeamId": "C72YB42R2D"
|
"appleTeamId": "C72YB42R2D",
|
||||||
|
"ascAppId": "6816366478"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-1
@@ -22,8 +22,9 @@ TODO Neu2:
|
|||||||
→ Zurück-Pfeil war nur unsichtbar (schwarz auf schwarzem Header im Dark Mode), jetzt auf allen Unterseiten sichtbar. Speicherproblem lag am Datumsformat (nächster Punkt) plus fehlender Fehlermeldung bei ungültiger Eingabe — beides behoben, End-to-End getestet.
|
→ Zurück-Pfeil war nur unsichtbar (schwarz auf schwarzem Header im Dark Mode), jetzt auf allen Unterseiten sichtbar. Speicherproblem lag am Datumsformat (nächster Punkt) plus fehlender Fehlermeldung bei ungültiger Eingabe — beides behoben, End-to-End getestet.
|
||||||
- [x] Datum wird Korrekt dargestellt, die eingabe ist aber, aktuelle ansicht JJJJ-MM-DD // korrekt wäre DD-MM-JJJJ
|
- [x] Datum wird Korrekt dargestellt, die eingabe ist aber, aktuelle ansicht JJJJ-MM-DD // korrekt wäre DD-MM-JJJJ
|
||||||
→ Eingabefelder akzeptieren jetzt TT-MM-JJJJ, werden intern korrekt umgewandelt; ungültige Eingaben zeigen eine Fehlermeldung.
|
→ Eingabefelder akzeptieren jetzt TT-MM-JJJJ, werden intern korrekt umgewandelt; ungültige Eingaben zeigen eine Fehlermeldung.
|
||||||
- [ ] Gewichtsdaten werden nicht als Diagramm angezeigt (feature)
|
- [x] Gewichtsdaten werden nicht als Diagramm angezeigt (feature)
|
||||||
→ Echtes Feature, kein Bug — noch offen, Rückmeldung ob/wann gewünscht.
|
→ Echtes Feature, kein Bug — noch offen, Rückmeldung ob/wann gewünscht.
|
||||||
|
→ **Umgesetzt (04.10.):** Neues `WeightChart` (`src/components/health/WeightChart.tsx`, Port des Web-Diagramms mit `react-native-svg`) erscheint ab 2 Einträgen über der Gewichtsliste. Die x-Achse ist zeitproportional (unregelmäßige Wiegetermine werden nicht verzerrt), die y-Achse hat runde Werte, Datumslabels überlappen nicht (bei über einem Jahr „Feb. 25“). Antippen wählt die nächste Messung, Wert + Datum stehen im Kopf; rechts die Veränderung seit der ersten Messung. Linienfarbe als neues Token `chartLine` (#ea580c), mit dem Dataviz-Validator gegen helle und dunkle Flächen geprüft (Kontrast ≥ 3:1). Geometrie in `src/lib/weight-chart.ts` mit 11 Tests; die Darstellung habe ich als Vorschau mit der echten Geometrie in Hell + Dunkel gerendert und geprüft. Commit `e60abba`, OTA Runtime 1.0.6: Android `16d95168-caf2-4c0d-a0c7-730acd1bfb96`, iOS `d8e82f19-2a66-4f21-9558-b16296a7ba3f`. Nicht auf einem Gerät getestet.
|
||||||
- [x] Technische Frage, was wird beim Feed abgerufen? Zum vergleich in der Web umgebung wird mein feed(followerbezogen) und der trend (könnte dir auch gefallen) seperat eingeblendet, wird in der App eine kombination daraus gemacht ? falls ja super.
|
- [x] Technische Frage, was wird beim Feed abgerufen? Zum vergleich in der Web umgebung wird mein feed(followerbezogen) und der trend (könnte dir auch gefallen) seperat eingeblendet, wird in der App eine kombination daraus gemacht ? falls ja super.
|
||||||
→ Nein: Mobile Feed zeigt ausschließlich den chronologischen Follower-Feed (feed.getFeed, mode "chrono"). Web's separater "Trending"-Tab (explore.getTrending, "könnte dir auch gefallen") ist in der App noch gar nicht gebaut — bekannte Lücke, bereits in der README dokumentiert.
|
→ Nein: Mobile Feed zeigt ausschließlich den chronologischen Follower-Feed (feed.getFeed, mode "chrono"). Web's separater "Trending"-Tab (explore.getTrending, "könnte dir auch gefallen") ist in der App noch gar nicht gebaut — bekannte Lücke, bereits in der README dokumentiert.
|
||||||
- [x] unter meine Tiere, bitte Impressum datenschutz und agbs als link einbinden. sowie eine Versionsnummer der App
|
- [x] unter meine Tiere, bitte Impressum datenschutz und agbs als link einbinden. sowie eine Versionsnummer der App
|
||||||
@@ -113,6 +114,7 @@ ErrorRecovery: exception encountered: Error: Missing required env var EXPO_PUBLI
|
|||||||
Neu 7:
|
Neu 7:
|
||||||
- [ ] Datums eingabe unüblich mit - getrennt statt mit . ansicht am Handy wird meist durch die tastatur überblendet. können wir für die mobile eine data wheel picker nutzen ?
|
- [ ] Datums eingabe unüblich mit - getrennt statt mit . ansicht am Handy wird meist durch die tastatur überblendet. können wir für die mobile eine data wheel picker nutzen ?
|
||||||
→ Analyse erledigt, betrifft 4 Dateien (edit.tsx Geburtstag/Adoptionsdatum + 3 Health-Sektionen). Wheel-Picker braucht ein natives Modul (`@react-native-community/datetimepicker`) → kein einzelner Build nur dafür, stattdessen auf `PENDING-NATIVE-BUILD.md` verschoben und wird mit dem nächsten ohnehin anstehenden nativen Build mitgenommen. Der reine Trennzeichen-Fix ("-" → ".") ist davon unabhängig und OTA-fähig, sobald gewünscht.
|
→ Analyse erledigt, betrifft 4 Dateien (edit.tsx Geburtstag/Adoptionsdatum + 3 Health-Sektionen). Wheel-Picker braucht ein natives Modul (`@react-native-community/datetimepicker`) → kein einzelner Build nur dafür, stattdessen auf `PENDING-NATIVE-BUILD.md` verschoben und wird mit dem nächsten ohnehin anstehenden nativen Build mitgenommen. Der reine Trennzeichen-Fix ("-" → ".") ist davon unabhängig und OTA-fähig, sobald gewünscht.
|
||||||
|
→ **Trennzeichen erledigt (04.10.)**, siehe iOS-Abschnitt unten (`DateField`, TT.MM.JJJJ). Wheel-Picker weiter offen (nativer Build).
|
||||||
- [x] Ein User bemängelt, das die Regisrierung gut ist, aber beim eingeben des Passworts diese hinter der "Bildschrimtastatur" verschwindet, ausserdem gibt es keine Möglichkeit für den User die korrekte eingabe nochmal zu prüfen.
|
- [x] Ein User bemängelt, das die Regisrierung gut ist, aber beim eingeben des Passworts diese hinter der "Bildschrimtastatur" verschwindet, ausserdem gibt es keine Möglichkeit für den User die korrekte eingabe nochmal zu prüfen.
|
||||||
→ Beides im Code bestätigt (`app/(auth)/sign-up.tsx`, gleiches Muster auch in `sign-in.tsx` gefunden und mitbehoben): (1) Der Screen nutzte nur ein einfaches `View` als äußeren Container, kein `ScrollView`/`KeyboardAvoidingView` — auf kleinen Screens schob sich die Tastatur einfach über die unteren Felder. Fix: äußerer Container jetzt `KeyboardAvoidingView` (iOS: `padding`) + `ScrollView` (`keyboardShouldPersistTaps="handled"`, `contentContainerStyle` mit `flexGrow` statt `flex`, damit weiterhin zentriert bleibt aber scrollbar wird). (2) Neue geteilte `PasswordField`-Komponente (`src/components/PasswordField.tsx`) mit Auge/Auge-durchgestrichen-Toggle (lucide `Eye`/`EyeOff`) ersetzt das nackte `secureTextEntry`-`TextField` in beiden Screens. Kein natives Modul, OTA-fähig. Typecheck sauber (Mobile).
|
→ Beides im Code bestätigt (`app/(auth)/sign-up.tsx`, gleiches Muster auch in `sign-in.tsx` gefunden und mitbehoben): (1) Der Screen nutzte nur ein einfaches `View` als äußeren Container, kein `ScrollView`/`KeyboardAvoidingView` — auf kleinen Screens schob sich die Tastatur einfach über die unteren Felder. Fix: äußerer Container jetzt `KeyboardAvoidingView` (iOS: `padding`) + `ScrollView` (`keyboardShouldPersistTaps="handled"`, `contentContainerStyle` mit `flexGrow` statt `flex`, damit weiterhin zentriert bleibt aber scrollbar wird). (2) Neue geteilte `PasswordField`-Komponente (`src/components/PasswordField.tsx`) mit Auge/Auge-durchgestrichen-Toggle (lucide `Eye`/`EyeOff`) ersetzt das nackte `secureTextEntry`-`TextField` in beiden Screens. Kein natives Modul, OTA-fähig. Typecheck sauber (Mobile).
|
||||||
- [x] Ein anderer User, meinte wäre es nicht schöner und aufgeräumter wenn die App Settings nicht unter MyPets stehen würden sondern einen eigenen Tab erhalten.
|
- [x] Ein anderer User, meinte wäre es nicht schöner und aufgeräumter wenn die App Settings nicht unter MyPets stehen würden sondern einen eigenen Tab erhalten.
|
||||||
@@ -139,10 +141,83 @@ Neu 7:
|
|||||||
**Stolperstein beim Publish:** Das lokale (gitignorete) `mobile/android/`-Verzeichnis vom Device-Test der letzten Session ließ `eas update` das Projekt fälschlich als "Bare Workflow" erkennen (`CommandError: runtime version policies are not supported` statt der konfigurierten `"policy": "appVersion"`) — Fix war, `android/` kurz umzubenennen/wegzuschieben, Update zu publishen, danach zurückzubenennen. Für künftige OTA-Publishes nach einem lokalen `expo run:android`-Test: dasselbe Problem erwarten.
|
**Stolperstein beim Publish:** Das lokale (gitignorete) `mobile/android/`-Verzeichnis vom Device-Test der letzten Session ließ `eas update` das Projekt fälschlich als "Bare Workflow" erkennen (`CommandError: runtime version policies are not supported` statt der konfigurierten `"policy": "appVersion"`) — Fix war, `android/` kurz umzubenennen/wegzuschieben, Update zu publishen, danach zurückzubenennen. Für künftige OTA-Publishes nach einem lokalen `expo run:android`-Test: dasselbe Problem erwarten.
|
||||||
- [x] Im Pet-Profil ist der Erste Post nicht Klickbar da er in die Gesten-Steuerung rein ragt.
|
- [x] Im Pet-Profil ist der Erste Post nicht Klickbar da er in die Gesten-Steuerung rein ragt.
|
||||||
→ Ursache: Android läuft edge-to-edge, und die Stack-Screens (ohne Tab-Leiste darunter) hatten unten keinen Safe-Area-Abstand — der letzte Inhalt lag unter der Gesten-Leiste. Fix: neuer Hook `src/hooks/useBottomSafePadding.ts` (paddingBottom = 16 + insets.bottom), eingebaut in Tierprofil, Follower, Folgt, Gesundheit, Meilensteine, Profil bearbeiten, Einstellungen, Suche, Nachrichten-Liste und Konto löschen. Commit `de237f1`, per OTA für Android veröffentlicht (Update-Group `4dc6f797`), noch nicht am Gerät bestätigt. Gilt automatisch auch für iOS (Home-Indicator).
|
→ Ursache: Android läuft edge-to-edge, und die Stack-Screens (ohne Tab-Leiste darunter) hatten unten keinen Safe-Area-Abstand — der letzte Inhalt lag unter der Gesten-Leiste. Fix: neuer Hook `src/hooks/useBottomSafePadding.ts` (paddingBottom = 16 + insets.bottom), eingebaut in Tierprofil, Follower, Folgt, Gesundheit, Meilensteine, Profil bearbeiten, Einstellungen, Suche, Nachrichten-Liste und Konto löschen. Commit `de237f1`, per OTA für Android veröffentlicht (Update-Group `4dc6f797`), noch nicht am Gerät bestätigt. Gilt automatisch auch für iOS (Home-Indicator).
|
||||||
|
- [x] Nutzer-Feedback: Nach dem Posten einer Story erscheint man selbst nicht als Ring — man kann die eigene Story nicht ansehen.
|
||||||
|
→ Ursache (Web **und** App gleich): Die Story-Leiste zeigt nur die „+“-Kachel und Storys gefolgter Tiere (`follows.getPetsWithActiveStories`); das eigene aktive Tier ist darin nie enthalten. Fix: zusätzlicher Ring „Deine Story“ direkt nach der „+“-Kachel, solange das aktive Tier eine aktive Story hat (`stories.hasActiveStory`, wird nach dem Posten schon invalidiert). Web: `src/components/stories/StoryTray.tsx` (öffnet den StoryViewer — dort sieht der Owner auch die Zuschauerliste); App: `src/components/StoryTray.tsx`. Commit `204d5f6`, live (Web + OTA Android `58e99a29`, iOS `2aaa3975`). Typecheck/Tests/Build grün; die 2 Lint-Fehler „Cannot access refs during render“ in der Web-StoryTray bestanden schon vorher (horizontales Scrollen).
|
||||||
|
**Story-Kommentare:** Backend (`stories.addComment/listComments/deleteComment`, `toggleReaction`, `getViewers`) und Web-UI (`StoryCommentsOverlay`) existieren bereits — nur der App-Viewer (`app/(app)/story-viewer.tsx`) hat Kommentare, Reaktionen und Zuschauerliste beim Portieren bewusst ausgelassen. Offen als eigener Punkt.
|
||||||
|
- [x] Story-Kommentare, Pfoten-Reaktionen und Zuschauerliste in der App (Folge-Punkt zum Story-Feedback).
|
||||||
|
→ Neuer App-Viewer (`app/(app)/story-viewer.tsx`) mit Pfote (`src/components/stories/StoryPawButton.tsx`), Kommentar-Bottom-Sheet (`StoryCommentsSheet.tsx`, Schreiben + eigene löschen) und — nur bei der eigenen Story — Zuschauerliste (`StoryViewersSheet.tsx`, Augen-Symbol oben links). Solange ein Sheet offen ist, pausiert der Story-Timer (anders als im Web, damit die Story beim Tippen nicht weiterspringt). Nutzt die vorhandenen Server-Prozeduren; einzige Server-Änderung: `stories.listActive` nimmt optional `viewerPetId` und liefert `reactedByViewer`, damit die Pfote gefüllt startet, wenn man schon reagiert hat — abwärtskompatibel, und der Web-Viewer nutzt es jetzt auch (bekannte Einschränkung "Pfote startet immer leer" behoben). Tests in `src/__tests__/stories.test.ts`, alle 268 Tests + Build + App-Typecheck grün. Nicht auf einem Gerät getestet; Tastaturverhalten im Kommentar-Sheet (Android `KeyboardAvoidingView` behavior "height" im Modal) bitte bei Testern beobachten. Weiterhin offen: keine Benachrichtigung bei Story-Kommentaren/-Pfoten (Notification hat keine `storyId`, bräuchte Schemaänderung); Video-Storys spielt der App-Viewer weiterhin nicht ab. Commit `ccebfa4`, live (Web + OTA Runtime 1.0.6: Android `a3941052-a3ec-4c3c-a875-24ad3dcd1c98`, iOS `5e855c5b-f6db-44d0-8350-6b0b91308804`).
|
||||||
-
|
-
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
!!! iOS - Founds beginnen hier - nicht mit Android vertauschen !!!
|
!!! iOS - Founds beginnen hier - nicht mit Android vertauschen !!!
|
||||||
|
|
||||||
|
- [x] Nutzer-Feedback: Storys und Beiträge mit Bildern im HEIF/HEIC-Format (Apple) lassen sich nicht erstellen.
|
||||||
|
→ **Kein reines Serverproblem.** Der Server nimmt bewusst nur JPEG/PNG/WebP an (`ALLOWED_CONTENT_TYPES` in `src/lib/supabase-storage.ts`), und die Bytes gehen direkt vom Client zu Supabase — umgewandelt wurde HEIC nirgends. Zwei Fundstellen:
|
||||||
|
1. **App, Beiträge:** `app/(app)/post-new.tsx` filterte gewählte Fotos nach ihrem *Original*-MIME-Typ (`image/jpeg|png|webp`) und warf `image/heic`/`image/heif` still raus — obwohl `resizeForUpload()` danach sowieso jedes Bild in JPEG umwandelt. Storys/Meilensteine/Avatar in der App filtern nicht und waren nicht betroffen.
|
||||||
|
2. **Web, alle Bild-Uploads** (Beiträge, Storys, Meilensteine, Tierbild): nur JPEG/PNG/WebP erlaubt; ein `.heic` vom Mac oder eine rohe HEIC-Datei aus iOS-Safari (Storys nutzen `accept="image/*,video/*"`, das Safaris automatische HEIC→JPEG-Umwandlung aushebelt) wurde abgelehnt.
|
||||||
|
Fix: App — HEIC/HEIF in `post-new.tsx` zugelassen, plus Hinweis-Alert, falls doch einmal Bilder übersprungen werden (`post.unsupportedFormat`). Web — neues `src/lib/heic-to-jpeg.ts` wandelt HEIC vor dem Upload per Browser-Dekodierung (Canvas → JPEG 0,9) um, eingebaut in `MultiImageUpload`, `StoryForm`, `MilestoneForm`, `AvatarUpload`; `accept` um HEIC ergänzt, damit der Mac-Dateidialog sie anbietet. Bewusst **ohne** Zusatzbibliothek (heic-to: LGPL-3.0, ~26 MB; heic2any: seit 2023 ungepflegt): Safari (iOS 17+/macOS 14+) dekodiert HEIC selbst; Browser ohne HEIC-Support (z. B. Chrome/Windows) bekommen eine klare Meldung „Öffne PawFeed in Safari oder speichere das Foto als JPG“ (`heicUnsupported`). Tests in `src/__tests__/heic-to-jpeg.test.ts`, alle 266 Tests + Build grün. Nicht auf einem echten iPhone getestet. Commit `1bf0f92`, Web per Rolling-Deploy live, OTA Runtime 1.0.6: Android `1b4baa80-a35a-49b7-ac6e-f44ebe93b4db`, iOS `c31382ee-83b7-4a81-a06d-1a2e53180d7d`. **Vom Nutzer bestätigt (02.10.2026): funktioniert.**
|
||||||
|
|
||||||
|
- [x] Nutzerfeedback - Story löschbar ? wie kann man die story löschen, falls man mal was falsches in die story geladen hat ?
|
||||||
|
→ Gab es bisher nirgends. Neu: Server `stories.delete` (nur Owner; löscht Aufrufe — `StoryView` hat bewusst kein Cascade —, dann die Story, Pfoten/Kommentare per Cascade; danach Bild in Supabase Storage bzw. Mux-Asset best-effort). Web: Papierkorb neben der Zuschauerzahl in der eigenen Story + Rückfrage (`AlertDialog`), Bild-Timer pausiert während der Rückfrage, gelöscht wird die beim Antippen gemerkte Story-ID. App: gleicher Papierkorb + native Rückfrage, Timer pausiert. Nach dem Löschen springt der Viewer zur nächsten Story bzw. schließt, wenn keine mehr da ist. 5 neue Tests in `src/__tests__/stories.test.ts`. Nicht auf einem Gerät getestet. Commit `81d9c6d`, live (Web + OTA Runtime 1.0.6: Android `49757586-c443-4952-a679-c66a4225ef82`, iOS `48d22b11-abc9-4c75-be52-a122b0130c5e`).
|
||||||
|
- [x] Storys nur für Ersteller länger einsehbar wegen den Kommentaren (7Tage)
|
||||||
|
→ **Umgesetzt (02.10.):** Der Ersteller sieht seine Storys 7 Tage lang (danach löscht der Aufräum-Job sie), alle anderen weiterhin nur 24 Stunden. Server: `stories.listActive` liefert dem Owner die Storys der letzten 7 Tage mit `isExpired`; `stories.hasActiveStory` meldet mit `includeOwnRecent` (nur die Story-Leiste setzt das) einen grauen Ring `expiredOnly`, damit Profilbilder im Feed nicht ebenfalls grau umrandet werden. Web + App: grauer Ring „Deine Story“, Hinweis „Abgelaufen – nur für dich sichtbar“ im Viewer, Kommentare bei abgelaufenen Storys nur lesbar, Pfote nur als Zähler; Zuschauerliste und Löschen bleiben. Keine Schemaänderung. 4 neue Tests. Nicht auf einem Gerät getestet. Commit `2c0680c`, live (Web + OTA Runtime 1.0.6: Android `5b324ee8-4821-4ca5-b6e7-6a02635a41f7`, iOS `a430ccd0-f2bb-452f-b3cd-45e94910901b`).
|
||||||
|
→ Noch offen. Befund: abgelaufene Storys werden nie gelöscht (Einträge, Bilder, Mux-Videos bleiben liegen) — Vorschlag: grauer Ring "Deine Story" 7 Tage für den Ersteller, danach Aufräum-Cron (braucht einen Auslöser auf dem NAS, die bestehenden Cron-Routen haben keinen) + Satz unter "Speicherdauer" in der Datenschutzerklärung. **Teil erledigt (02.10.):** Aufräum-Job gebaut — `/api/cron/cleanup-stories` löscht Storys älter als 7 Tage komplett (Aufrufe, Pfoten, Kommentare, Bild, Mux-Video; gemeinsame Funktion `src/lib/story-cleanup.ts`, auch von „Story löschen“ genutzt), täglich 04:30 über die bestehende NAS-Crontab (`docker/run-cron.sh`, Log in `docker/cron.log`); Datenschutzerklärung Abschnitt 6 ergänzt. Offen bleibt nur die 7-Tage-Ansicht für den Ersteller.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- [x] Video-Storys in der App (Upload + Wiedergabe)
|
||||||
|
→ **Umgesetzt (03.10.):** Branch `feature/mobile-video-stories` (Gitea #37, 20.09. auf Emulator + Gerät getestet) auf den seit 02.10. umgebauten Story-Viewer portiert. Galerie-Picker nimmt Fotos und Videos (max. 30 s, Prüfung vor dem Upload), Upload direkt zu Mux über `stories.createVideoUpload`; Viewer spielt READY-Videos über `expo-video` ab (Fortschritt aus `timeUpdate`, weiter bei `playToEnd`, `play()` erst bei `readyToPlay`), zeigt „wird verarbeitet“/Fehler und pausiert das Video, solange Kommentare/Zuschauer/Löschen offen sind. Nur JS (`expo-video` ist seit dem 1.0.6-Build nativ drin). App-Typecheck grün; der Port selbst (Pause bei offenem Sheet) **nicht auf einem Gerät getestet**. Kamera nimmt weiterhin nur Fotos auf (Video-Aufnahme bräuchte Mikrofon-Berechtigung = neuer Build). Commit `69e2c13`, OTA Runtime 1.0.6: Android `665f2bf0-7e19-4b38-a07f-5da03459a55c`, iOS `963e721c-3c7c-4d45-b08a-b3c49969e5a2`.
|
||||||
|
- [x] Nutzer-Feedback: Beim Video-Story-Upload sieht man nur einen Spinner — Fortschritt nicht erkennbar.
|
||||||
|
→ **Umgesetzt (03.10.):** Beim Video zuerst „Upload wird vorbereitet …“, dann Fortschrittsbalken mit Prozent („Video wird hochgeladen … 45 %“) aus `File.upload` `onProgress` (expo-file-system 57.0.7, nativ im 1.0.6-Build — Android meldet alle 100 ms). Vorschau während des Uploads gesperrt. Fotos behalten den Spinner (Upload dauert dort nur Sekundenbruchteile). App-Typecheck grün, nicht auf einem Gerät getestet. Commit `5606a81`, OTA Runtime 1.0.6: Android `000908ec-4aac-4bc3-80c7-a86ea438d1c2`, iOS `cd0481b9-84d3-4734-987b-c1b418d51c61`.
|
||||||
|
|
||||||
|
- [x] Feedback kam über iOS, betrifft aber das System, bei Impfungen die man anlegt, sollte ein Wiederholungstermin nach dem Abklingen der Impfung eingebunden werden, mit einem Reminder -1Monat vor ablauf damit man einen neuen Termin vereinbaren kann zum Impfen.
|
||||||
|
→ **Umgesetzt und live (04.10.).** Den Folgetermin gab es schon (`Vaccine.nextDueDate`, Web + App), es fehlte nur die Erinnerung. Neu: täglicher Cron `/api/cron/vaccine-reminders` (`src/lib/vaccine-reminders.ts`) erinnert **einmalig 30 Tage vor** dem Folgetermin per Mitteilung `VACCINE_DUE` + Push („Die Impfung „Tollwut“ für Bubi ist am 01.11.2026 fällig …“). Bereits überfällige Termine werden nicht nachträglich erinnert (die zeigt die Gesundheitsseite als „überfällig“). Schema: `NotificationType.VACCINE_DUE`, `Notification.vaccineId` (Cascade), `Vaccine.reminderSentAt` (Schutz gegen doppelten Versand, wird bei Fehler wieder freigegeben). Web + App zeigen die Mitteilung mit 💉, Tippen öffnet die Gesundheitsseite. App: Schnellauswahl „+1 Jahr / +3 Jahre“ für den Folgetermin plus Hinweis „Mit Folgetermin erinnern wir dich 30 Tage vorher“. 8 neue Tests, Typecheck Web + App grün. Commit `c74bbff`, App-Teil per OTA Runtime 1.0.6: Android `64144379-e335-45ca-bb05-bc12155da54c`, iOS `b8f31fed-aa02-41a2-97c3-3707d02857ce`. Schema per `db push` auf dem NAS (rein additiv), Rolling-Deploy (`7ab74f8`; der erste Versuch brach ab, weil ein Test aus `src/__tests__` nach `mobile/` importierte, das im Docker-Build fehlt — Test liegt jetzt in `mobile/src/lib/date-format.test.ts`), Crontab täglich 08:15. Testaufruf: HTTP 200, `sent: 0` (derzeit nichts fällig). Der echte Versand inkl. Push ist noch nicht mit einer fälligen Impfung beobachtet, die App nicht auf einem Gerät getestet.
|
||||||
|
- [x] Feedback iOS Datums eingabe läuft noch mittels - Trennung statt . ( option für iOS - Data Wheel Picker)
|
||||||
|
→ **Trennzeichen umgesetzt (04.10.):** Neue `DateField`-Komponente (`src/components/DateField.tsx`) für alle Datumsfelder (Geburtstag, Adoptionstag, Gewicht, Tierarzt, Impfungen): Zifferntastatur, die Punkte setzt die App beim Tippen selbst (TT.MM.JJJJ; die iOS-Zifferntastatur hat keinen Punkt). Ältere Eingaben mit „-“ oder „/“ und einstellige Tage/Monate werden weiter akzeptiert. Tests in `src/__tests__/mobile-date-format.test.ts`. Commit `c74bbff`, OTA wie oben. Nicht auf einem Gerät getestet. **Wheel-Picker weiterhin offen** — braucht ein natives Modul, steht in `PENDING-NATIVE-BUILD.md` für den nächsten Build.
|
||||||
|
- [x] Nutzer-Feedback iOS (Screenshot `feedback-ios/WhatsApp Image 2026-10-04 at 12.50.58.jpeg`): Beim Antippen eines Datumsfelds in „Profil bearbeiten“ verdeckt die Tastatur das Feld, die Eingabe ist nicht zu sehen.
|
||||||
|
→ **Behoben (04.10.):** Der Screen hatte keinen Tastatur-Ausgleich (nur `FlatList`, kein `KeyboardAvoidingView`). Neu: `automaticallyAdjustKeyboardInsets` (iOS: unterer Inset in Tastaturhöhe **und** automatisches Scrollen zum aktiven Feld, im RN-Quellcode `RCTScrollViewComponentView.mm` geprüft) plus `keyboardShouldPersistTaps="handled"`. Gleiche Lücke auch in Gesundheit (Gewicht/Impfung/Tierarzt), Neuer Beitrag, Neuer Meilenstein, Tier anlegen/Onboarding und Einladen gefunden und mitbehoben. Android unverändert (Prop gilt nur für iOS, dort nicht gemeldet). Typecheck sauber. Commit `333ba16`, OTA nur iOS Runtime 1.0.6 `718ede5a-91c4-4e4e-b444-174ab3af66a2`. Nicht auf einem Gerät getestet.
|
||||||
|
- [x] Feedback iOS - Rassen suche ist als Dropdown, ohne Suchfunktion. Bei Hunden und Katzen ist die Liste lang, die Suche würde verkürzt werden, wenn man eintippen könnte um das Dropdown zu kürzen.
|
||||||
|
→ **Umgesetzt (04.10.):** `OptionPicker` zeigt ab 10 Einträgen oben ein Suchfeld („Suchen …“), das beim Tippen filtert — ohne Groß-/Kleinschreibung und Akzente („frise“ findet „Bichon Frisé“, `src/lib/filter-options.ts` + Tests). Öffnet sich die Tastatur, rückt die Liste nach oben, damit sie nicht verdeckt wird. Wirkt in Tier anlegen, Profil bearbeiten und Explore-Filter; die kurze Tierarten-Liste bleibt ohne Suchfeld. Typecheck sauber. Commit `01459c7`, OTA Runtime 1.0.6: Android `02f677cc-406b-4475-9601-e61b8a4b963a`, iOS `708be8d6-9786-44ad-a3c9-c305a3f93dd2`. Nicht auf einem Gerät getestet — bitte besonders das Hochrücken über der Tastatur prüfen.
|
||||||
|
- [x] Feature: Storie-Edit Modus, mittels Overlays eigene Storys hübsch machen, durch Texteingaben in das Bild oder Effekte oder Gifs. (Analog zu Instagram) ->Branch
|
||||||
|
→ **Umgesetzt und live (04.10.)** als Story-Editor (Branch `feature/story-editor`, nach `main` gemergt): In der App nach Foto/Video oder als **Text-Story auf Farbverlauf**: Text (3 Stile, 8 Farben, 2 Schriften), **@-Markierungen** anderer Tiere, **GIFs** (GIPHY, nur jugendfrei), **8 Sticker** — verschieben, mit zwei Fingern zoomen/drehen, zum Löschen auf den Papierkorb ziehen. Markieren richtet sich nach „Wer darf die Follower sehen?“ (Alle / nur Follower / niemand), markierte Tiere und Story-Besitzer bekommen Mitteilungen (Markierung, Kommentar, Pfote). Web zeigt alles an, erstellt aber nicht. Overlays sind Daten über dem Bild (nicht eingerechnet), daher alles per OTA, kein Store-Build. **Notschalter** im Admin-Dashboard („Story-Editor“), blendet Editor und Overlays ohne Update aus. 20 neue Tests, alle 376 grün, Produktions-Build ok, gerenderte Vorschau geprüft. db push + RLS, Rolling-Deploy, OTA Runtime 1.0.6: Android `8a1a12cb-d61a-4d14-91ab-391396781d87`, iOS `fe114163-abea-4ccc-ad19-cd1f8ccc7532`. **Nicht am Gerät getestet** — besonders Zwei-Finger-Gesten und Tastatur im Texteditor prüfen.
|
||||||
|
|
||||||
|
- [x] Admin-Dashboard -> Zurückgewiesene Reports, als Meldung an den Melder mit einer Begründung warum abgewiesen wurde. Sowie Meldung bei bestätigter Report löschung, z.b mit Danksagung für die Meldung.
|
||||||
|
→ **Umgesetzt und live (04.10.), als Team-Nachricht.** Admin → Reports: „Report ignorieren“ hat jetzt ein Feld „Begründung für den Melder“; der Melder bekommt vom PawFeed-Team „Danke für deine Meldung zum Beitrag/Profil von X … kein Verstoß … Begründung: …“ (Begründung steht zusätzlich im Moderations-Log). „Post löschen“ dankt jedem Melder des Beitrags einmal („… Inhalt entfernt. Danke, dass du hilfst …“); die interne Lösch-Begründung geht bewusst nicht an den Melder. Beides per Schalter im Dialog abschaltbar, Versand erst nach erfolgreicher Moderation und ohne sie bei einem Fehler zu kippen. **Nebenbei behoben:** Beim Löschen eines gemeldeten Beitrags blieben dessen Reports verwaist in der Liste (`targetPostId` ist keine Relation) — werden jetzt mitgelöscht. 7 neue Tests (`admin-report-feedback.test.ts`), alle 336 Tests grün. Commit `f51b158`, Rolling-Deploy auf dem NAS. Kein App-Update nötig. Im Live-Admin noch nicht mit einem echten Report ausprobiert.
|
||||||
|
|
||||||
|
|
||||||
|
- [x] Feedback - iOS & Andriod, Story-Bilder und Videos mit Overlays bearbeiten direkt in der App. Text hinzufügen, Gifs einbinden, Markierungen auf andere Pets setzten (Analog zu Instagram) bitte als neues Branch anlegen.
|
||||||
|
→ **Umgesetzt und live (04.10.)** als Story-Editor (Branch `feature/story-editor`, nach `main` gemergt): In der App nach Foto/Video oder als **Text-Story auf Farbverlauf**: Text (3 Stile, 8 Farben, 2 Schriften), **@-Markierungen** anderer Tiere, **GIFs** (GIPHY, nur jugendfrei), **8 Sticker** — verschieben, mit zwei Fingern zoomen/drehen, zum Löschen auf den Papierkorb ziehen. Markieren richtet sich nach „Wer darf die Follower sehen?“ (Alle / nur Follower / niemand), markierte Tiere und Story-Besitzer bekommen Mitteilungen (Markierung, Kommentar, Pfote). Web zeigt alles an, erstellt aber nicht. Overlays sind Daten über dem Bild (nicht eingerechnet), daher alles per OTA, kein Store-Build. **Notschalter** im Admin-Dashboard („Story-Editor“), blendet Editor und Overlays ohne Update aus. 20 neue Tests, alle 376 grün, Produktions-Build ok, gerenderte Vorschau geprüft. db push + RLS, Rolling-Deploy, OTA Runtime 1.0.6: Android `8a1a12cb-d61a-4d14-91ab-391396781d87`, iOS `fe114163-abea-4ccc-ad19-cd1f8ccc7532`. **Nicht am Gerät getestet** — besonders Zwei-Finger-Gesten und Tastatur im Texteditor prüfen.
|
||||||
|
|
||||||
|
- [x] Admin-Dashboard - Tiere zeigt aktuell neben dem Namen nur einen Rang an, zur besseren übersicht sollten alle erlangten Ränge angezeigt werden in der Liste.
|
||||||
|
→ **Umgesetzt und live (04.10.):** Die Liste zeigte nur den höchsten Rang (Cache `referralRankId`) und nur mit Vorteil NAME_BADGE. Jetzt lädt `petAdmin.list` alle Rang-Einträge des Besitzers (nach Schwelle sortiert) und zeigt jedes Rangsymbol (`AchievedRankBadges`), unabhängig von NAME_BADGE. Entzogene Ränge erscheinen ausgegraut, Tooltip mit „manuell vergeben“ / „entzogen“. Commit `dbc416c`, Rolling-Deploy.
|
||||||
|
|
||||||
|
- [x] Feedback - iOS, wenn man Nachrichten erhält bekommt man zwar eine Benachrichtung aber in der App wird bei der Sprechblase kein Indicator angezeigt z.b. ein Orangener Punkt der auf eine ungelesene nachricht hindeutet.
|
||||||
|
→ **Umgesetzt (04.10.):** Die Sprechblase oben zeigt jetzt wie die Glocke einen orangen Zähler mit der Zahl ungelesener Nachrichten (`conversations.unreadCount`, gab es serverseitig schon). Aktualisiert sich alle 30 s, solange die App offen ist, und verschwindet nach dem Lesen des Chats. Commit `2bd4b2d`, OTA Runtime 1.0.6: Android `70b064d1-3424-44af-b834-7e77a60475e6`, iOS `e0993ecc-0961-4270-8876-a999e8339ba3`. Nicht auf einem Gerät getestet.
|
||||||
|
|
||||||
|
- [x] Testuser hat Goldene Pfote als Rang erhalten, im Rang habe ich Profil_RING mit eingesetzt damit sich der Profil-Ring mit färbt. aber das passiert nicht.
|
||||||
|
→ **Ursache:** Kein Datenfehler — der Testnutzer („Krypton“) hatte PROFILE_RING korrekt im Cache. Der Ring war bewusst **nur im Tierprofil oben** zu sehen, nicht im Feed oder an Storys. **Auf Wunsch erweitert und live (04.10.):** (1) Ring in Rangfarbe jetzt **überall am Tierbild** — Feed, Kommentare, Suche, Explore, Nachrichten, Mitteilungen, Follower-Listen, Meilenstein-/Repost-Karten, Story-Kommentare/-Zuschauer (Web + App). Dafür liefert die gemeinsame Tier-Abfrage (`petIdentitySelect`) die Rangdaten jetzt mit. Der Ring liegt außen um das Bild, Listen verrutschen nicht. (2) **Story-Ring:** Bei ungesehener Story leuchtet die Pfote in der Rangfarbe statt Orange; ohne Story gibt es einen schlichten Ring in Rangfarbe; gesehene Storys bleiben grau. 6 neue Tests, alle 353 Tests grün. Commit `c7c1473`, Rolling-Deploy + OTA (wie oben). Nicht auf einem Gerät getestet.
|
||||||
|
|
||||||
|
- [x] Prüfung ob es möglich wäre, wenn ein Haushalt mit 2 Accounts, aber dem gleichen Tier. sich beide um das gleich tier kümmern können??? reiner research auf machbarkeit. pro / kontra liste erstellen
|
||||||
|
→ **Research erledigt (04.10.)** — ausführlich in Obsidian: `PawFeed/Punkte/Gemeinsame Tierhalter – Machbarkeit`. Kurz: Heute hat ein Tier genau einen Account (`Pet.ownerId`). Möglich wären (A) gemeinsamer Login — geht schon, aber Push nur aufs zuletzt angemeldete Handy; (B) echte Mitbetreuer mit eigenen Accounts — passt gut zum tierzentrierten Konzept, ist aber ein großer Umbau (zentrale Rechteprüfung + ~22 Dateien, Einladungsablauf, Regeln für Empfehlungsrang, Gesundheitsdaten/DSGVO, Konto-Löschung), grob 4–6 Tage, ohne Store-Build; (C) Tier übertragen — klein, löst die Frage aber nicht. Empfehlung: erst „Push an mehrere Geräte“ bauen (½–1 Tag, hilft sofort), B nur bei häufigerem Wunsch und nach Klärung der offenen Regeln.
|
||||||
|
|
||||||
|
- [x] Tester-Feedback 04.10. zum Story-Editor: **Android** — Farbwahl im Texteditor liegt im Gesten-/Schutzbereich. **iOS** — „Text-Story“ lässt sich nicht öffnen (stattdessen „klickt es auf das X“), Foto aus der Galerie: alles verschwindet, das X leuchtet kurz auf.
|
||||||
|
→ **Behoben (04.10.):** Ursache iOS: Der Editor lief im Auswahl-Screen „Neue Story“, der auf iOS ein Sheet ist — beim Öffnen wurde dessen Kopfzeile ausgeblendet (das aufleuchtende X) und zusätzlich ein natives Fenster (Texteditor) eingeblendet, was iOS während einer laufenden Präsentation verweigert. Jetzt eigener Vollbild-Screen `story-editor` (ohne Kopfzeile, ohne Wisch-zum-Schließen), Texteditor und Auswahlfenster sind Ebenen statt nativer Fenster (`EditorLayer`, Android-Zurück schließt sie). Android: echte Safe-Area-Abstände oben/unten. Neu: Tippen auf eine freie Stelle öffnet den Texteditor. **Am Android-Emulator durchgeklickt** (Text-Story, Texteditor, Verschieben, GIF-Suche live, Sticker, X zurück) — iOS hier nicht testbar, bitte am iPhone prüfen. Commit `6d31367`, OTA Runtime 1.0.6: Android `da1a187e-5032-4e4e-8401-43ba167e9fd5`, iOS `c90c270f-8c89-42fa-ae11-5202163f3f80`.
|
||||||
|
|
||||||
|
- [x] Tester-Feedback 04.10.: Mitteilung „STORY_MENTION“ — anklickbar, aber man landet auf dem Profil statt auf der Story; der Text ist nicht korrekt.
|
||||||
|
→ **Behoben (04.10.):** Web öffnet die Story jetzt direkt als Overlay an der markierten Story (vorher bewusst Profil — unnötiger Umweg), die App öffnet sie über den ganzen Eintrag inkl. Namen und springt im Viewer direkt zur richtigen Story. Der rohe Text „STORY_MENTION“ kam von einem Gerät mit älterem App-Stand, der den neuen Typ noch nicht kannte — künftig zeigen App und Web für unbekannte Typen einen neutralen Text statt des Code-Namens. Zusätzlich: keine Markier-Mitteilung mehr an eigene Tiere. Commit `8aca1bc`, Rolling-Deploy, OTA Runtime 1.0.6: Android `da0b5a30-6a00-47f7-8c2f-91274f2629ec`, iOS `18681e15-dadf-46f4-b3cf-b44aab78fe42`. Bitte die App zweimal komplett neu starten.
|
||||||
|
|
||||||
|
- [x] Wunsch 04.10.: Eine Nachrichten-Mitteilung soll beim Antippen direkt zur Nachricht führen (wie bei Kommentaren) — für alle Funktionen: „Benachrichtigung bringt dich auf kürzestem Weg dorthin, wo es wichtig ist.“
|
||||||
|
→ **Umgesetzt (04.10.):** Gemeinsame Zielregel für Mitteilungsliste und Push (`mobile/src/lib/notification-route.ts`, mit Tests): **Nachricht → Gespräch**, Kommentar/Pfote/Erwähnung/Paw-Back → Beitrag, Story-Mitteilungen → die Story, Follow/Vorschlag/Einladung → Profil, Rang → Einladen, Geburtstag/Adoptionstag/Impfung → Gesundheit. Neu: **Antippen einer Push-Mitteilung** führt direkt dorthin (vorher öffnete sich nur die App), auch beim Kaltstart; gilt sie für ein anderes eigenes Tier, wird vorher umgeschaltet. Bei Nachrichten und Storys führt auch der Name zum Ziel. Web: Nachrichten öffnen das Gespräch. Server liefert dafür das Gespräch zur Mitteilung und erweiterte Push-Daten. 386 Tests grün. Commit `4cf2821`, Rolling-Deploy, OTA Runtime 1.0.6: Android `7ea956bf-b519-4a57-a9f0-4008aa212899`, iOS `b8f7c983-fc18-43d7-aea3-10fa4315d4c6`. Nicht am Gerät getestet; Push-Ziele greifen für Mitteilungen ab jetzt (ältere Pushes haben die neuen Daten noch nicht).
|
||||||
|
|
||||||
|
- [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 ChatMessage = RouterOutputs["messages"]["list"]["messages"][number];
|
||||||
export type MilestonePost = RouterOutputs["milestones"]["listByPet"][number];
|
export type MilestonePost = RouterOutputs["milestones"]["listByPet"][number];
|
||||||
export type TrendingPost = RouterOutputs["explore"]["getTrending"]["posts"][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 { useSignInWithApple } from "@clerk/expo/apple";
|
||||||
import * as Sentry from "@sentry/react-native";
|
import * as Sentry from "@sentry/react-native";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
import { radii } from "../theme/tokens";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
@@ -57,21 +56,32 @@ function AppleSignInButtonInner({ onError, legalAccepted, onNeedsConsent, disabl
|
|||||||
const result = await startAppleAuthenticationFlow();
|
const result = await startAppleAuthenticationFlow();
|
||||||
const { setActive, signUp } = result;
|
const { setActive, signUp } = result;
|
||||||
let createdSessionId = result.createdSessionId;
|
let createdSessionId = result.createdSessionId;
|
||||||
|
let missingFields = signUp?.status === "missing_requirements" ? signUp.missingFields : [];
|
||||||
|
|
||||||
if (!createdSessionId && signUp?.status === "missing_requirements" && signUp.missingFields.includes("legal_accepted")) {
|
if (!createdSessionId && signUp && missingFields.includes("legal_accepted")) {
|
||||||
if (!legalAccepted) {
|
if (!legalAccepted) {
|
||||||
onNeedsConsent?.();
|
onNeedsConsent?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updatedSignUp = await signUp.update({ legalAccepted: true });
|
const updatedSignUp = await signUp.update({ legalAccepted: true });
|
||||||
createdSessionId = updatedSignUp.createdSessionId;
|
createdSessionId = updatedSignUp.createdSessionId;
|
||||||
|
missingFields = updatedSignUp.status === "missing_requirements" ? updatedSignUp.missingFields : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (createdSessionId && setActive) {
|
if (createdSessionId && setActive) {
|
||||||
await setActive({ session: createdSessionId });
|
await setActive({ session: createdSessionId });
|
||||||
router.replace("/(app)");
|
router.replace("/(app)");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// No createdSessionId otherwise (user cancelled the Apple sheet).
|
|
||||||
|
// Any other required field Apple can't supply (e.g. first/last name —
|
||||||
|
// Apple only sends the name on the very first authorization) would
|
||||||
|
// otherwise leave the button silently doing nothing.
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
Sentry.captureMessage(`Apple sign-up missing requirements: ${missingFields.join(", ")}`);
|
||||||
|
onError(t("auth.appleSignInError"));
|
||||||
|
}
|
||||||
|
// No createdSessionId and nothing missing: user cancelled the Apple sheet.
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Sentry.captureException(err);
|
Sentry.captureException(err);
|
||||||
onError(err instanceof Error ? err.message : t("auth.appleSignInError"));
|
onError(err instanceof Error ? err.message : t("auth.appleSignInError"));
|
||||||
@@ -91,7 +101,7 @@ function AppleSignInButtonInner({ onError, legalAccepted, onNeedsConsent, disabl
|
|||||||
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
||||||
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
|
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
|
||||||
}
|
}
|
||||||
cornerRadius={radii.lg}
|
cornerRadius={14}
|
||||||
style={styles.button}
|
style={styles.button}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
/>
|
/>
|
||||||
@@ -100,6 +110,6 @@ function AppleSignInButtonInner({ onError, legalAccepted, onNeedsConsent, disabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
button: { height: 48, width: "100%" },
|
button: { height: 52, width: "100%" },
|
||||||
disabled: { opacity: 0.5 },
|
disabled: { opacity: 0.5 },
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
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 { useTheme } from "../context/ThemeContext";
|
||||||
import { radii, fontFamily, type ColorTokens } from "../theme/tokens";
|
import { radii, fontFamily, type ColorTokens } from "../theme/tokens";
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ type Variant = "primary" | "outline";
|
|||||||
type Props = PressableProps & {
|
type Props = PressableProps & {
|
||||||
title: string;
|
title: string;
|
||||||
variant?: Variant;
|
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
|
* 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.
|
* 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 { colors } = useTheme();
|
||||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
disabled={disabled}
|
disabled={disabled || isLoading}
|
||||||
style={(state) => [
|
style={(state) => [
|
||||||
styles.base,
|
styles.base,
|
||||||
variant === "primary" ? styles.primary : styles.outline,
|
variant === "primary" ? styles.primary : styles.outline,
|
||||||
@@ -32,7 +34,11 @@ export function Button({ title, variant = "primary", style, disabled, ...rest }:
|
|||||||
]}
|
]}
|
||||||
{...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>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { TextInputProps } from "react-native";
|
||||||
|
import { TextField } from "./TextField";
|
||||||
|
import { formatDateInput } from "../lib/date-format";
|
||||||
|
|
||||||
|
const DISPLAY_DATE_LENGTH = 10; // DD.MM.YYYY
|
||||||
|
|
||||||
|
type DateFieldProps = Omit<TextInputProps, "onChangeText" | "keyboardType" | "maxLength"> & {
|
||||||
|
onChangeText: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text date input with a DD.MM.YYYY mask: number pad only, the dots are
|
||||||
|
* inserted automatically (iOS' number pad has no "." key). A native wheel
|
||||||
|
* picker needs @react-native-community/datetimepicker → PENDING-NATIVE-BUILD.md.
|
||||||
|
*/
|
||||||
|
export function DateField({ onChangeText, ...props }: DateFieldProps) {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
{...props}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
maxLength={DISPLAY_DATE_LENGTH}
|
||||||
|
onChangeText={(text) => onChangeText(formatDateInput(text))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { useSSO } from "@clerk/expo";
|
|||||||
import * as Sentry from "@sentry/react-native";
|
import * as Sentry from "@sentry/react-native";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
import { useWarmUpBrowser } from "../hooks/useWarmUpBrowser";
|
import { useWarmUpBrowser } from "../hooks/useWarmUpBrowser";
|
||||||
import { radii, fontFamily, type ColorTokens } from "../theme/tokens";
|
import { fontFamily, type ColorTokens } from "../theme/tokens";
|
||||||
import { GoogleIcon } from "./GoogleIcon";
|
import { GoogleIcon } from "./GoogleIcon";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -109,8 +109,8 @@ export function GoogleSignInButton({ onError, legalAccepted, onNeedsConsent, dis
|
|||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
base: {
|
base: {
|
||||||
height: 48,
|
height: 52,
|
||||||
borderRadius: radii.lg,
|
borderRadius: 14,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.hairlineGray,
|
borderColor: colors.hairlineGray,
|
||||||
backgroundColor: colors.pureWhite,
|
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
|
* changeLanguage() logic, no backend field (mirrors web: locale lives only
|
||||||
* in a cookie/local preference, never on the Owner row).
|
* in a cookie/local preference, never on the Owner row).
|
||||||
*/
|
*/
|
||||||
export function LanguageSwitcher() {
|
export function useLanguagePreference() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { colors } = useTheme();
|
|
||||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [preference, setPreferenceState] = useState<Locale | null>(() => getLanguagePreference());
|
const [preference, setPreferenceState] = useState<Locale | null>(() => getLanguagePreference());
|
||||||
|
|
||||||
const languageOptions = [
|
const languageOptions = [
|
||||||
{ value: "de" as const, label: t("pets.languageGerman"), flag: "🇩🇪" },
|
{ value: "de" as const, label: t("pets.languageGerman"), flag: "🇩🇪", short: "DE" },
|
||||||
{ value: "en" as const, label: t("pets.languageEnglish"), flag: "🇬🇧" },
|
{ value: "en" as const, label: t("pets.languageEnglish"), flag: "🇬🇧", short: "EN" },
|
||||||
{ value: null, label: t("pets.languageAuto"), flag: "🌐" },
|
{ value: null, label: t("pets.languageAuto"), flag: "🌐", short: "Auto" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSelect = (value: Locale | null) => {
|
const handleSelect = (value: Locale | null) => {
|
||||||
@@ -33,6 +31,14 @@ export function LanguageSwitcher() {
|
|||||||
queryClient.invalidateQueries();
|
queryClient.invalidateQueries();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return { preference, languageOptions, handleSelect };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageSwitcher() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
|
const { preference, languageOptions, handleSelect } = useLanguagePreference();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
{languageOptions.map((option) => {
|
{languageOptions.map((option) => {
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { View, Text, Pressable, ScrollView, ActivityIndicator, Modal, StyleSheet } from "react-native";
|
import {
|
||||||
import { ChevronDown } from "lucide-react-native";
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
ActivityIndicator,
|
||||||
|
Modal,
|
||||||
|
StyleSheet,
|
||||||
|
Keyboard,
|
||||||
|
Platform,
|
||||||
|
useWindowDimensions,
|
||||||
|
} from "react-native";
|
||||||
|
import { ChevronDown, Search } from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
import { radii, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
|
import { radii, fontFamily, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
|
||||||
|
import { filterOptionsByName } from "../lib/filter-options";
|
||||||
|
|
||||||
export type PickerOption = { id: string; name: string };
|
export type PickerOption = { id: string; name: string };
|
||||||
|
|
||||||
@@ -22,6 +35,11 @@ type Props = {
|
|||||||
const BUTTON_HEIGHT = 48;
|
const BUTTON_HEIGHT = 48;
|
||||||
const LIST_GAP = 4;
|
const LIST_GAP = 4;
|
||||||
const MAX_LIST_HEIGHT = 220;
|
const MAX_LIST_HEIGHT = 220;
|
||||||
|
// Ab so vielen Einträgen gibt es oben ein Suchfeld (found.md: Hunde-/Katzen-
|
||||||
|
// Rassenlisten sind lang). Tierarten (kurze Liste) bleiben ohne.
|
||||||
|
const SEARCH_MIN_OPTIONS = 10;
|
||||||
|
const SEARCH_HEIGHT = 44;
|
||||||
|
const MIN_LIST_TOP = 48;
|
||||||
|
|
||||||
type Anchor = { top: number; left: number; width: number };
|
type Anchor = { top: number; left: number; width: number };
|
||||||
|
|
||||||
@@ -64,6 +82,13 @@ export function OptionPicker({
|
|||||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
const triggerRef = useRef<View>(null);
|
const triggerRef = useRef<View>(null);
|
||||||
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
|
const [listHeight, setListHeight] = useState(0);
|
||||||
|
const { height: windowHeight } = useWindowDimensions();
|
||||||
|
|
||||||
|
const hasSearch = options.length >= SEARCH_MIN_OPTIONS;
|
||||||
|
const visibleOptions = useMemo(() => filterOptionsByName(options, query), [options, query]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// No need to reset `anchor` to null when closing — the Modal already
|
// No need to reset `anchor` to null when closing — the Modal already
|
||||||
@@ -76,6 +101,38 @@ export function OptionPicker({
|
|||||||
});
|
});
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// The search field brings up the keyboard; a list anchored low on the
|
||||||
|
// screen would end up underneath it, so it moves up while the keyboard is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !hasSearch) return;
|
||||||
|
const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
|
||||||
|
const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
|
||||||
|
const showSub = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height));
|
||||||
|
const hideSub = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0));
|
||||||
|
return () => {
|
||||||
|
showSub.remove();
|
||||||
|
hideSub.remove();
|
||||||
|
setKeyboardHeight(0);
|
||||||
|
};
|
||||||
|
}, [open, hasSearch]);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setQuery("");
|
||||||
|
onToggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
const select = (option: PickerOption) => {
|
||||||
|
setQuery("");
|
||||||
|
onSelect(option);
|
||||||
|
};
|
||||||
|
|
||||||
|
const listTop = (() => {
|
||||||
|
if (!anchor) return 0;
|
||||||
|
if (!keyboardHeight || !listHeight) return anchor.top;
|
||||||
|
const maxTop = windowHeight - keyboardHeight - listHeight - LIST_GAP;
|
||||||
|
return Math.max(MIN_LIST_TOP, Math.min(anchor.top, maxTop));
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -87,7 +144,7 @@ export function OptionPicker({
|
|||||||
<Text style={selectedName ? styles.value : styles.placeholder}>{selectedName ?? placeholder}</Text>
|
<Text style={selectedName ? styles.value : styles.placeholder}>{selectedName ?? placeholder}</Text>
|
||||||
<ChevronDown color={colors.quietGray} size={18} />
|
<ChevronDown color={colors.quietGray} size={18} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Modal visible={open} transparent animationType="fade" onRequestClose={onToggle}>
|
<Modal visible={open} transparent animationType="fade" onRequestClose={close}>
|
||||||
{/*
|
{/*
|
||||||
Backdrop and list are SIBLINGS, not parent/child. The list must
|
Backdrop and list are SIBLINGS, not parent/child. The list must
|
||||||
not be a descendant of the backdrop Pressable at all — a Pressable
|
not be a descendant of the backdrop Pressable at all — a Pressable
|
||||||
@@ -99,17 +156,36 @@ export function OptionPicker({
|
|||||||
the list's bounds hit-tests into the list's own subtree and never
|
the list's bounds hit-tests into the list's own subtree and never
|
||||||
reaches the backdrop's Pressable underneath at all.
|
reaches the backdrop's Pressable underneath at all.
|
||||||
*/}
|
*/}
|
||||||
<Pressable style={styles.backdrop} onPress={onToggle} />
|
<Pressable style={styles.backdrop} onPress={close} />
|
||||||
{anchor && (
|
{anchor && (
|
||||||
<View style={[styles.list, { top: anchor.top, left: anchor.left, width: anchor.width }]}>
|
<View
|
||||||
|
style={[styles.list, hasSearch && styles.listWithSearch, { top: listTop, left: anchor.left, width: anchor.width }]}
|
||||||
|
onLayout={(e) => setListHeight(e.nativeEvent.layout.height)}
|
||||||
|
>
|
||||||
|
{hasSearch && (
|
||||||
|
<View style={styles.searchRow}>
|
||||||
|
<Search color={colors.quietGray} size={16} />
|
||||||
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder={t("common.searchPlaceholder")}
|
||||||
|
placeholderTextColor={colors.quietGray}
|
||||||
|
style={styles.searchInput}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
returnKeyType="search"
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator color={colors.marigoldOrange} style={styles.loading} />
|
<ActivityIndicator color={colors.marigoldOrange} style={styles.loading} />
|
||||||
) : options.length === 0 ? (
|
) : visibleOptions.length === 0 ? (
|
||||||
<Text style={styles.empty}>{emptyLabel ?? t("common.noResultsFound")}</Text>
|
<Text style={styles.empty}>{emptyLabel && !query ? emptyLabel : t("common.noResultsFound")}</Text>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView style={styles.scrollView}>
|
<ScrollView style={styles.scrollView} keyboardShouldPersistTaps="handled">
|
||||||
{options.map((option) => (
|
{visibleOptions.map((option) => (
|
||||||
<Pressable key={option.id} style={styles.row} onPress={() => onSelect(option)}>
|
<Pressable key={option.id} style={styles.row} onPress={() => select(option)}>
|
||||||
<Text style={getTypography(colors).body}>{option.name}</Text>
|
<Text style={getTypography(colors).body}>{option.name}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
@@ -154,7 +230,18 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
shadowOpacity: 0.15,
|
shadowOpacity: 0.15,
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
},
|
},
|
||||||
|
listWithSearch: { maxHeight: MAX_LIST_HEIGHT + SEARCH_HEIGHT },
|
||||||
scrollView: { maxHeight: MAX_LIST_HEIGHT },
|
scrollView: { maxHeight: MAX_LIST_HEIGHT },
|
||||||
|
searchRow: {
|
||||||
|
height: SEARCH_HEIGHT,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.hairlineGray,
|
||||||
|
},
|
||||||
|
searchInput: { flex: 1, height: SEARCH_HEIGHT, fontFamily: fontFamily.regular, fontSize: 14, color: colors.ink },
|
||||||
row: { paddingHorizontal: 14, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
row: { paddingHorizontal: 14, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||||
loading: { padding: 16 },
|
loading: { padding: 16 },
|
||||||
empty: { ...typography.body, color: colors.quietGray, padding: 16 },
|
empty: { ...typography.body, color: colors.quietGray, padding: 16 },
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Pressable, Text, StyleSheet } from "react-native";
|
import { Pressable, Text, StyleSheet } from "react-native";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { PawPrint } from "lucide-react-native";
|
import { PawPrint } from "lucide-react-native";
|
||||||
import { useTRPC } from "../api/trpc";
|
import { useTRPC } from "../api/trpc";
|
||||||
|
import { usePawToggle } from "../hooks/usePawToggle";
|
||||||
import { colors, fontFamily } from "../theme/tokens";
|
import { colors, fontFamily } from "../theme/tokens";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -16,37 +16,12 @@ type Props = {
|
|||||||
* `false` on mount — the web app's feed response doesn't tell the client
|
* `false` on mount — the web app's feed response doesn't tell the client
|
||||||
* whether the viewer already reacted (postInclude has no reactions relation),
|
* whether the viewer already reacted (postInclude has no reactions relation),
|
||||||
* an acknowledged simplification on web that this mirrors rather than fixes.
|
* an acknowledged simplification on web that this mirrors rather than fixes.
|
||||||
* Same optimistic-update shape: flip locally in onMutate, reconcile the exact
|
* Optimistic toggle via usePawToggle (shared with StoryPawButton).
|
||||||
* count from the server's authoritative `reacted` in onSuccess, roll back in
|
|
||||||
* onError.
|
|
||||||
*/
|
*/
|
||||||
export function PawButton({ postId, petId, initialCount }: Props) {
|
export function PawButton({ postId, petId, initialCount }: Props) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const [reacted, setReacted] = useState(false);
|
const paw = usePawToggle(false, initialCount);
|
||||||
const [count, setCount] = useState(initialCount);
|
const toggleMutation = useMutation(trpc.reactions.toggle.mutationOptions(paw.callbacks));
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -56,10 +31,10 @@ export function PawButton({ postId, petId, initialCount }: Props) {
|
|||||||
>
|
>
|
||||||
<PawPrint
|
<PawPrint
|
||||||
size={20}
|
size={20}
|
||||||
color={reacted ? colors.marigoldOrange : colors.quietGray}
|
color={paw.reacted ? colors.marigoldOrange : colors.quietGray}
|
||||||
fill={reacted ? colors.marigoldOrange : "transparent"}
|
fill={paw.reacted ? colors.marigoldOrange : "transparent"}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.count}>{count}</Text>
|
<Text style={styles.count}>{paw.count}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Svg, { Circle } from "react-native-svg";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTRPC } from "../api/trpc";
|
import { useTRPC } from "../api/trpc";
|
||||||
import { useActivePet } from "../context/ActivePetContext";
|
import { useActivePet } from "../context/ActivePetContext";
|
||||||
|
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
petId: string;
|
petId: string;
|
||||||
@@ -10,6 +11,14 @@ type Props = {
|
|||||||
/** Avatar size in px — used to scale the paw outline (default: 32) */
|
/** Avatar size in px — used to scale the paw outline (default: 32) */
|
||||||
size?: number;
|
size?: number;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
/** Nur Story-Leiste: eigene abgelaufene Storys (≤ 7 Tage) als grauer Ring. */
|
||||||
|
includeOwnRecent?: boolean;
|
||||||
|
/**
|
||||||
|
* Owner with the PROFILE_RING perk (found.md): an unseen story's paw takes
|
||||||
|
* the rank colour instead of orange, and without a story a plain ring in
|
||||||
|
* the rank colour is drawn. Seen stories stay gray either way.
|
||||||
|
*/
|
||||||
|
owner?: ReferralOwnerFields | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RING_PAD = 8;
|
const RING_PAD = 8;
|
||||||
@@ -35,15 +44,21 @@ export function getPawRingBox(size: number) {
|
|||||||
* instead of inline SVG markup. Orange = unseen stories, gray = all seen,
|
* instead of inline SVG markup. Orange = unseen stories, gray = all seen,
|
||||||
* no ring = no active story (STORY-02).
|
* no ring = no active story (STORY-02).
|
||||||
*/
|
*/
|
||||||
export function PawRing({ petId, children, size = 32, onPress }: Props) {
|
export function PawRing({ petId, children, size = 32, onPress, includeOwnRecent, owner }: Props) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const { activePetId } = useActivePet();
|
const { activePetId } = useActivePet();
|
||||||
|
|
||||||
const { data = { hasStory: false, allSeen: false } } = useQuery(
|
const { data = { hasStory: false, allSeen: false } } = useQuery(
|
||||||
trpc.stories.hasActiveStory.queryOptions({ petId, viewerPetId: activePetId ?? undefined })
|
trpc.stories.hasActiveStory.queryOptions({
|
||||||
|
petId,
|
||||||
|
viewerPetId: activePetId ?? undefined,
|
||||||
|
...(includeOwnRecent ? { includeOwnRecent: true } : {}),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const { hasStory, allSeen } = data;
|
const { hasStory, allSeen } = data;
|
||||||
const ringColor = allSeen ? SEEN_COLOR : UNSEEN_COLOR;
|
const rank = useOwnerRankWithPerk(owner, "PROFILE_RING");
|
||||||
|
const ringColor = allSeen ? SEEN_COLOR : (rank?.color ?? UNSEEN_COLOR);
|
||||||
|
const showRankRingOnly = !hasStory && !!rank;
|
||||||
|
|
||||||
const box = getPawRingBox(size);
|
const box = getPawRingBox(size);
|
||||||
const strokeWidth = Math.max(4, size / 8);
|
const strokeWidth = Math.max(4, size / 8);
|
||||||
@@ -64,6 +79,16 @@ export function PawRing({ petId, children, size = 32, onPress }: Props) {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{showRankRingOnly && (
|
||||||
|
<Svg
|
||||||
|
width={box.width}
|
||||||
|
height={box.height}
|
||||||
|
viewBox={`0 ${-BEAN_OVERHANG} 100 ${100 + BEAN_OVERHANG}`}
|
||||||
|
style={{ position: "absolute", top: 0, left: 0 }}
|
||||||
|
>
|
||||||
|
<Circle cx={50} cy={50} r={44} fill="none" stroke={rank.color} strokeWidth={strokeWidth * 0.6} />
|
||||||
|
</Svg>
|
||||||
|
)}
|
||||||
{hasStory && (
|
{hasStory && (
|
||||||
<Svg
|
<Svg
|
||||||
width={box.width}
|
width={box.width}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Image } from "expo-image";
|
|||||||
import { getAvatarUrl } from "../lib/media-url";
|
import { getAvatarUrl } from "../lib/media-url";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
import { fontFamily, type ColorTokens } from "../theme/tokens";
|
import { fontFamily, type ColorTokens } from "../theme/tokens";
|
||||||
|
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
|
||||||
|
|
||||||
const SIZE_PRESETS = { sm: 32, md: 40, lg: 56 } as const;
|
const SIZE_PRESETS = { sm: 32, md: 40, lg: 56 } as const;
|
||||||
|
|
||||||
@@ -11,28 +12,60 @@ type Props = {
|
|||||||
name: string;
|
name: string;
|
||||||
avatarKey?: string | null;
|
avatarKey?: string | null;
|
||||||
size?: keyof typeof SIZE_PRESETS;
|
size?: keyof typeof SIZE_PRESETS;
|
||||||
|
/**
|
||||||
|
* The pet's owner (referralRankId/referralPerks, part of petIdentitySelect).
|
||||||
|
* With the PROFILE_RING perk the avatar gets a ring in the rank colour.
|
||||||
|
* Don't pass it when a PawRing wraps this avatar — PawRing draws that ring.
|
||||||
|
*/
|
||||||
|
owner?: ReferralOwnerFields | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ring sits *outside* the avatar (absolute overlay) so ranked and unranked
|
||||||
|
// avatars keep the same footprint and list rows don't shift.
|
||||||
|
const RING_WIDTH = { sm: 2, md: 2, lg: 3 } as const;
|
||||||
|
const RING_GAP = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port of web's src/components/ui/pet-avatar.tsx — image if the pet has one,
|
* Port of web's src/components/ui/pet-avatar.tsx — image if the pet has one,
|
||||||
* otherwise a colored initial fallback.
|
* otherwise a colored initial fallback.
|
||||||
*/
|
*/
|
||||||
export function PetAvatar({ name, avatarKey, size = "md" }: Props) {
|
export function PetAvatar({ name, avatarKey, size = "md", owner }: Props) {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
|
const rank = useOwnerRankWithPerk(owner, "PROFILE_RING");
|
||||||
const px = SIZE_PRESETS[size];
|
const px = SIZE_PRESETS[size];
|
||||||
const uri = getAvatarUrl(avatarKey);
|
const uri = getAvatarUrl(avatarKey);
|
||||||
const dimensionStyle = { width: px, height: px, borderRadius: px / 2 };
|
const dimensionStyle = { width: px, height: px, borderRadius: px / 2 };
|
||||||
|
|
||||||
if (uri) {
|
const avatar = uri ? (
|
||||||
return <Image source={{ uri }} style={dimensionStyle} contentFit="cover" transition={150} />;
|
<Image source={{ uri }} style={dimensionStyle} contentFit="cover" transition={150} />
|
||||||
}
|
) : (
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.fallback, dimensionStyle]}>
|
<View style={[styles.fallback, dimensionStyle]}>
|
||||||
<Text style={[styles.fallbackText, { fontSize: px * 0.4 }]}>{name.charAt(0).toUpperCase()}</Text>
|
<Text style={[styles.fallbackText, { fontSize: px * 0.4 }]}>{name.charAt(0).toUpperCase()}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!rank) return avatar;
|
||||||
|
|
||||||
|
const offset = RING_GAP + RING_WIDTH[size];
|
||||||
|
return (
|
||||||
|
<View style={{ width: px, height: px }}>
|
||||||
|
{avatar}
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -offset,
|
||||||
|
left: -offset,
|
||||||
|
width: px + offset * 2,
|
||||||
|
height: px + offset * 2,
|
||||||
|
borderRadius: (px + offset * 2) / 2,
|
||||||
|
borderWidth: RING_WIDTH[size],
|
||||||
|
borderColor: rank.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
|
|||||||
@@ -1,27 +1,42 @@
|
|||||||
import { View, StyleSheet } from "react-native";
|
import { View, StyleSheet } from "react-native";
|
||||||
import { BadgeCheck, Sparkles } from "lucide-react-native";
|
import { BadgeCheck, Ribbon, Siren, Sparkles, Sprout } from "lucide-react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { isActiveSponsor } from "../lib/sponsor";
|
import { isActiveSponsor } from "../lib/sponsor";
|
||||||
import { colors } from "../theme/tokens";
|
import { colors } from "../theme/tokens";
|
||||||
|
import { useTheme } from "../context/ThemeContext";
|
||||||
import { ReferralIcon } from "../lib/referral-icons";
|
import { ReferralIcon } from "../lib/referral-icons";
|
||||||
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
|
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isVerified?: boolean;
|
isVerified?: boolean;
|
||||||
sponsor?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
|
/** Eines der ersten 250 Tiere (Pet.isFoundingMember). */
|
||||||
|
isFoundingMember?: boolean;
|
||||||
|
/** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */
|
||||||
|
isDeceased?: boolean;
|
||||||
|
/** Aktive Vermisst-Meldung (Pet.lostSince gesetzt). */
|
||||||
|
isLost?: boolean;
|
||||||
|
sponsor?: { sponsorSince?: Date | string | null; sponsorPeriodEnd?: Date | string | null } | null;
|
||||||
/** Owner-Felder referralRankId/referralPerks — Rang-Abzeichen des Empfehlungsprogramms. */
|
/** Owner-Felder referralRankId/referralPerks — Rang-Abzeichen des Empfehlungsprogramms. */
|
||||||
referral?: ReferralOwnerFields | null;
|
referral?: ReferralOwnerFields | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Port of web's src/components/ui/pet-identity-badges.tsx. */
|
/** Port of web's src/components/ui/pet-identity-badges.tsx. */
|
||||||
export function PetIdentityBadges({ isVerified, sponsor, referral }: Props) {
|
const FOUNDING_COLOR = "#059669"; // emerald-600, wie im Web
|
||||||
|
|
||||||
|
export function PetIdentityBadges({ isVerified, isFoundingMember, isDeceased, isLost, sponsor, referral }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { colors: theme } = useTheme();
|
||||||
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
|
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
|
||||||
const rank = useOwnerRankWithPerk(referral, "NAME_BADGE");
|
const rank = useOwnerRankWithPerk(referral, "NAME_BADGE");
|
||||||
if (!isVerified && !showSponsor && !rank) return null;
|
if (!isDeceased && !isLost && !isVerified && !isFoundingMember && !showSponsor && !rank) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
|
{isLost && <Siren size={14} color="#dc2626" accessibilityLabel={t("lostReport.badge")} />}
|
||||||
|
{isDeceased && <Ribbon size={14} color={theme.ink} fill={theme.ink} accessibilityLabel={t("inMemory.label")} />}
|
||||||
{isVerified && <BadgeCheck size={14} color={colors.marigoldOrange} />}
|
{isVerified && <BadgeCheck size={14} color={colors.marigoldOrange} />}
|
||||||
{showSponsor && <Sparkles size={14} color={colors.marigoldOrange} />}
|
{showSponsor && <Sparkles size={14} color={colors.marigoldOrange} />}
|
||||||
|
{isFoundingMember && <Sprout size={14} color={FOUNDING_COLOR} accessibilityLabel={t("foundingMember.label")} />}
|
||||||
{rank && <ReferralIcon icon={rank.icon} size={14} color={rank.color} fill={rank.color} accessibilityLabel={rank.name} />}
|
{rank && <ReferralIcon icon={rank.icon} size={14} color={rank.color} fill={rank.color} accessibilityLabel={rank.name} />}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { getMediaUrl } from "../lib/media-url";
|
|||||||
import { formatPetName, formatRelativeTime } from "../lib/format";
|
import { formatPetName, formatRelativeTime } from "../lib/format";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
import { radii, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
|
import { radii, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
|
||||||
|
import type { ReferralOwnerFields } from "../hooks/useReferralRanks";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structural rather than imported from a single router's inferred output:
|
* Structural rather than imported from a single router's inferred output:
|
||||||
@@ -39,7 +40,7 @@ type PostCardOriginalPost = {
|
|||||||
type?: string | null;
|
type?: string | null;
|
||||||
images: PostCardImage[];
|
images: PostCardImage[];
|
||||||
milestone?: unknown;
|
milestone?: unknown;
|
||||||
pet: { id: string; name: string; avatarKey?: string | null };
|
pet: { id: string; name: string; avatarKey?: string | null; owner?: ReferralOwnerFields | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PostCardPost = {
|
export type PostCardPost = {
|
||||||
@@ -59,9 +60,14 @@ export type PostCardPost = {
|
|||||||
nickname?: string | null;
|
nickname?: string | null;
|
||||||
avatarKey?: string | null;
|
avatarKey?: string | null;
|
||||||
isVerified?: boolean;
|
isVerified?: boolean;
|
||||||
|
deceasedAt?: Date | string | null;
|
||||||
|
lostSince?: Date | string | null;
|
||||||
|
isFoundingMember?: boolean;
|
||||||
|
// Sponsor fields only come with petCardSelect (feed/explore); a plain pet
|
||||||
|
// identity's owner (posts.byPetId, comments) carries just the referral rank.
|
||||||
owner?: {
|
owner?: {
|
||||||
sponsorSince: Date | string | null;
|
sponsorSince?: Date | string | null;
|
||||||
sponsorPeriodEnd: Date | string | null;
|
sponsorPeriodEnd?: Date | string | null;
|
||||||
referralRankId?: string | null;
|
referralRankId?: string | null;
|
||||||
referralPerks?: string[] | null;
|
referralPerks?: string[] | null;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -144,11 +150,11 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
|
|||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
<Pressable style={styles.header} onPress={() => router.push(`/pets/${post.pet.id}`)}>
|
<Pressable style={styles.header} onPress={() => router.push(`/pets/${post.pet.id}`)}>
|
||||||
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} />
|
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} owner={post.pet.owner} />
|
||||||
<View style={styles.headerText}>
|
<View style={styles.headerText}>
|
||||||
<View style={styles.nameRow}>
|
<View style={styles.nameRow}>
|
||||||
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
|
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
|
||||||
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} referral={post.pet.owner} />
|
<PetIdentityBadges isLost={!!post.pet.lostSince} isDeceased={!!post.pet.deceasedAt} isVerified={post.pet.isVerified} isFoundingMember={post.pet.isFoundingMember} sponsor={post.pet.owner} referral={post.pet.owner} />
|
||||||
{isTrending && (
|
{isTrending && (
|
||||||
<View style={styles.trendingBadge}>
|
<View style={styles.trendingBadge}>
|
||||||
<Flame size={11} color={colors.marigoldDeep} fill={colors.marigoldDeep} />
|
<Flame size={11} color={colors.marigoldDeep} fill={colors.marigoldDeep} />
|
||||||
@@ -306,8 +312,8 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
borderRadius: radii.sm,
|
borderRadius: radii.sm,
|
||||||
backgroundColor: colors.marigoldTint,
|
backgroundColor: colors.marigoldTint,
|
||||||
},
|
},
|
||||||
trendingBadgeText: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 10, color: colors.marigoldDeep },
|
trendingBadgeText: { ...typography.caption, fontSize: 10, color: colors.marigoldDeep },
|
||||||
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
timestamp: { ...typography.caption },
|
||||||
caption: { ...typography.body },
|
caption: { ...typography.body },
|
||||||
actions: { flexDirection: "row", alignItems: "center", gap: 20 },
|
actions: { flexDirection: "row", alignItems: "center", gap: 20 },
|
||||||
actionGroup: { flexDirection: "row", alignItems: "center", gap: 6 },
|
actionGroup: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function RepostCard({ post }: Props) {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable style={styles.header} onPress={() => router.push(`/pets/${origPet.id}`)}>
|
<Pressable style={styles.header} onPress={() => router.push(`/pets/${origPet.id}`)}>
|
||||||
<PetAvatar name={origPet.name} avatarKey={origPet.avatarKey} size="sm" />
|
<PetAvatar name={origPet.name} avatarKey={origPet.avatarKey} size="sm" owner={origPet.owner} />
|
||||||
<Text style={styles.name}>{origPet.name}</Text>
|
<Text style={styles.name}>{origPet.name}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
card: { gap: 8 },
|
card: { gap: 8 },
|
||||||
attribution: { flexDirection: "row", alignItems: "center", gap: 6 },
|
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 },
|
header: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||||
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
name: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||||
caption: { ...typography.body },
|
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>;
|
||||||
|
}
|
||||||
@@ -16,20 +16,33 @@ const RING_BOX = getPawRingBox(AVATAR_SIZE);
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Port of web's src/components/stories/StoryTray.tsx: an "add story" tile
|
* Port of web's src/components/stories/StoryTray.tsx: an "add story" tile
|
||||||
* for the active pet, then followed pets with an active story (each in a
|
* for the active pet, then the active pet's own story while it has one
|
||||||
* PawRing). No viewer list, reactions, or comments on stories yet (Gitea
|
* ("Deine Story" — the followed-pets query never includes the viewer's own
|
||||||
|
* pet), then followed pets with an active story (each in a PawRing). No viewer list, reactions, or comments on stories yet (Gitea
|
||||||
* #29 on web) — that's a fast-follow, matching how earlier phases trimmed
|
* #29 on web) — that's a fast-follow, matching how earlier phases trimmed
|
||||||
* AI-disclosure/mentions from ported forms. Photo stories only, no video
|
* AI-disclosure/mentions from ported forms. Photo and video stories both
|
||||||
* story upload/playback (mirrors mobile's post-creation scope decision).
|
* play back the same way here — see story-viewer.tsx for the branch.
|
||||||
*/
|
*/
|
||||||
export function StoryTray() {
|
export function StoryTray() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { activePetId } = useActivePet();
|
const { activePetId, pets } = useActivePet();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
|
|
||||||
|
const { data: ownStory } = useQuery({
|
||||||
|
...trpc.stories.hasActiveStory.queryOptions({ petId: activePetId ?? "", includeOwnRecent: true }),
|
||||||
|
enabled: !!activePetId,
|
||||||
|
});
|
||||||
|
const activePet = pets.find((pet) => pet.id === activePetId) ?? null;
|
||||||
|
|
||||||
|
const openViewer = (pet: { id: string; name: string; avatarKey?: string | null }) =>
|
||||||
|
router.push({
|
||||||
|
pathname: "/story-viewer",
|
||||||
|
params: { petId: pet.id, petName: pet.name, petAvatarKey: pet.avatarKey ?? "" },
|
||||||
|
});
|
||||||
|
|
||||||
const { data: followedWithStories = [] } = useQuery({
|
const { data: followedWithStories = [] } = useQuery({
|
||||||
...trpc.follows.getPetsWithActiveStories.queryOptions({ followerPetId: activePetId ?? "" }),
|
...trpc.follows.getPetsWithActiveStories.queryOptions({ followerPetId: activePetId ?? "" }),
|
||||||
enabled: !!activePetId,
|
enabled: !!activePetId,
|
||||||
@@ -48,18 +61,20 @@ export function StoryTray() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
|
{ownStory?.hasStory && activePet && (
|
||||||
|
<Pressable style={styles.item} onPress={() => openViewer(activePet)}>
|
||||||
|
<PawRing petId={activePet.id} size={AVATAR_SIZE} includeOwnRecent owner={activePet.owner}>
|
||||||
|
<PetAvatar name={activePet.name} avatarKey={activePet.avatarKey} size="lg" />
|
||||||
|
</PawRing>
|
||||||
|
<Text style={styles.label} numberOfLines={1}>
|
||||||
|
{t("feed.yourStory")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
{followedWithStories.map((pet) => (
|
{followedWithStories.map((pet) => (
|
||||||
<Pressable
|
<Pressable key={pet.id} style={styles.item} onPress={() => openViewer(pet)}>
|
||||||
key={pet.id}
|
<PawRing petId={pet.id} size={AVATAR_SIZE} owner={pet.owner}>
|
||||||
style={styles.item}
|
|
||||||
onPress={() =>
|
|
||||||
router.push({
|
|
||||||
pathname: "/story-viewer",
|
|
||||||
params: { petId: pet.id, petName: pet.name, petAvatarKey: pet.avatarKey ?? "" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PawRing petId={pet.id} size={AVATAR_SIZE}>
|
|
||||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||||
</PawRing>
|
</PawRing>
|
||||||
<Text style={styles.label} numberOfLines={1}>
|
<Text style={styles.label} numberOfLines={1}>
|
||||||
|
|||||||
@@ -96,5 +96,5 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: 8,
|
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 },
|
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||||
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
|
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 },
|
ctaRow: { flexDirection: "row", gap: 10 },
|
||||||
ctaButton: { flex: 1 },
|
ctaButton: { flex: 1 },
|
||||||
manageBox: {
|
manageBox: {
|
||||||
@@ -163,7 +163,7 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
},
|
},
|
||||||
manageText: { flex: 1 },
|
manageText: { flex: 1 },
|
||||||
manageTitle: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 },
|
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 },
|
revokeLink: { ...typography.body, color: colors.alertRed, fontFamily: typography.title.fontFamily, fontSize: 13 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,15 @@ import { Plus, Trash2 } from "lucide-react-native";
|
|||||||
import { useTRPC } from "../../api/trpc";
|
import { useTRPC } from "../../api/trpc";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { TextField } from "../TextField";
|
import { TextField } from "../TextField";
|
||||||
|
import { DateField } from "../DateField";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Card } from "../Card";
|
import { Card } from "../Card";
|
||||||
|
import { healthSectionStyles } from "./healthSectionStyles";
|
||||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||||
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
import { addYearsToDisplayDate, displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||||
|
|
||||||
|
/** Übliche Auffrisch-Intervalle (z. B. Tollwut 1–3 Jahre) als Schnellauswahl für den Folgetermin. */
|
||||||
|
const NEXT_DUE_PRESET_YEARS = [1, 3];
|
||||||
|
|
||||||
/** Port of web's health.ts vaccine CRUD — see health/page.tsx's Impfungen tab. */
|
/** Port of web's health.ts vaccine CRUD — see health/page.tsx's Impfungen tab. */
|
||||||
export function VaccineSection({ petId }: { petId: string }) {
|
export function VaccineSection({ petId }: { petId: string }) {
|
||||||
@@ -80,8 +85,24 @@ export function VaccineSection({ petId }: { petId: string }) {
|
|||||||
{showForm && (
|
{showForm && (
|
||||||
<Card style={styles.form}>
|
<Card style={styles.form}>
|
||||||
<TextField placeholder={t("health.vaccineNamePlaceholder")} value={name} onChangeText={setName} maxLength={100} />
|
<TextField placeholder={t("health.vaccineNamePlaceholder")} value={name} onChangeText={setName} maxLength={100} />
|
||||||
<TextField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
|
<DateField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
|
||||||
<TextField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
|
<DateField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
|
||||||
|
<View style={styles.presetRow}>
|
||||||
|
{NEXT_DUE_PRESET_YEARS.map((years) => {
|
||||||
|
const suggestion = addYearsToDisplayDate(dateGiven, years);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={years}
|
||||||
|
style={[styles.preset, !suggestion && styles.presetDisabled]}
|
||||||
|
disabled={!suggestion}
|
||||||
|
onPress={() => suggestion && setNextDueDate(suggestion)}
|
||||||
|
>
|
||||||
|
<Text style={styles.presetText}>{t("health.vaccineNextDuePreset", { count: years })}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.hint}>{t("health.vaccineReminderHint")}</Text>
|
||||||
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
|
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
|
||||||
<Button
|
<Button
|
||||||
title={addMutation.isPending ? "…" : t("common.save")}
|
title={addMutation.isPending ? "…" : t("common.save")}
|
||||||
@@ -123,16 +144,14 @@ export function VaccineSection({ petId }: { petId: string }) {
|
|||||||
|
|
||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
const typography: Typography = getTypography(colors);
|
const typography: Typography = getTypography(colors);
|
||||||
|
const base = healthSectionStyles(colors);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
section: { gap: 10 },
|
...base,
|
||||||
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 },
|
|
||||||
overdue: { color: colors.alertRed },
|
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.caption, color: colors.ink },
|
||||||
|
hint: { ...typography.caption, color: colors.quietGray },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,10 @@ import { Plus, Trash2 } from "lucide-react-native";
|
|||||||
import { useTRPC } from "../../api/trpc";
|
import { useTRPC } from "../../api/trpc";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { TextField } from "../TextField";
|
import { TextField } from "../TextField";
|
||||||
|
import { DateField } from "../DateField";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Card } from "../Card";
|
import { Card } from "../Card";
|
||||||
|
import { healthSectionStyles } from "./healthSectionStyles";
|
||||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||||
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ export function VetVisitSection({ petId }: { petId: string }) {
|
|||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<Card style={styles.form}>
|
<Card style={styles.form}>
|
||||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||||
<TextField placeholder={t("health.vetReasonPlaceholder")} value={reason} onChangeText={setReason} maxLength={100} />
|
<TextField placeholder={t("health.vetReasonPlaceholder")} value={reason} onChangeText={setReason} maxLength={100} />
|
||||||
<TextField placeholder={t("health.vetNamePlaceholder")} value={vetName} onChangeText={setVetName} maxLength={100} />
|
<TextField placeholder={t("health.vetNamePlaceholder")} value={vetName} onChangeText={setVetName} maxLength={100} />
|
||||||
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={500} multiline />
|
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={500} multiline />
|
||||||
@@ -111,16 +113,9 @@ export function VetVisitSection({ petId }: { petId: string }) {
|
|||||||
|
|
||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
const typography: Typography = getTypography(colors);
|
const typography: Typography = getTypography(colors);
|
||||||
|
const base = healthSectionStyles(colors);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
section: { gap: 10 },
|
...base,
|
||||||
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 },
|
|
||||||
rowNotes: { ...typography.body, fontSize: 13, marginTop: 2 },
|
rowNotes: { ...typography.body, fontSize: 13, marginTop: 2 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { View, Text, Pressable, StyleSheet, type LayoutChangeEvent } from "react-native";
|
||||||
|
import Svg, { Path, Line, Circle, Text as SvgText, Defs, LinearGradient, Stop } from "react-native-svg";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { Card } from "../Card";
|
||||||
|
import { getTypography, fontFamily, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||||
|
import { buildWeightChart, nearestPointIndex, type WeightEntry } from "../../lib/weight-chart";
|
||||||
|
|
||||||
|
const CHART_HEIGHT = 170;
|
||||||
|
const BOX = { left: 40, right: 12, top: 12, bottom: 26 };
|
||||||
|
const AXIS_FONT_SIZE = 10;
|
||||||
|
const MAX_LABELED_Y_TICKS = 5;
|
||||||
|
const MARKER_RADIUS = 4;
|
||||||
|
const SELECTED_RADIUS = 6;
|
||||||
|
const LONG_SPAN_MS = 300 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weight history (found.md: Gewichtsdaten als Diagramm) — port of web's
|
||||||
|
* src/components/health/WeightChart.tsx. Tap anywhere on the plot to select
|
||||||
|
* the nearest weigh-in; its value and date show in the header (no floating
|
||||||
|
* tooltip to collide with the edges on a phone). The newest entry is
|
||||||
|
* selected by default. The entry list below the chart is the table view.
|
||||||
|
*/
|
||||||
|
export function WeightChart({ entries }: { entries: WeightEntry[] }) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const chart = useMemo(
|
||||||
|
() => (width > 0 ? buildWeightChart(entries, { width, height: CHART_HEIGHT, ...BOX }) : null),
|
||||||
|
[entries, width]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatKg = (kg: number, maximumFractionDigits = 2) =>
|
||||||
|
`${kg.toLocaleString(i18n.language, { maximumFractionDigits })} kg`;
|
||||||
|
const formatDate = (time: number, options: Intl.DateTimeFormatOptions) =>
|
||||||
|
new Date(time).toLocaleDateString(i18n.language, { timeZone: "UTC", ...options });
|
||||||
|
|
||||||
|
const onLayout = (e: LayoutChangeEvent) => setWidth(Math.round(e.nativeEvent.layout.width));
|
||||||
|
|
||||||
|
if (entries.length < 2) return null;
|
||||||
|
|
||||||
|
const points = chart?.points ?? [];
|
||||||
|
const activeIndex = selectedIndex !== null && selectedIndex < points.length ? selectedIndex : points.length - 1;
|
||||||
|
const active = points[activeIndex];
|
||||||
|
const delta = chart?.deltaKg ?? 0;
|
||||||
|
const deltaLabel = `${delta > 0 ? "+" : delta < 0 ? "−" : "±"}${formatKg(Math.abs(delta))}`;
|
||||||
|
const spansLong = points.length > 1 && points[points.length - 1].time - points[0].time > LONG_SPAN_MS;
|
||||||
|
const axisDateOptions: Intl.DateTimeFormatOptions = spansLong
|
||||||
|
? { month: "short", year: "2-digit" }
|
||||||
|
: { day: "2-digit", month: "2-digit" };
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
points.length > 1
|
||||||
|
? t("health.weightChartA11y", {
|
||||||
|
from: formatKg(points[0].kg),
|
||||||
|
fromDate: formatDate(points[0].time, { dateStyle: "medium" }),
|
||||||
|
to: formatKg(points[points.length - 1].kg),
|
||||||
|
toDate: formatDate(points[points.length - 1].time, { dateStyle: "medium" }),
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={styles.card}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.eyebrow}>{t("health.weightChartTitle")}</Text>
|
||||||
|
{active && (
|
||||||
|
<Text style={styles.readout}>
|
||||||
|
{formatKg(active.kg)}
|
||||||
|
<Text style={styles.readoutDate}>{" "}{formatDate(active.time, { dateStyle: "medium" })}</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.deltaChip}>
|
||||||
|
<Text style={styles.deltaText}>{deltaLabel}</Text>
|
||||||
|
<Text style={styles.deltaCaption}>{t("health.weightChartSinceFirst")}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onLayout={onLayout}
|
||||||
|
onPress={(e) => chart && setSelectedIndex(nearestPointIndex(chart.points, e.nativeEvent.locationX))}
|
||||||
|
accessibilityRole="image"
|
||||||
|
accessibilityLabel={summary}
|
||||||
|
style={styles.plot}
|
||||||
|
>
|
||||||
|
{chart && (
|
||||||
|
<Svg width={width} height={CHART_HEIGHT}>
|
||||||
|
<Defs>
|
||||||
|
<LinearGradient id="weightArea" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<Stop offset="0" stopColor={colors.chartLine} stopOpacity={0.2} />
|
||||||
|
<Stop offset="1" stopColor={colors.chartLine} stopOpacity={0} />
|
||||||
|
</LinearGradient>
|
||||||
|
</Defs>
|
||||||
|
|
||||||
|
{chart.yTicks.map((tick, i) => (
|
||||||
|
<Line
|
||||||
|
key={`grid-${i}`}
|
||||||
|
x1={BOX.left}
|
||||||
|
x2={width - BOX.right}
|
||||||
|
y1={tick.y}
|
||||||
|
y2={tick.y}
|
||||||
|
stroke={colors.hairlineGray}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{chart.yTicks.map((tick, i) =>
|
||||||
|
chart.yTicks.length <= MAX_LABELED_Y_TICKS || i % 2 === 0 ? (
|
||||||
|
<SvgText
|
||||||
|
key={`ylabel-${i}`}
|
||||||
|
x={BOX.left - 6}
|
||||||
|
y={tick.y + AXIS_FONT_SIZE / 3}
|
||||||
|
fontSize={AXIS_FONT_SIZE}
|
||||||
|
fontFamily={fontFamily.regular}
|
||||||
|
fill={colors.quietGray}
|
||||||
|
textAnchor="end"
|
||||||
|
>
|
||||||
|
{tick.kg.toLocaleString(i18n.language, {
|
||||||
|
minimumFractionDigits: chart.yTickDecimals,
|
||||||
|
maximumFractionDigits: chart.yTickDecimals,
|
||||||
|
})}
|
||||||
|
</SvgText>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Path d={chart.areaPath} fill="url(#weightArea)" />
|
||||||
|
<Path
|
||||||
|
d={chart.linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={colors.chartLine}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<Line
|
||||||
|
x1={active.x}
|
||||||
|
x2={active.x}
|
||||||
|
y1={BOX.top}
|
||||||
|
y2={chart.baselineY}
|
||||||
|
stroke={colors.quietGray}
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chart.points.map((p, i) => (
|
||||||
|
<Circle
|
||||||
|
key={`pt-${i}`}
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={i === activeIndex ? SELECTED_RADIUS : MARKER_RADIUS}
|
||||||
|
fill={i === activeIndex ? colors.chartLine : colors.pureWhite}
|
||||||
|
stroke={i === activeIndex ? colors.pureWhite : colors.chartLine}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{chart.xLabelIndices.map((i) => {
|
||||||
|
const p = chart.points[i];
|
||||||
|
const isFirst = i === chart.xLabelIndices[0] && p.x - BOX.left < 20;
|
||||||
|
const isLast = i === chart.points.length - 1;
|
||||||
|
return (
|
||||||
|
<SvgText
|
||||||
|
key={`xlabel-${i}`}
|
||||||
|
x={p.x}
|
||||||
|
y={CHART_HEIGHT - 6}
|
||||||
|
fontSize={AXIS_FONT_SIZE}
|
||||||
|
fontFamily={fontFamily.regular}
|
||||||
|
fill={colors.quietGray}
|
||||||
|
textAnchor={isLast ? "end" : isFirst ? "start" : "middle"}
|
||||||
|
>
|
||||||
|
{formatDate(p.time, axisDateOptions)}
|
||||||
|
</SvgText>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Svg>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(colors: ColorTokens) {
|
||||||
|
const typography: Typography = getTypography(colors);
|
||||||
|
return StyleSheet.create({
|
||||||
|
card: { gap: 8 },
|
||||||
|
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", gap: 12 },
|
||||||
|
eyebrow: { ...typography.label },
|
||||||
|
readout: { ...typography.title, fontSize: 20, marginTop: 2 },
|
||||||
|
readoutDate: { ...typography.caption, fontSize: 12 },
|
||||||
|
deltaChip: { alignItems: "flex-end" },
|
||||||
|
deltaText: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||||
|
deltaCaption: { ...typography.caption, fontSize: 11 },
|
||||||
|
plot: { height: CHART_HEIGHT },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,9 +6,12 @@ import { Plus, Trash2 } from "lucide-react-native";
|
|||||||
import { useTRPC } from "../../api/trpc";
|
import { useTRPC } from "../../api/trpc";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { TextField } from "../TextField";
|
import { TextField } from "../TextField";
|
||||||
|
import { DateField } from "../DateField";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Card } from "../Card";
|
import { Card } from "../Card";
|
||||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
import { healthSectionStyles } from "./healthSectionStyles";
|
||||||
|
import { WeightChart } from "./WeightChart";
|
||||||
|
import type { ColorTokens } from "../../theme/tokens";
|
||||||
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
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. */
|
/** Port of web's health.ts weightLog CRUD — see src/app/(app)/pets/[petId]/health/page.tsx's Gewicht tab. */
|
||||||
@@ -65,7 +68,7 @@ export function WeightLogSection({ petId }: { petId: string }) {
|
|||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<Card style={styles.form}>
|
<Card style={styles.form}>
|
||||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||||
<TextField placeholder={t("health.weightKgPlaceholder")} value={weightKg} onChangeText={setWeightKg} keyboardType="decimal-pad" />
|
<TextField placeholder={t("health.weightKgPlaceholder")} value={weightKg} onChangeText={setWeightKg} keyboardType="decimal-pad" />
|
||||||
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
|
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
|
||||||
<Button
|
<Button
|
||||||
@@ -81,36 +84,32 @@ export function WeightLogSection({ petId }: { petId: string }) {
|
|||||||
) : logs.length === 0 ? (
|
) : logs.length === 0 ? (
|
||||||
<Text style={styles.empty}>{t("health.weightEmpty")}</Text>
|
<Text style={styles.empty}>{t("health.weightEmpty")}</Text>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
<>
|
||||||
<View key={log.id} style={styles.row}>
|
<WeightChart entries={logs} />
|
||||||
<View style={styles.rowText}>
|
{logs.map((log) => (
|
||||||
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
|
<View key={log.id} style={styles.row}>
|
||||||
<Text style={styles.rowSecondary}>
|
<View style={styles.rowText}>
|
||||||
{new Date(log.date).toLocaleDateString(i18n.language)}
|
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
|
||||||
{log.notes ? ` · ${log.notes}` : ""}
|
<Text style={styles.rowSecondary}>
|
||||||
</Text>
|
{new Date(log.date).toLocaleDateString(i18n.language)}
|
||||||
|
{log.notes ? ` · ${log.notes}` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
|
||||||
|
<Trash2 color={colors.quietGray} size={16} />
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
|
))}
|
||||||
<Trash2 color={colors.quietGray} size={16} />
|
</>
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
const typography: Typography = getTypography(colors);
|
const base = healthSectionStyles(colors);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
section: { gap: 10 },
|
...base,
|
||||||
sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
row: { ...base.row, alignItems: "center" },
|
||||||
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 },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { View, Text, Pressable, Switch, Share, Linking, Alert, StyleSheet } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Siren } from "lucide-react-native";
|
||||||
|
import { useTRPC } from "../../api/trpc";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { TextField } from "../TextField";
|
||||||
|
import { DateField } from "../DateField";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { env } from "../../lib/env";
|
||||||
|
import { displayDateToIso, isoToDisplayDate, todayAsDisplayDate } from "../../lib/date-format";
|
||||||
|
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||||
|
|
||||||
|
type Props = { petId: string; petName: string; isDeceased: boolean };
|
||||||
|
|
||||||
|
type Form = {
|
||||||
|
date: string; // DD.MM.YYYY
|
||||||
|
time: string; // HH:MM
|
||||||
|
lastSeenPlace: string;
|
||||||
|
description: string;
|
||||||
|
contactPhone: string;
|
||||||
|
contactEmail: string;
|
||||||
|
allowIndexing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIME_PATTERN = /^([01]?\d|2[0-3]):[0-5]\d$/;
|
||||||
|
const RED = "#dc2626";
|
||||||
|
|
||||||
|
function pad(n: number): string {
|
||||||
|
return String(n).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromDate(date: Date): Pick<Form, "date" | "time"> {
|
||||||
|
const iso = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||||
|
return { date: isoToDisplayDate(iso), time: `${pad(date.getHours())}:${pad(date.getMinutes())}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Datum + Uhrzeit (Ortszeit) → ISO-Zeitpunkt, oder null bei ungültiger Eingabe. */
|
||||||
|
function toIsoTimestamp(form: Form): string | null {
|
||||||
|
const iso = displayDateToIso(form.date);
|
||||||
|
if (!iso || !TIME_PATTERN.test(form.time)) return null;
|
||||||
|
const [h, m] = form.time.split(":");
|
||||||
|
const date = new Date(`${iso}T${pad(Number(h))}:${m}:00`);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vermisst-Meldung in „Profil bearbeiten“ — Port von web
|
||||||
|
* src/components/lost/LostReportSettings.tsx. Melden (Push an Follower und
|
||||||
|
* gefolgte Tiere), Angaben ergänzen, Link teilen, „Wieder da!“.
|
||||||
|
*/
|
||||||
|
export function LostReportSettings({ petId, petName, isDeceased }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
|
const { data: report, isPending } = useQuery(trpc.lostReports.mine.queryOptions({ petId }));
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [notifyFound, setNotifyFound] = useState(true);
|
||||||
|
const [form, setForm] = useState<Form | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 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(title, message);
|
||||||
|
};
|
||||||
|
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.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.foundTitle"), t("lostReport.foundToast", { name: petName })),
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDeceased || isPending) return null;
|
||||||
|
|
||||||
|
const current: Form =
|
||||||
|
form ??
|
||||||
|
(report
|
||||||
|
? {
|
||||||
|
...formFromDate(new Date(report.lastSeenAt)),
|
||||||
|
lastSeenPlace: report.lastSeenPlace,
|
||||||
|
description: report.description ?? "",
|
||||||
|
contactPhone: report.contactPhone ?? "",
|
||||||
|
contactEmail: report.contactEmail ?? "",
|
||||||
|
allowIndexing: report.allowIndexing,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
date: todayAsDisplayDate(),
|
||||||
|
time: "",
|
||||||
|
lastSeenPlace: "",
|
||||||
|
description: "",
|
||||||
|
contactPhone: "",
|
||||||
|
contactEmail: "",
|
||||||
|
allowIndexing: false,
|
||||||
|
});
|
||||||
|
const set = (patch: Partial<Form>) => setForm({ ...current, ...patch });
|
||||||
|
const publicUrl = report ? `${env.apiUrl}/vermisst/${report.code}` : "";
|
||||||
|
|
||||||
|
function payload() {
|
||||||
|
const lastSeenAt = toIsoTimestamp(current);
|
||||||
|
if (!lastSeenAt || !current.lastSeenPlace.trim()) {
|
||||||
|
setError(t("lostReport.invalidForm"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
return {
|
||||||
|
petId,
|
||||||
|
lastSeenAt,
|
||||||
|
lastSeenPlace: current.lastSeenPlace,
|
||||||
|
description: current.description,
|
||||||
|
contactPhone: current.contactPhone,
|
||||||
|
contactEmail: current.contactEmail,
|
||||||
|
allowIndexing: current.allowIndexing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!report && !isOpen) {
|
||||||
|
return (
|
||||||
|
<View style={styles.teaser}>
|
||||||
|
<Text style={styles.teaserText}>{t("lostReport.teaser", { name: petName })}</Text>
|
||||||
|
<Pressable style={styles.teaserButton} onPress={() => setIsOpen(true)} accessibilityRole="button">
|
||||||
|
<Siren size={16} color={RED} />
|
||||||
|
<Text style={styles.teaserButtonText}>{t("lostReport.open")}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.headingRow}>
|
||||||
|
<Siren size={18} color={RED} />
|
||||||
|
<Text style={styles.heading}>
|
||||||
|
{report ? t("lostReport.activeHeading", { name: petName }) : t("lostReport.formHeading", { name: petName })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.hint}>{report ? t("lostReport.activeHint") : t("lostReport.formHint", { name: petName })}</Text>
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.link} onPress={() => void Linking.openURL(publicUrl)}>
|
||||||
|
{publicUrl}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title={t("lostReport.share")}
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => void Share.share({ message: `${t("lostReport.shareTitle", { name: petName })} ${publicUrl}` })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.label}>{t("lostReport.lastSeenAt")}</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.flex}>
|
||||||
|
<DateField placeholder={t("health.weightDatePlaceholder")} value={current.date} onChangeText={(date) => set({ date })} />
|
||||||
|
</View>
|
||||||
|
<TextField
|
||||||
|
style={styles.time}
|
||||||
|
placeholder={t("lostReport.timePlaceholder")}
|
||||||
|
keyboardType="numbers-and-punctuation"
|
||||||
|
maxLength={5}
|
||||||
|
value={current.time}
|
||||||
|
onChangeText={(time) => set({ time })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.label}>{t("lostReport.lastSeenPlace")}</Text>
|
||||||
|
<TextField
|
||||||
|
maxLength={120}
|
||||||
|
placeholder={t("lostReport.lastSeenPlacePlaceholder")}
|
||||||
|
value={current.lastSeenPlace}
|
||||||
|
onChangeText={(lastSeenPlace) => set({ lastSeenPlace })}
|
||||||
|
/>
|
||||||
|
<Text style={styles.label}>{t("lostReport.description")}</Text>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
maxLength={1000}
|
||||||
|
style={styles.multiline}
|
||||||
|
placeholder={t("lostReport.descriptionPlaceholder")}
|
||||||
|
value={current.description}
|
||||||
|
onChangeText={(description) => set({ description })}
|
||||||
|
/>
|
||||||
|
<Text style={styles.hint}>{t("lostReport.contactHint")}</Text>
|
||||||
|
<Text style={styles.label}>{t("lostReport.contactPhone")}</Text>
|
||||||
|
<TextField keyboardType="phone-pad" maxLength={40} value={current.contactPhone} onChangeText={(contactPhone) => set({ contactPhone })} />
|
||||||
|
<Text style={styles.label}>{t("lostReport.contactEmail")}</Text>
|
||||||
|
<TextField
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
maxLength={120}
|
||||||
|
value={current.contactEmail}
|
||||||
|
onChangeText={(contactEmail) => set({ contactEmail })}
|
||||||
|
/>
|
||||||
|
<View style={styles.switchRow}>
|
||||||
|
<View style={styles.flex}>
|
||||||
|
<Text style={styles.switchLabel}>{t("lostReport.allowIndexing")}</Text>
|
||||||
|
<Text style={styles.hint}>{t("lostReport.allowIndexingHint")}</Text>
|
||||||
|
</View>
|
||||||
|
<Switch value={current.allowIndexing} onValueChange={(allowIndexing) => set({ allowIndexing })} />
|
||||||
|
</View>
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
|
{report ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
title={t("lostReport.saveChanges")}
|
||||||
|
variant="outline"
|
||||||
|
disabled={update.isPending || !form}
|
||||||
|
onPress={() => {
|
||||||
|
const p = payload();
|
||||||
|
if (p) update.mutate(p);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<View style={styles.switchRow}>
|
||||||
|
<Text style={[styles.switchLabel, styles.flex]}>{t("lostReport.notifyFound", { name: petName })}</Text>
|
||||||
|
<Switch value={notifyFound} onValueChange={setNotifyFound} />
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
title={t("lostReport.markFound", { name: petName })}
|
||||||
|
disabled={markFound.isPending}
|
||||||
|
onPress={() => markFound.mutate({ petId, notifyAudience: notifyFound })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
title={t("lostReport.submit")}
|
||||||
|
disabled={create.isPending}
|
||||||
|
onPress={() => {
|
||||||
|
const p = payload();
|
||||||
|
if (p) create.mutate(p);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title={t("lostReport.cancel")}
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setForm(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.caption },
|
||||||
|
teaserButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 6,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.alertRedTint,
|
||||||
|
},
|
||||||
|
teaserButtonText: { ...typography.body, color: RED, fontFamily: typography.title.fontFamily },
|
||||||
|
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.caption },
|
||||||
|
label: { ...typography.caption, marginTop: 4 },
|
||||||
|
link: { ...typography.body, textDecorationLine: "underline" },
|
||||||
|
row: { flexDirection: "row", gap: 8 },
|
||||||
|
flex: { flex: 1 },
|
||||||
|
time: { width: 88 },
|
||||||
|
multiline: { height: 88, textAlignVertical: "top", paddingTop: 12 },
|
||||||
|
switchRow: { flexDirection: "row", alignItems: "center", gap: 12, marginTop: 4 },
|
||||||
|
switchLabel: { ...typography.body },
|
||||||
|
divider: { height: 1, backgroundColor: colors.hairlineGray, marginVertical: 8 },
|
||||||
|
error: { ...typography.body, color: colors.alertRed },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { useTheme } from "../../context/ThemeContext";
|
|||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { RedeemCodeForm } from "../referrals/RedeemCodeForm";
|
import { RedeemCodeForm } from "../referrals/RedeemCodeForm";
|
||||||
import { radii, getTypography, type ColorTokens } from "../../theme/tokens";
|
import { radii, getTypography, type ColorTokens } from "../../theme/tokens";
|
||||||
|
import { StepScaffold } from "./flow/StepScaffold";
|
||||||
|
|
||||||
const SOURCES = [
|
const SOURCES = [
|
||||||
"FRIENDS",
|
"FRIENDS",
|
||||||
@@ -46,9 +47,16 @@ export function AcquisitionStep({ canRedeem, onDone }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<StepScaffold
|
||||||
<Text style={styles.title}>{t("referrals.onboardingTitle")}</Text>
|
title={t("referrals.onboardingTitle")}
|
||||||
<Text style={styles.subtitle}>{t("referrals.onboardingSubtitle")}</Text>
|
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">
|
<View style={styles.chips} accessibilityRole="radiogroup">
|
||||||
{SOURCES.map((option) => {
|
{SOURCES.map((option) => {
|
||||||
@@ -82,18 +90,13 @@ export function AcquisitionStep({ canRedeem, onDone }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button title={saveSource.isPending ? "…" : t("common.continue")} disabled={saveSource.isPending} onPress={handleContinue} style={styles.cta} />
|
</StepScaffold>
|
||||||
<Button title={t("common.skip")} variant="outline" onPress={onDone} />
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
const typography = getTypography(colors);
|
const typography = getTypography(colors);
|
||||||
return StyleSheet.create({
|
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 },
|
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 12 },
|
||||||
chip: { paddingHorizontal: 14, paddingVertical: 9, borderRadius: 999, borderWidth: 1, borderColor: colors.hairlineGray },
|
chip: { paddingHorizontal: 14, paddingVertical: 9, borderRadius: 999, borderWidth: 1, borderColor: colors.hairlineGray },
|
||||||
chipSelected: { borderColor: colors.marigoldOrange, backgroundColor: colors.marigoldTint },
|
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 },
|
redeemBox: { padding: 14, borderRadius: radii.xl, borderWidth: 1, borderColor: colors.hairlineGray, marginBottom: 12 },
|
||||||
redeemedRow: { flexDirection: "row", alignItems: "center", gap: 8 },
|
redeemedRow: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||||
redeemedText: { ...typography.body, flex: 1 },
|
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 { 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 { useTranslation } from "react-i18next";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
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 { useTRPC } from "../../api/trpc";
|
||||||
import { Button } from "../Button";
|
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";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
type Props = { onContinue: () => void };
|
type Props = { onContinue: () => void };
|
||||||
@@ -30,61 +31,62 @@ export function ArachnophobiaOptIn({ onContinue }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<StepScaffold
|
||||||
<View style={styles.titleRow}>
|
title={t("onboarding.spiderTitle")}
|
||||||
<Bug color={colors.marigoldOrange} size={20} />
|
subtitle={t("onboarding.spiderSubtitle")}
|
||||||
<Text style={styles.title}>{t("onboarding.arachnophobiaTitle")}</Text>
|
footer={
|
||||||
</View>
|
<Button
|
||||||
<Text style={styles.subtitle}>{t("onboarding.arachnophobiaSubtitle")}</Text>
|
title={t("common.continue")}
|
||||||
|
isLoading={setPreferenceMutation.isPending}
|
||||||
<Pressable style={styles.checkboxRow} onPress={() => setHideSpiders((v) => !v)}>
|
onPress={() => setPreferenceMutation.mutate({ hideSpiders })}
|
||||||
<View style={[styles.checkbox, hideSpiders && styles.checkboxChecked]}>
|
style={styles.cta}
|
||||||
{hideSpiders && <Check color={colors.pureWhite} size={14} />}
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.card}
|
||||||
|
onPress={() => setHideSpiders((v) => !v)}
|
||||||
|
accessibilityRole="switch"
|
||||||
|
accessibilityState={{ checked: hideSpiders }}
|
||||||
|
>
|
||||||
|
<View style={styles.icon}>
|
||||||
|
<EyeOff color={colors.marigoldDeep} size={20} />
|
||||||
</View>
|
</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>
|
</Pressable>
|
||||||
|
|
||||||
<Text style={styles.hint}>{t("onboarding.arachnophobiaHint")}</Text>
|
<Text style={styles.hint}>{t("onboarding.arachnophobiaHint")}</Text>
|
||||||
|
</StepScaffold>
|
||||||
<Button
|
|
||||||
title={setPreferenceMutation.isPending ? "…" : t("common.continue")}
|
|
||||||
disabled={setPreferenceMutation.isPending}
|
|
||||||
onPress={() => setPreferenceMutation.mutate({ hideSpiders })}
|
|
||||||
style={styles.cta}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
const typography: Typography = getTypography(colors);
|
const typography: Typography = getTypography(colors);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: { padding: 24, gap: 4 },
|
card: {
|
||||||
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",
|
flexDirection: "row",
|
||||||
gap: 12,
|
alignItems: "center",
|
||||||
padding: 14,
|
gap: 14,
|
||||||
borderRadius: radii.xl,
|
padding: 16,
|
||||||
|
borderRadius: 18,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.hairlineGray,
|
borderColor: colors.hairlineGray,
|
||||||
marginBottom: 16,
|
backgroundColor: colors.pureWhite,
|
||||||
},
|
},
|
||||||
checkbox: {
|
icon: { width: 44, height: 44, borderRadius: 13, backgroundColor: colors.marigoldTint, alignItems: "center", justifyContent: "center" },
|
||||||
width: 20,
|
cardText: { flex: 1 },
|
||||||
height: 20,
|
cardTitle: { fontFamily: fontFamily.semiBold, fontSize: 15, color: colors.ink },
|
||||||
borderRadius: 5,
|
cardSub: { ...typography.body, fontSize: 13, color: colors.quietGray },
|
||||||
borderWidth: 1.5,
|
hint: { ...typography.body, fontSize: 13, color: colors.quietGray, marginTop: 4 },
|
||||||
borderColor: colors.hairlineGray,
|
cta: { height: 54, borderRadius: 15 },
|
||||||
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 },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2,28 +2,29 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
|
import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { ImagePlus, Camera } from "lucide-react-native";
|
||||||
import { useTRPC } from "../../api/trpc";
|
import { useTRPC } from "../../api/trpc";
|
||||||
import { uploadImageToSupabase } from "../../lib/upload";
|
import { uploadPickedImage } from "../../lib/upload";
|
||||||
import { resizeForUpload } from "../../lib/resize-image";
|
|
||||||
import { PetAvatar } from "../PetAvatar";
|
|
||||||
import { Button } from "../Button";
|
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";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
petId: string;
|
petId: string;
|
||||||
petName: 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
|
* Port of web's onboarding avatar step (AvatarUpload, invoked from
|
||||||
* onboarding/pet/page.tsx once createdPetId exists). Uses the media router
|
* onboarding/pet/page.tsx once createdPetId exists). Uses the media router
|
||||||
* (not posts/stories) — media.getPresignedUrl scopes keys to `avatars/`.
|
* (not posts/stories) — media.getPresignedUrl scopes keys to `avatars/`.
|
||||||
* "Skip for now" is always available, matching web: an avatar is never
|
* An avatar is never required: "Done" finishes onboarding with or without a
|
||||||
* required to finish onboarding.
|
* 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) {
|
export function AvatarStep({ petId, petName, onDone }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -31,10 +32,11 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
|
|||||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
|
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
|
||||||
const [uploadedKey, setUploadedKey] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const getPresignedUrlMutation = useMutation(trpc.media.getPresignedUrl.mutationOptions());
|
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 confirmUploadMutation = useMutation(trpc.media.confirmUpload.mutationOptions());
|
||||||
|
|
||||||
const pickAndUpload = useCallback(
|
const pickAndUpload = useCallback(
|
||||||
@@ -46,28 +48,21 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resized = await resizeForUpload(picked.uri, picked.width, picked.height);
|
const { key } = await uploadPickedImage(picked, (file) => getPresignedUrlMutation.mutateAsync({ petId, ...file }));
|
||||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
|
||||||
petId,
|
|
||||||
contentType: resized.mimeType,
|
|
||||||
fileSize: resized.fileSize,
|
|
||||||
});
|
|
||||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
|
||||||
await confirmUploadMutation.mutateAsync({ petId, key });
|
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 {
|
} catch {
|
||||||
setError(t("onboarding.uploadError"));
|
setError(t("onboarding.uploadError"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[petId, getPresignedUrlMutation, confirmUploadMutation]
|
[petId, getPresignedUrlMutation, confirmUploadMutation, queryClient, trpc, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const pickFromGallery = useCallback(async () => {
|
const pickFromGallery = useCallback(async () => {
|
||||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
// Kein Berechtigungsdialog: der System-Bildwähler (iOS PHPicker, Android Photo Picker)
|
||||||
if (!permission.granted) {
|
// braucht keine Freigabe der Fotomediathek (Apple 5.1.1(iv), 07.10.).
|
||||||
Alert.alert(t("onboarding.galleryPermissionDenied"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await pickAndUpload(() => ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 }));
|
await pickAndUpload(() => ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 }));
|
||||||
}, [pickAndUpload]);
|
}, [pickAndUpload]);
|
||||||
|
|
||||||
@@ -78,84 +73,100 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await pickAndUpload(() => ImagePicker.launchCameraAsync({ quality: 0.8 }));
|
await pickAndUpload(() => ImagePicker.launchCameraAsync({ quality: 0.8 }));
|
||||||
}, [pickAndUpload]);
|
}, [pickAndUpload, t]);
|
||||||
|
|
||||||
const isUploading = getPresignedUrlMutation.isPending || confirmUploadMutation.isPending;
|
const isUploading = getPresignedUrlMutation.isPending || confirmUploadMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<StepScaffold
|
||||||
<Text style={styles.title}>{t("onboarding.avatarTitle")}</Text>
|
title={t("onboarding.photoTitle", { name: petName })}
|
||||||
<Text style={styles.subtitle}>{t("onboarding.avatarSubtitle")}</Text>
|
highlight={petName}
|
||||||
|
subtitle={t("onboarding.photoSubtitle", { name: petName })}
|
||||||
{asset ? (
|
footer={
|
||||||
<View style={styles.previewWrap}>
|
// Ohne „Überspringen“-Link (Apple 5.1.1(iv)): „Weiter“ führt auch ohne Foto weiter.
|
||||||
<RNImage source={{ uri: asset.uri }} style={styles.preview} />
|
<Button title={t("common.continue")} onPress={() => onDone(uploadedUri)} disabled={isUploading} style={styles.cta} />
|
||||||
{isUploading && (
|
}
|
||||||
<View style={styles.previewOverlay}>
|
>
|
||||||
<ActivityIndicator color={colors.pureWhite} />
|
<View style={styles.photoRing}>
|
||||||
</View>
|
<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>
|
</View>
|
||||||
) : (
|
</View>
|
||||||
<PetAvatar name={petName} avatarKey={null} size="lg" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
<View style={styles.pickerRow}>
|
<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} />
|
<ImagePlus color={colors.marigoldOrange} size={22} />
|
||||||
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
|
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
|
||||||
</Pressable>
|
</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} />
|
<Camera color={colors.marigoldOrange} size={22} />
|
||||||
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
|
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
</StepScaffold>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PHOTO_SIZE = 156;
|
||||||
|
|
||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
const typography: Typography = getTypography(colors);
|
const typography: Typography = getTypography(colors);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: { padding: 24, gap: 4, alignItems: "center" },
|
photoRing: {
|
||||||
title: { ...typography.title, fontSize: 18, textAlign: "center" },
|
alignSelf: "center",
|
||||||
subtitle: { ...typography.body, color: colors.quietGray, textAlign: "center", marginBottom: 20 },
|
padding: 8,
|
||||||
previewWrap: { width: 96, height: 96, borderRadius: 48, overflow: "hidden", backgroundColor: colors.hairlineGray },
|
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%" },
|
preview: { width: "100%", height: "100%" },
|
||||||
previewOverlay: {
|
previewOverlay: {
|
||||||
position: "absolute",
|
...StyleSheet.absoluteFill,
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "rgba(0,0,0,0.4)",
|
backgroundColor: "rgba(0,0,0,0.4)",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
error: { ...typography.body, color: colors.alertRed, marginTop: 12, textAlign: "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: {
|
pickerButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
height: 48,
|
height: 56,
|
||||||
borderRadius: radii.lg,
|
borderRadius: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1.5,
|
||||||
borderColor: colors.hairlineGray,
|
borderColor: colors.hairlineGray,
|
||||||
|
backgroundColor: colors.pureWhite,
|
||||||
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
pickerText: { ...typography.body, color: colors.ink },
|
pickerText: { fontFamily: fontFamily.semiBold, fontSize: 15, color: colors.ink },
|
||||||
cta: { marginTop: 20, alignSelf: "stretch" },
|
cta: { height: 54, borderRadius: 15 },
|
||||||
skipLink: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray, marginTop: 12 },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { View, Text, Alert, StyleSheet } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Ribbon } from "lucide-react-native";
|
||||||
|
import { useTRPC } from "../../api/trpc";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { DateField } from "../DateField";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||||
|
import { displayDateToIso, isoToDisplayDate, todayAsDisplayDate } from "../../lib/date-format";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
petId: string;
|
||||||
|
petName: string;
|
||||||
|
deceasedAt: Date | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** YYYY-MM-DD eines @db.Date-Werts (kommt als UTC-Mitternacht). */
|
||||||
|
function toIso(value: Date | string | null): string | null {
|
||||||
|
return value ? new Date(value).toISOString().slice(0, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gedenkmodus „In Erinnerung“ — Port von web src/components/pet/MemorialSettings.tsx.
|
||||||
|
* Datum setzen (mit Bestätigung, Follower werden einmalig still informiert),
|
||||||
|
* korrigieren oder beenden. Server: pets.setDeceased.
|
||||||
|
*/
|
||||||
|
export function MemorialSettings({ petId, petName, deceasedAt }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
|
const savedIso = toIso(deceasedAt);
|
||||||
|
const isDeceased = savedIso != null;
|
||||||
|
const [date, setDate] = useState(() => (savedIso ? isoToDisplayDate(savedIso) : todayAsDisplayDate()));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation(
|
||||||
|
trpc.pets.setDeceased.mutationOptions({
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
void queryClient.invalidateQueries();
|
||||||
|
Alert.alert(variables.deceasedAt ? t("inMemory.saved") : t("inMemory.removed"));
|
||||||
|
},
|
||||||
|
onError: () => Alert.alert(t("inMemory.saveFailed")),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Gültiges, nicht zukünftiges Datum als YYYY-MM-DD — sonst Fehlermeldung. */
|
||||||
|
function validIso(): string | null {
|
||||||
|
const iso = displayDateToIso(date);
|
||||||
|
const todayIso = displayDateToIso(todayAsDisplayDate());
|
||||||
|
if (!iso || (todayIso && iso > todayIso)) {
|
||||||
|
setError(t("inMemory.invalidDate"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate() {
|
||||||
|
const iso = validIso();
|
||||||
|
if (!iso) return;
|
||||||
|
Alert.alert(t("inMemory.confirmTitle", { name: petName }), t("inMemory.confirmDescription", { name: petName }), [
|
||||||
|
{ text: t("inMemory.cancel"), style: "cancel" },
|
||||||
|
{ text: t("inMemory.confirm"), onPress: () => mutation.mutate({ petId, deceasedAt: iso }) },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDate() {
|
||||||
|
const iso = validIso();
|
||||||
|
if (iso && iso !== savedIso) mutation.mutate({ petId, deceasedAt: iso });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.headingRow}>
|
||||||
|
<Ribbon size={16} color={colors.ink} />
|
||||||
|
<Text style={styles.heading}>{t("inMemory.settingsHeading")}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
{isDeceased ? t("inMemory.activeHint", { name: petName }) : t("inMemory.settingsHint")}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.label}>{t("inMemory.dateLabel")}</Text>
|
||||||
|
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||||
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
{isDeceased ? (
|
||||||
|
<>
|
||||||
|
<Button title={t("inMemory.saveDate")} variant="outline" disabled={mutation.isPending} onPress={saveDate} />
|
||||||
|
<Button
|
||||||
|
title={t("inMemory.remove")}
|
||||||
|
variant="outline"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onPress={() => mutation.mutate({ petId, deceasedAt: null })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button title={t("inMemory.activate")} variant="outline" disabled={mutation.isPending} onPress={activate} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(colors: ColorTokens) {
|
||||||
|
const typography: Typography = getTypography(colors);
|
||||||
|
return StyleSheet.create({
|
||||||
|
section: { marginTop: 24, gap: 8 },
|
||||||
|
headingRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||||
|
heading: { ...typography.title, fontSize: 16 },
|
||||||
|
hint: { ...typography.caption },
|
||||||
|
label: { ...typography.caption, marginTop: 4 },
|
||||||
|
error: { ...typography.body, color: colors.alertRed },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { View, Text, Pressable, Alert, StyleSheet } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check } from "lucide-react-native";
|
||||||
|
import { useTRPC } from "../../api/trpc";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||||
|
|
||||||
|
type DmPolicy = "EVERYONE" | "FOLLOWERS_ONLY";
|
||||||
|
type FollowerVisibility = "EVERYONE" | "FOLLOWERS_ONLY" | "NOBODY";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
petId: string;
|
||||||
|
petName: string;
|
||||||
|
dmPolicy: DmPolicy;
|
||||||
|
followerVisibility: FollowerVisibility;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port of the "Privatsphäre" block from web's src/app/(app)/pets/[petId]/edit/page.tsx
|
||||||
|
* (found.md: only editable on web so far). Each choice is saved immediately via
|
||||||
|
* pets.update — no separate save button, so it can't get lost in the long form.
|
||||||
|
*/
|
||||||
|
export function PrivacySettings({ petId, petName, dmPolicy, followerVisibility }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
|
|
||||||
|
const update = useMutation(
|
||||||
|
trpc.pets.update.mutationOptions({
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: trpc.pets.byId.queryOptions({ petId }).queryKey }),
|
||||||
|
onError: () => Alert.alert(t("privacy.saveFailed")),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const dmOptions: { value: DmPolicy; label: string }[] = [
|
||||||
|
{ value: "EVERYONE", label: t("privacy.everyone") },
|
||||||
|
{ value: "FOLLOWERS_ONLY", label: t("privacy.onlyFollowedPets") },
|
||||||
|
];
|
||||||
|
const followerOptions: { value: FollowerVisibility; label: string }[] = [
|
||||||
|
{ value: "EVERYONE", label: t("privacy.everyone") },
|
||||||
|
{ value: "FOLLOWERS_ONLY", label: t("privacy.onlyMyFollowers") },
|
||||||
|
{ value: "NOBODY", label: t("privacy.onlyMe") },
|
||||||
|
];
|
||||||
|
const followerHint = {
|
||||||
|
EVERYONE: t("privacy.followerEveryoneHint", { name: petName }),
|
||||||
|
FOLLOWERS_ONLY: t("privacy.followerFollowersOnlyHint", { name: petName }),
|
||||||
|
NOBODY: t("privacy.followerNobodyHint", { name: petName }),
|
||||||
|
}[followerVisibility];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.heading}>{t("privacy.heading")}</Text>
|
||||||
|
|
||||||
|
<Text style={styles.question}>{t("privacy.whoCanMessage", { name: petName })}</Text>
|
||||||
|
<OptionGroup
|
||||||
|
options={dmOptions}
|
||||||
|
value={dmPolicy}
|
||||||
|
disabled={update.isPending}
|
||||||
|
onChange={(value) => update.mutate({ petId, dmPolicy: value })}
|
||||||
|
styles={styles}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
{dmPolicy === "FOLLOWERS_ONLY" ? t("privacy.dmFollowersOnlyHint") : t("privacy.dmEveryoneHint")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.question}>{t("privacy.whoCanSeeFollowers", { name: petName })}</Text>
|
||||||
|
<OptionGroup
|
||||||
|
options={followerOptions}
|
||||||
|
value={followerVisibility}
|
||||||
|
disabled={update.isPending}
|
||||||
|
onChange={(value) => update.mutate({ petId, followerVisibility: value })}
|
||||||
|
styles={styles}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
<Text style={styles.hint}>{followerHint}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionGroup<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
styles,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
value: T;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
styles: ReturnType<typeof makeStyles>;
|
||||||
|
colors: ColorTokens;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.group} accessibilityRole="radiogroup">
|
||||||
|
{options.map((option, index) => {
|
||||||
|
const isSelected = option.value === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
style={[styles.option, index > 0 && styles.optionDivider]}
|
||||||
|
onPress={() => !isSelected && onChange(option.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityState={{ checked: isSelected, disabled }}
|
||||||
|
>
|
||||||
|
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>{option.label}</Text>
|
||||||
|
{isSelected && <Check color={colors.marigoldOrange} size={18} />}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(colors: ColorTokens) {
|
||||||
|
const typography: Typography = getTypography(colors);
|
||||||
|
return StyleSheet.create({
|
||||||
|
section: { marginTop: 24, gap: 8 },
|
||||||
|
heading: { ...typography.title, fontSize: 16 },
|
||||||
|
question: { ...typography.body, fontFamily: typography.title.fontFamily, marginTop: 8 },
|
||||||
|
group: { borderWidth: 1, borderColor: colors.hairlineGray, borderRadius: radii.lg, overflow: "hidden" },
|
||||||
|
option: {
|
||||||
|
minHeight: 48,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
backgroundColor: colors.pureWhite,
|
||||||
|
},
|
||||||
|
optionDivider: { borderTopWidth: 1, borderTopColor: colors.hairlineGray },
|
||||||
|
optionText: { ...typography.body },
|
||||||
|
optionTextSelected: { fontFamily: typography.title.fontFamily, color: colors.marigoldOrange },
|
||||||
|
hint: { ...typography.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 { useTheme } from "../../context/ThemeContext";
|
||||||
import { ReferralIcon } from "../../lib/referral-icons";
|
import { ReferralIcon } from "../../lib/referral-icons";
|
||||||
import type { ReferralRankView } from "../../hooks/useReferralRanks";
|
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 = {
|
type Props = {
|
||||||
ranks: ReferralRankView[];
|
ranks: ReferralRankView[];
|
||||||
@@ -14,14 +16,22 @@ type Props = {
|
|||||||
nextRankId: string | null;
|
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) {
|
export function RankLadder({ ranks, achievedRankIds, qualifiedCount, nextRankId }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||||
const activeRanks = ranks.filter((rank) => rank.isActive);
|
const activeRanks = ranks.filter((rank) => rank.isActive).sort((a, b) => a.threshold - b.threshold);
|
||||||
const next = activeRanks.find((rank) => rank.id === nextRankId) ?? null;
|
|
||||||
const progress = next ? Math.min(100, Math.round((qualifiedCount / next.threshold) * 100)) : 100;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.card}>
|
<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.heading}>{t("referrals.ranksHeading")}</Text>
|
||||||
<Text style={styles.muted}>{t("referrals.countedInvites", { count: qualifiedCount })}</Text>
|
<Text style={styles.muted}>{t("referrals.countedInvites", { count: qualifiedCount })}</Text>
|
||||||
</View>
|
</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}>
|
<View>
|
||||||
{activeRanks.map((rank) => {
|
{activeRanks.map((rank, i) => {
|
||||||
const isAchieved = achievedRankIds.has(rank.id);
|
const isAchieved = achievedRankIds.has(rank.id);
|
||||||
|
const isNext = rank.id === nextRankId;
|
||||||
|
const isLast = i === activeRanks.length - 1;
|
||||||
return (
|
return (
|
||||||
<View
|
<View key={rank.id} style={[styles.row, isNext && { backgroundColor: withAlpha(rank.color, 0.12) }]}>
|
||||||
key={rank.id}
|
{isLast ? null : <View style={[styles.connector, isAchieved && { backgroundColor: rank.color }]} />}
|
||||||
style={[styles.row, isAchieved ? { borderColor: rank.color } : styles.rowPending]}
|
<View
|
||||||
>
|
style={[
|
||||||
<View style={[styles.iconCircle, { borderColor: rank.color, backgroundColor: isAchieved ? rank.color : "transparent" }]}>
|
styles.dot,
|
||||||
<ReferralIcon icon={rank.icon} size={16} color={isAchieved ? "#ffffff" : rank.color} />
|
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>
|
||||||
<View style={styles.rowText}>
|
<View style={styles.text}>
|
||||||
<Text style={styles.rankName}>{rank.name}</Text>
|
<Text style={[styles.name, !isAchieved && !isNext && styles.faded]}>{rank.name}</Text>
|
||||||
<Text style={styles.rankDescription}>{rank.description}</Text>
|
<Text style={styles.description}>{rank.description}</Text>
|
||||||
</View>
|
</View>
|
||||||
{isAchieved ? (
|
{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>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -73,18 +83,29 @@ export function RankLadder({ ranks, achievedRankIds, qualifiedCount, nextRankId
|
|||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
const typography = getTypography(colors);
|
const typography = getTypography(colors);
|
||||||
return StyleSheet.create({
|
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 },
|
headerRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "baseline", gap: 8 },
|
||||||
heading: { ...typography.title, fontSize: 16 },
|
heading: { ...typography.title, fontSize: 16 },
|
||||||
muted: { ...typography.body, fontSize: 13, color: colors.quietGray },
|
muted: { ...typography.body, fontSize: 13, color: colors.quietGray },
|
||||||
track: { height: 8, borderRadius: 4, backgroundColor: colors.whisperGray, overflow: "hidden" },
|
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 10, paddingHorizontal: 8, borderRadius: 16 },
|
||||||
fill: { height: 8, borderRadius: 4 },
|
// Line from this dot to the next one; dot centre = 8 (row padding) + DOT / 2.
|
||||||
list: { gap: 8, marginTop: 6 },
|
connector: {
|
||||||
row: { flexDirection: "row", alignItems: "center", gap: 12, padding: 10, borderRadius: radii.xl, borderWidth: 1 },
|
position: "absolute",
|
||||||
rowPending: { borderColor: colors.hairlineGray, borderStyle: "dashed" },
|
left: 8 + DOT / 2 - 1,
|
||||||
iconCircle: { width: 32, height: 32, borderRadius: 16, borderWidth: 2, alignItems: "center", justifyContent: "center" },
|
top: 10 + DOT,
|
||||||
rowText: { flex: 1 },
|
bottom: -10,
|
||||||
rankName: { ...typography.body, fontWeight: "600" },
|
width: 2,
|
||||||
rankDescription: { ...typography.body, fontSize: 12, color: colors.quietGray },
|
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 { Sun, Moon, SunMoon } from "lucide-react-native";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Darstellung (light/dark/auto) toggle. Extracted from pets.tsx's former
|
* Darstellung (light/dark/auto) toggle. Extracted from pets.tsx's former
|
||||||
@@ -49,7 +48,7 @@ export function AppearanceSection() {
|
|||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
const typography: Typography = getTypography(colors);
|
const typography: Typography = getTypography(colors);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
sectionTitle: sectionTitleStyle(colors),
|
sectionTitle: { ...typography.caption, marginBottom: 10 },
|
||||||
row: { flexDirection: "row", gap: 8 },
|
row: { flexDirection: "row", gap: 8 },
|
||||||
option: {
|
option: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -1,36 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { InviteCard } from "../referrals/InviteCard";
|
||||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ChevronRight, Gift } from "lucide-react-native";
|
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
|
||||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
|
||||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
|
||||||
|
|
||||||
/** Einstieg zum Empfehlungsprogramm (Bildschirm /invite) aus den Einstellungen. */
|
/** Einstieg zum Empfehlungsprogramm oben in den Einstellungen. */
|
||||||
export function InviteFriendsSection() {
|
export function InviteFriendsSection() {
|
||||||
const { t } = useTranslation();
|
return <InviteCard showRanksLink />;
|
||||||
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 },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,7 @@ import { View, Text, StyleSheet } from "react-native";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { LanguageSwitcher } from "../LanguageSwitcher";
|
import { LanguageSwitcher } from "../LanguageSwitcher";
|
||||||
import type { ColorTokens } from "../../theme/tokens";
|
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Section chrome (title + spacing) around the shared LanguageSwitcher — see
|
* Section chrome (title + spacing) around the shared LanguageSwitcher — see
|
||||||
@@ -26,6 +25,6 @@ export function LanguageSection() {
|
|||||||
|
|
||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
sectionTitle: sectionTitleStyle(colors),
|
sectionTitle: { ...getTypography(colors).caption, marginBottom: 10 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -69,6 +69,6 @@ function makeStyles(colors: ColorTokens) {
|
|||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
section: { gap: 12, alignItems: "center" },
|
section: { gap: 12, alignItems: "center" },
|
||||||
legalLink: { ...typography.body, fontSize: 13, color: colors.marigoldDeep },
|
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 { Alert } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "@clerk/expo";
|
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 { useActivePet } from "../../context/ActivePetContext";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
|
|
||||||
@@ -22,6 +25,17 @@ export function LogoutSection() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { signOut } = useAuth();
|
const { signOut } = useAuth();
|
||||||
const { clearActivePetId } = useActivePet();
|
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 = () => {
|
const confirmLogout = () => {
|
||||||
Alert.alert(t("pets.logoutConfirmTitle"), t("pets.logoutConfirmMessage"), [
|
Alert.alert(t("pets.logoutConfirmTitle"), t("pets.logoutConfirmMessage"), [
|
||||||
@@ -30,8 +44,7 @@ export function LogoutSection() {
|
|||||||
text: t("pets.logoutButton"),
|
text: t("pets.logoutButton"),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
clearActivePetId();
|
void logout();
|
||||||
void signOut();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useTRPC } from "../../api/trpc";
|
import { useTRPC } from "../../api/trpc";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opt-out for the PET_SUGGESTION cron notification
|
* Opt-out for the PET_SUGGESTION cron notification
|
||||||
@@ -53,7 +52,7 @@ export function PetSuggestionsSection() {
|
|||||||
function makeStyles(colors: ColorTokens) {
|
function makeStyles(colors: ColorTokens) {
|
||||||
const typography: Typography = getTypography(colors);
|
const typography: Typography = getTypography(colors);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
sectionTitle: sectionTitleStyle(colors),
|
sectionTitle: { ...typography.caption, marginBottom: 10 },
|
||||||
toggleRow: { flexDirection: "row", alignItems: "center", gap: 12 },
|
toggleRow: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||||
toggleText: { flex: 1 },
|
toggleText: { flex: 1 },
|
||||||
toggleLabel: { ...typography.body, fontSize: 12, color: colors.quietGray, marginTop: 2 },
|
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 };
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Send, Trash2, X } from "lucide-react-native";
|
||||||
|
import { useTRPC } from "../../api/trpc";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { PetAvatar } from "../PetAvatar";
|
||||||
|
import { TextField } from "../TextField";
|
||||||
|
import { formatPetName, formatRelativeTime } from "../../lib/format";
|
||||||
|
import { getTypography, radii, type ColorTokens } from "../../theme/tokens";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
storyId: string;
|
||||||
|
activePetId: string;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Wird nach Hinzufügen/Löschen aufgerufen, damit der Viewer den Zähler neu lädt. */
|
||||||
|
onChanged: () => void;
|
||||||
|
onPressPet: (petId: string) => void;
|
||||||
|
/** Abgelaufene Story in der 7-Tage-Ansicht des Erstellers: nur lesen. */
|
||||||
|
readOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port of web's StoryCommentsOverlay als Bottom-Sheet. Der Viewer pausiert die
|
||||||
|
* Story, solange das Sheet offen ist (anders als im Web — auf dem Handy
|
||||||
|
* verdeckt die Tastatur sonst die laufende Story und sie springt weiter,
|
||||||
|
* während man noch tippt).
|
||||||
|
*/
|
||||||
|
export function StoryCommentsSheet({ storyId, activePetId, visible, onClose, onChanged, onPressPet, readOnly = false }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
|
||||||
|
const { data: comments = [], isLoading } = useQuery({
|
||||||
|
...trpc.stories.listComments.queryOptions({ storyId }),
|
||||||
|
enabled: visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: trpc.stories.listComments.queryKey({ storyId }) });
|
||||||
|
onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = useMutation(
|
||||||
|
trpc.stories.addComment.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
setBody("");
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const remove = useMutation(trpc.stories.deleteComment.mutationOptions({ onSuccess: refresh }));
|
||||||
|
|
||||||
|
const trimmed = body.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel={t("common.close")} />
|
||||||
|
<View style={styles.sheet}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("storyViewer.commentsTitle")}</Text>
|
||||||
|
<Pressable onPress={onClose} hitSlop={12} accessibilityLabel={t("common.close")}>
|
||||||
|
<X size={20} color={colors.ink} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator style={styles.loading} color={colors.marigoldOrange} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={comments}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
ListEmptyComponent={<Text style={styles.muted}>{t("comments.empty")}</Text>}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Pressable onPress={() => onPressPet(item.pet.id)}>
|
||||||
|
<PetAvatar name={item.pet.name} avatarKey={item.pet.avatarKey} size="sm" owner={item.pet.owner} />
|
||||||
|
</Pressable>
|
||||||
|
<View style={styles.rowBody}>
|
||||||
|
<View style={styles.rowHeader}>
|
||||||
|
<Text style={styles.petName} onPress={() => onPressPet(item.pet.id)}>
|
||||||
|
{formatPetName(item.pet)}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.time}>{formatRelativeTime(item.createdAt)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.body}>{item.body}</Text>
|
||||||
|
</View>
|
||||||
|
{item.pet.id === activePetId && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => remove.mutate({ commentId: item.id, petId: activePetId })}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityLabel={t("storyViewer.deleteComment")}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} color={colors.quietGray} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{readOnly ? (
|
||||||
|
<Text style={styles.readOnly}>{t("storyViewer.readOnlyExpired")}</Text>
|
||||||
|
) : (
|
||||||
|
<View style={styles.inputRow}>
|
||||||
|
<TextField
|
||||||
|
placeholder={t("comments.placeholder")}
|
||||||
|
value={body}
|
||||||
|
onChangeText={setBody}
|
||||||
|
maxLength={500}
|
||||||
|
style={styles.input}
|
||||||
|
returnKeyType="send"
|
||||||
|
onSubmitEditing={() => trimmed && add.mutate({ storyId, petId: activePetId, body: trimmed })}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => add.mutate({ storyId, petId: activePetId, body: trimmed })}
|
||||||
|
disabled={!trimmed || add.isPending}
|
||||||
|
style={[styles.send, (!trimmed || add.isPending) && styles.sendDisabled]}
|
||||||
|
accessibilityLabel={t("messages.send")}
|
||||||
|
>
|
||||||
|
{add.isPending ? <ActivityIndicator color="#ffffff" /> : <Send size={18} color="#ffffff" />}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{add.isError && <Text style={styles.error}>{t("storyViewer.commentError")}</Text>}
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(colors: ColorTokens, insetBottom: number) {
|
||||||
|
const typography = getTypography(colors);
|
||||||
|
return StyleSheet.create({
|
||||||
|
flex: { flex: 1 },
|
||||||
|
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.35)" },
|
||||||
|
sheet: {
|
||||||
|
maxHeight: "65%",
|
||||||
|
backgroundColor: colors.porchWhite,
|
||||||
|
borderTopLeftRadius: radii["2xl"],
|
||||||
|
borderTopRightRadius: radii["2xl"],
|
||||||
|
paddingBottom: insetBottom + 12,
|
||||||
|
},
|
||||||
|
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 16, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||||
|
title: { ...typography.title, fontSize: 16 },
|
||||||
|
loading: { padding: 24 },
|
||||||
|
list: { padding: 16, gap: 14 },
|
||||||
|
muted: { ...typography.body, color: colors.quietGray, textAlign: "center", paddingVertical: 16 },
|
||||||
|
row: { flexDirection: "row", gap: 10, alignItems: "flex-start" },
|
||||||
|
rowBody: { flex: 1, gap: 2 },
|
||||||
|
rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 },
|
||||||
|
petName: { ...typography.body, fontWeight: "600", fontSize: 13 },
|
||||||
|
time: { ...typography.body, fontSize: 11, color: colors.quietGray },
|
||||||
|
body: { ...typography.body },
|
||||||
|
inputRow: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingTop: 10 },
|
||||||
|
input: { flex: 1 },
|
||||||
|
send: { width: 48, height: 48, borderRadius: radii.lg, backgroundColor: colors.marigoldOrange, alignItems: "center", justifyContent: "center" },
|
||||||
|
sendDisabled: { opacity: 0.5 },
|
||||||
|
readOnly: { ...typography.body, fontSize: 13, color: colors.quietGray, paddingHorizontal: 16, paddingTop: 10 },
|
||||||
|
error: { ...typography.body, fontSize: 12, color: colors.alertRed, paddingHorizontal: 12, paddingTop: 6 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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 = {
|
||||||
|
storyId: string;
|
||||||
|
petId: string;
|
||||||
|
initialCount: number;
|
||||||
|
initialReacted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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={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={paw.reacted ? colors.marigoldOrange : colors.pureWhite} fill={paw.reacted ? colors.marigoldOrange : "transparent"} />
|
||||||
|
</View>
|
||||||
|
{paw.count > 0 && <Text style={styles.count}>{paw.count}</Text>}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: { alignItems: "center", gap: 2 },
|
||||||
|
circle: { width: 44, height: 44, borderRadius: 22, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
|
||||||
|
count: { color: colors.pureWhite, fontSize: 12, fontWeight: "600", textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 3 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { ActivityIndicator, FlatList, Modal, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { X } from "lucide-react-native";
|
||||||
|
import { useTRPC } from "../../api/trpc";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
import { PetAvatar } from "../PetAvatar";
|
||||||
|
import { formatPetName, formatRelativeTime } from "../../lib/format";
|
||||||
|
import { getTypography, radii, type ColorTokens } from "../../theme/tokens";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
storyId: string;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onPressPet: (petId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port of web's owner-only viewer Sheet — wer hat diese Story gesehen.
|
||||||
|
* stories.getViewers prüft serverseitig, dass nur der Owner der Story fragt.
|
||||||
|
*/
|
||||||
|
export function StoryViewersSheet({ storyId, visible, onClose, onPressPet }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
|
||||||
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
const { data: viewers = [], isLoading } = useQuery({
|
||||||
|
...trpc.stories.getViewers.queryOptions({ storyId }),
|
||||||
|
enabled: visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel={t("common.close")} />
|
||||||
|
<View style={styles.sheet}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("storyViewer.viewersTitle", { count: viewers.length })}</Text>
|
||||||
|
<Pressable onPress={onClose} hitSlop={12} accessibilityLabel={t("common.close")}>
|
||||||
|
<X size={20} color={colors.ink} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator style={styles.loading} color={colors.marigoldOrange} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={viewers}
|
||||||
|
keyExtractor={(item) => item.viewerPet.id}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
ListEmptyComponent={<Text style={styles.muted}>{t("storyViewer.noViewers")}</Text>}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Pressable style={styles.row} onPress={() => onPressPet(item.viewerPet.id)}>
|
||||||
|
<PetAvatar name={item.viewerPet.name} avatarKey={item.viewerPet.avatarKey} size="sm" owner={item.viewerPet.owner} />
|
||||||
|
<Text style={styles.name} numberOfLines={1}>{formatPetName(item.viewerPet)}</Text>
|
||||||
|
<Text style={styles.time}>{formatRelativeTime(item.viewedAt)}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(colors: ColorTokens, insetBottom: number) {
|
||||||
|
const typography = getTypography(colors);
|
||||||
|
return StyleSheet.create({
|
||||||
|
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.35)" },
|
||||||
|
sheet: {
|
||||||
|
maxHeight: "60%",
|
||||||
|
backgroundColor: colors.porchWhite,
|
||||||
|
borderTopLeftRadius: radii["2xl"],
|
||||||
|
borderTopRightRadius: radii["2xl"],
|
||||||
|
paddingBottom: insetBottom + 12,
|
||||||
|
},
|
||||||
|
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 16, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||||
|
title: { ...typography.title, fontSize: 16 },
|
||||||
|
loading: { padding: 24 },
|
||||||
|
list: { padding: 16, gap: 12 },
|
||||||
|
muted: { ...typography.body, color: colors.quietGray, textAlign: "center", paddingVertical: 16 },
|
||||||
|
row: { flexDirection: "row", alignItems: "center", gap: 10 },
|
||||||
|
name: { ...typography.body, flex: 1, fontWeight: "600" },
|
||||||
|
time: { ...typography.body, fontSize: 11, color: colors.quietGray },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { PanResponder, View, type GestureResponderEvent, type PanResponderGestureState, type PanResponderInstance } from "react-native";
|
||||||
|
import { OverlayContent } from "./OverlayContent";
|
||||||
|
import { PositionedOverlay } from "./StoryOverlayLayer";
|
||||||
|
import { angleDelta, applyGesture, touchGeometry, type Rect, type StoryOverlay } from "../../lib/story-overlays";
|
||||||
|
|
||||||
|
const TAP_MAX_MOVE = 6;
|
||||||
|
const TAP_MAX_MS = 250;
|
||||||
|
|
||||||
|
type Callbacks = {
|
||||||
|
overlay: StoryOverlay;
|
||||||
|
rect: Rect;
|
||||||
|
onChange: (next: StoryOverlay) => void;
|
||||||
|
onTap: () => void;
|
||||||
|
/** Ziehen beginnt/läuft/endet — der Editor zeigt dann den Papierkorb. */
|
||||||
|
onDragStateChange: (dragging: boolean, pageY: number) => void;
|
||||||
|
/** Loslassen über dem Papierkorb? (pageY des Fingers) */
|
||||||
|
isOverTrash: (pageY: number) => boolean;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = Callbacks & { mentionName?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein verschiebbares Overlay im Story-Editor. Gesten mit React-Natives
|
||||||
|
* eingebautem PanResponder statt gesture-handler/reanimated: Die sind zwar
|
||||||
|
* über expo-router installiert, aber ob sie im Store-Build 1.0.6 nativ stecken,
|
||||||
|
* ist nicht sicher belegt — ein OTA-Update mit ihrem Import würde dann beim
|
||||||
|
* Start abstürzen. PanResponder ist RN-Core und damit OTA-sicher.
|
||||||
|
*
|
||||||
|
* 1 Finger: verschieben · 2 Finger: zoomen + drehen (+ verschieben) ·
|
||||||
|
* kurz tippen: bearbeiten · auf den Papierkorb ziehen: löschen.
|
||||||
|
*/
|
||||||
|
export function EditableOverlay({ mentionName, ...callbacks }: Props) {
|
||||||
|
// Die Gesten-Handler leben über Renders hinweg und lesen den aktuellen Stand
|
||||||
|
// erst beim Berühren aus diesem Ref (klassisches PanResponder-Muster).
|
||||||
|
const latest = useRef<Callbacks>(callbacks);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
latest.current = callbacks;
|
||||||
|
});
|
||||||
|
const getLatest = useCallback(() => latest.current, []);
|
||||||
|
// Die Fabrik legt nur Handler an; gelesen wird der Ref erst bei Berührung, nie beim Rendern.
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- PanResponder-Muster, siehe createOverlayResponder
|
||||||
|
const [responder] = useState(() => createOverlayResponder(getLatest));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PositionedOverlay overlay={callbacks.overlay} rect={callbacks.rect} interactive>
|
||||||
|
<View {...responder.panHandlers} hitSlop={12}>
|
||||||
|
<OverlayContent overlay={callbacks.overlay} baseWidth={callbacks.rect.width} mentionName={mentionName} />
|
||||||
|
</View>
|
||||||
|
</PositionedOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gestenlogik außerhalb der Komponente: hält ihren Zustand in der Closure und
|
||||||
|
* liest Props nur in Event-Handlern (nie während des Renderns).
|
||||||
|
*/
|
||||||
|
function createOverlayResponder(getLatest: () => Callbacks): PanResponderInstance {
|
||||||
|
const g = {
|
||||||
|
base: null as StoryOverlay | null,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
touches: 0,
|
||||||
|
startDistance: 1,
|
||||||
|
startAngle: 0,
|
||||||
|
startCentroid: { x: 0, y: 0 },
|
||||||
|
startedAt: 0,
|
||||||
|
moved: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fingerzahl geändert → neue Ausgangslage, sonst springt das Overlay.
|
||||||
|
function rebase(evt: GestureResponderEvent, state: Pick<PanResponderGestureState, "dx" | "dy">) {
|
||||||
|
const touches = evt.nativeEvent.touches;
|
||||||
|
g.base = getLatest().overlay;
|
||||||
|
g.offsetX = state.dx;
|
||||||
|
g.offsetY = state.dy;
|
||||||
|
g.touches = touches.length;
|
||||||
|
if (touches.length >= 2) {
|
||||||
|
const geo = touchGeometry(touches[0], touches[1]);
|
||||||
|
g.startDistance = geo.distance;
|
||||||
|
g.startAngle = geo.angle;
|
||||||
|
g.startCentroid = { x: (touches[0].pageX + touches[1].pageX) / 2, y: (touches[0].pageY + touches[1].pageY) / 2 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(evt: GestureResponderEvent) {
|
||||||
|
const { pageY } = evt.nativeEvent;
|
||||||
|
getLatest().onDragStateChange(false, pageY);
|
||||||
|
if (g.moved <= TAP_MAX_MOVE && Date.now() - g.startedAt <= TAP_MAX_MS) {
|
||||||
|
getLatest().onTap();
|
||||||
|
} else if (getLatest().isOverTrash(pageY)) {
|
||||||
|
getLatest().onDelete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => true,
|
||||||
|
onMoveShouldSetPanResponder: () => true,
|
||||||
|
onPanResponderTerminationRequest: () => false,
|
||||||
|
onPanResponderGrant: (evt) => {
|
||||||
|
g.startedAt = Date.now();
|
||||||
|
g.moved = 0;
|
||||||
|
rebase(evt, { dx: 0, dy: 0 });
|
||||||
|
getLatest().onDragStateChange(true, evt.nativeEvent.pageY);
|
||||||
|
},
|
||||||
|
onPanResponderMove: (evt, state) => {
|
||||||
|
const touches = evt.nativeEvent.touches;
|
||||||
|
if (touches.length !== g.touches) rebase(evt, state);
|
||||||
|
g.moved = Math.max(g.moved, Math.hypot(state.dx, state.dy));
|
||||||
|
|
||||||
|
let pinch = 1;
|
||||||
|
let rotationDelta = 0;
|
||||||
|
let dx = state.dx - g.offsetX;
|
||||||
|
let dy = state.dy - g.offsetY;
|
||||||
|
if (touches.length >= 2 && g.startDistance > 0) {
|
||||||
|
const now = touchGeometry(touches[0], touches[1]);
|
||||||
|
pinch = now.distance / g.startDistance;
|
||||||
|
rotationDelta = angleDelta(g.startAngle, now.angle);
|
||||||
|
dx = (touches[0].pageX + touches[1].pageX) / 2 - g.startCentroid.x;
|
||||||
|
dy = (touches[0].pageY + touches[1].pageY) / 2 - g.startCentroid.y;
|
||||||
|
g.moved = Math.max(g.moved, TAP_MAX_MOVE + 1);
|
||||||
|
}
|
||||||
|
const base = g.base ?? getLatest().overlay;
|
||||||
|
getLatest().onChange(applyGesture(base, getLatest().rect, { dx, dy, pinch, rotationDelta }));
|
||||||
|
getLatest().onDragStateChange(true, evt.nativeEvent.pageY);
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (evt) => finish(evt),
|
||||||
|
onPanResponderTerminate: (evt) => finish(evt),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { BackHandler, KeyboardAvoidingView, Platform, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ebene innerhalb des Story-Editors (statt eines nativen <Modal>): Der Editor
|
||||||
|
* liegt selbst in einem modal präsentierten Screen. iOS verweigert ein weiteres
|
||||||
|
* Modal, solange noch eine Präsentation läuft (z. B. direkt beim Öffnen einer
|
||||||
|
* Text-Story) — der Texteditor erschien dann nicht und man traf nur das X.
|
||||||
|
* Als normale Ebene gibt es diesen Konflikt nicht; Safe-Area-Abstände setzt der
|
||||||
|
* Inhalt selbst. Android-Zurück-Taste schließt die Ebene.
|
||||||
|
*/
|
||||||
|
export function EditorLayer({
|
||||||
|
visible,
|
||||||
|
onRequestClose,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
onRequestClose: () => void;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||||
|
onRequestClose();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [visible, onRequestClose]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={[StyleSheet.absoluteFill, styles.layer, style]}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
layer: { zIndex: 20, elevation: 20 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { View, Text, TextInput, Pressable, FlatList, ActivityIndicator, useWindowDimensions, StyleSheet } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useTRPC } from "../../api/trpc";
|
||||||
|
import { useDebounce } from "../../hooks/useDebounce";
|
||||||
|
import { fontFamily } from "../../theme/tokens";
|
||||||
|
import { PickerSheet } from "./PickerSheet";
|
||||||
|
|
||||||
|
export type PickedGif = { id: string; url: string; width: number; height: number };
|
||||||
|
|
||||||
|
const COLUMNS = 3;
|
||||||
|
const GAP = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GIF-Suche über unseren Server (stories.searchGifs → GIPHY, nur Freigabe G).
|
||||||
|
* Ohne Suchbegriff erscheinen die aktuellen Trends. „Powered by GIPHY“ ist
|
||||||
|
* Pflicht laut GIPHY-Bedingungen.
|
||||||
|
*/
|
||||||
|
export function GifPicker({ visible, onSelect, onClose }: { visible: boolean; onSelect: (gif: PickedGif) => void; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const debounced = useDebounce(query.trim(), 400);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [items, setItems] = useState<PickedGif[]>([]);
|
||||||
|
|
||||||
|
// Neue Suche → von vorne
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Liste beim Suchwechsel leeren ist gewollt
|
||||||
|
setOffset(0);
|
||||||
|
setItems([]);
|
||||||
|
}, [debounced]);
|
||||||
|
|
||||||
|
const { data, isFetching, isError } = useQuery({
|
||||||
|
...trpc.stories.searchGifs.queryOptions({ query: debounced, offset }),
|
||||||
|
enabled: visible,
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Seiten werden angehängt, sobald sie da sind
|
||||||
|
setItems((prev) => {
|
||||||
|
const seen = new Set(prev.map((g) => g.id));
|
||||||
|
return [...prev, ...data.gifs.filter((g) => !seen.has(g.id))];
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const tile = (width - 32 - GAP * (COLUMNS - 1)) / COLUMNS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PickerSheet visible={visible} title={t("storyEditor.gifTitle")} onClose={onClose}>
|
||||||
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder={t("storyEditor.gifSearch")}
|
||||||
|
placeholderTextColor="#a3a3a3"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={styles.search}
|
||||||
|
/>
|
||||||
|
{isError && <Text style={styles.hint}>{t("storyEditor.gifError")}</Text>}
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(g) => g.id}
|
||||||
|
numColumns={COLUMNS}
|
||||||
|
columnWrapperStyle={{ gap: GAP }}
|
||||||
|
contentContainerStyle={{ gap: GAP }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
onEndReachedThreshold={0.6}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (data?.nextOffset && !isFetching) setOffset(data.nextOffset);
|
||||||
|
}}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Pressable onPress={() => onSelect(item)} accessibilityRole="button" accessibilityLabel="GIF">
|
||||||
|
<Image source={{ uri: item.url }} style={{ width: tile, height: tile, borderRadius: 8, backgroundColor: "#2a2a2a" }} contentFit="cover" autoplay alt="GIF" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
ListFooterComponent={isFetching ? <ActivityIndicator color="#f97316" style={{ marginVertical: 12 }} /> : null}
|
||||||
|
/>
|
||||||
|
<View style={styles.attribution}>
|
||||||
|
<Text style={styles.attributionText}>Powered by GIPHY</Text>
|
||||||
|
</View>
|
||||||
|
</PickerSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
search: { backgroundColor: "#2a2a2a", color: "#ffffff", borderRadius: 12, paddingHorizontal: 14, height: 44, fontFamily: fontFamily.regular, fontSize: 15 },
|
||||||
|
hint: { color: "#a3a3a3", fontFamily: fontFamily.regular, fontSize: 13 },
|
||||||
|
attribution: { alignItems: "center", paddingTop: 4 },
|
||||||
|
attributionText: { color: "#a3a3a3", fontFamily: fontFamily.bold, fontSize: 12, letterSpacing: 0.5 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Text, TextInput, Pressable, FlatList, ActivityIndicator, StyleSheet } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTRPC } from "../../api/trpc";
|
||||||
|
import { useDebounce } from "../../hooks/useDebounce";
|
||||||
|
import { PetAvatar } from "../PetAvatar";
|
||||||
|
import { fontFamily } from "../../theme/tokens";
|
||||||
|
import { PickerSheet } from "./PickerSheet";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tier zum Markieren suchen. Der Server liefert nur Tiere, die man laut ihrer
|
||||||
|
* Einstellung „Wer darf die Follower sehen?“ markieren darf (und keine
|
||||||
|
* blockierten) — darauf weist der Hinweis unten hin.
|
||||||
|
*/
|
||||||
|
export function MentionPicker({
|
||||||
|
visible,
|
||||||
|
petId,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
petId: string;
|
||||||
|
onSelect: (pet: { id: string; name: string }) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const debounced = useDebounce(query.trim(), 300);
|
||||||
|
|
||||||
|
const { data: pets = [], isFetching } = useQuery({
|
||||||
|
...trpc.stories.searchMentionable.queryOptions({ petId, query: debounced }),
|
||||||
|
enabled: visible && debounced.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PickerSheet visible={visible} title={t("storyEditor.mentionTitle")} onClose={onClose}>
|
||||||
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder={t("storyEditor.mentionSearch")}
|
||||||
|
placeholderTextColor="#a3a3a3"
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={styles.search}
|
||||||
|
/>
|
||||||
|
{isFetching && <ActivityIndicator color="#f97316" />}
|
||||||
|
<FlatList
|
||||||
|
data={debounced ? pets : []}
|
||||||
|
keyExtractor={(p) => p.id}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Pressable style={styles.row} onPress={() => onSelect({ id: item.id, name: item.name })} accessibilityRole="button">
|
||||||
|
<PetAvatar name={item.name} avatarKey={item.avatarKey} size="sm" owner={item.owner} />
|
||||||
|
<Text style={styles.name}>{item.name}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
debounced && !isFetching ? <Text style={styles.hint}>{t("storyEditor.mentionNoResults")}</Text> : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text style={styles.hint}>{t("storyEditor.mentionPrivacyHint")}</Text>
|
||||||
|
</PickerSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
search: { backgroundColor: "#2a2a2a", color: "#ffffff", borderRadius: 12, paddingHorizontal: 14, height: 44, fontFamily: fontFamily.regular, fontSize: 15 },
|
||||||
|
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 10 },
|
||||||
|
name: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 15 },
|
||||||
|
hint: { color: "#a3a3a3", fontFamily: fontFamily.regular, fontSize: 12, paddingVertical: 6 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import { View, Text, StyleSheet } from "react-native";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import Svg, { Circle, Path, Rect as SvgRect } from "react-native-svg";
|
||||||
|
import { fontFamily } from "../../theme/tokens";
|
||||||
|
import { OVERLAY_SIZES, contrastTextColor, type StoryOverlay, type TextOverlay } from "../../lib/story-overlays";
|
||||||
|
import { STORY_STICKERS, STICKER_OUTLINE, type StickerPart } from "../../lib/story-stickers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sichtbarer Inhalt eines Story-Overlays, ohne Position/Drehung/Skalierung —
|
||||||
|
* das übernehmen StoryOverlayLayer (Anzeige) bzw. EditableOverlay (Editor).
|
||||||
|
* Alle Größen hängen nur von `baseWidth` (Breite der Medienfläche) ab, damit
|
||||||
|
* es auf jedem Bildschirm und im Web (StoryOverlayLayer.tsx dort) gleich aussieht.
|
||||||
|
*/
|
||||||
|
export const OverlayContent = memo(function OverlayContent({
|
||||||
|
overlay,
|
||||||
|
baseWidth,
|
||||||
|
mentionName,
|
||||||
|
}: {
|
||||||
|
overlay: StoryOverlay;
|
||||||
|
baseWidth: number;
|
||||||
|
mentionName?: string;
|
||||||
|
}) {
|
||||||
|
switch (overlay.kind) {
|
||||||
|
case "text":
|
||||||
|
return <TextContent overlay={overlay} baseWidth={baseWidth} />;
|
||||||
|
case "mention":
|
||||||
|
return <MentionChip name={mentionName ?? "…"} baseWidth={baseWidth} />;
|
||||||
|
case "gif": {
|
||||||
|
const width = OVERLAY_SIZES.gifWidth * baseWidth;
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{ uri: overlay.url }}
|
||||||
|
style={{ width, height: (width * overlay.height) / overlay.width, borderRadius: baseWidth * 0.02 }}
|
||||||
|
autoplay
|
||||||
|
contentFit="cover"
|
||||||
|
accessibilityLabel="GIF"
|
||||||
|
alt="GIF"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "sticker":
|
||||||
|
return <StickerContent stickerId={overlay.stickerId} size={OVERLAY_SIZES.sticker * baseWidth} />;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function TextContent({ overlay, baseWidth }: { overlay: TextOverlay; baseWidth: number }) {
|
||||||
|
const fontSize = OVERLAY_SIZES.textFont * baseWidth;
|
||||||
|
const { textColor, background } = textAppearance(overlay);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
maxWidth: OVERLAY_SIZES.textMaxWidth * baseWidth,
|
||||||
|
backgroundColor: background,
|
||||||
|
borderRadius: fontSize * 0.3,
|
||||||
|
paddingHorizontal: background ? fontSize * 0.4 : 0,
|
||||||
|
paddingVertical: background ? fontSize * 0.18 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.text,
|
||||||
|
{
|
||||||
|
fontSize,
|
||||||
|
lineHeight: fontSize * 1.2,
|
||||||
|
color: textColor,
|
||||||
|
fontFamily: overlay.font === "bold" ? fontFamily.bold : fontFamily.regular,
|
||||||
|
},
|
||||||
|
!background && styles.textShadow,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{overlay.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Farben je Textstil — identische Regeln im Web (src/components/stories/StoryOverlayLayer.tsx). */
|
||||||
|
export function textAppearance(overlay: Pick<TextOverlay, "color" | "style">): { textColor: string; background?: string } {
|
||||||
|
switch (overlay.style) {
|
||||||
|
case "box":
|
||||||
|
return { textColor: contrastTextColor(overlay.color), background: overlay.color };
|
||||||
|
case "soft":
|
||||||
|
return {
|
||||||
|
textColor: overlay.color,
|
||||||
|
background: overlay.color === "#171717" ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.55)",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { textColor: overlay.color };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MentionChip({ name, baseWidth }: { name: string; baseWidth: number }) {
|
||||||
|
const fontSize = OVERLAY_SIZES.mentionFont * baseWidth;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderRadius: fontSize,
|
||||||
|
paddingHorizontal: fontSize * 0.6,
|
||||||
|
paddingVertical: fontSize * 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize, lineHeight: fontSize * 1.25, color: "#f97316", fontFamily: fontFamily.bold }} numberOfLines={1}>
|
||||||
|
@{name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StickerContent({ stickerId, size }: { stickerId: string; size: number }) {
|
||||||
|
const sticker = STORY_STICKERS.find((s) => s.id === stickerId);
|
||||||
|
if (!sticker) return null;
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="-6 -6 112 112" accessibilityLabel={sticker.label}>
|
||||||
|
{sticker.parts.map((part, i) => (
|
||||||
|
<StickerShape key={`o${i}`} part={part} fill={STICKER_OUTLINE.color} stroke={STICKER_OUTLINE.color} strokeWidth={STICKER_OUTLINE.width} />
|
||||||
|
))}
|
||||||
|
{sticker.parts.map((part, i) => (
|
||||||
|
<StickerShape key={`f${i}`} part={part} fill={part.fill} />
|
||||||
|
))}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StickerShape({ part, fill, stroke, strokeWidth }: { part: StickerPart; fill: string; stroke?: string; strokeWidth?: number }) {
|
||||||
|
const strokeProps = stroke ? { stroke, strokeWidth, strokeLinejoin: "round" as const } : {};
|
||||||
|
switch (part.type) {
|
||||||
|
case "circle":
|
||||||
|
return <Circle cx={part.cx} cy={part.cy} r={part.r} fill={fill} {...strokeProps} />;
|
||||||
|
case "rect":
|
||||||
|
return <SvgRect x={part.x} y={part.y} width={part.width} height={part.height} rx={part.rx} fill={fill} {...strokeProps} />;
|
||||||
|
case "path":
|
||||||
|
return <Path d={part.d} fill={fill} {...strokeProps} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
text: { textAlign: "center" },
|
||||||
|
textShadow: { textShadowColor: "rgba(0,0,0,0.45)", textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 6 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { X } from "lucide-react-native";
|
||||||
|
import { fontFamily } from "../../theme/tokens";
|
||||||
|
import { EditorLayer } from "./EditorLayer";
|
||||||
|
|
||||||
|
/** Dunkles Bottom-Sheet für die Auswahlfenster des Story-Editors (Markieren, GIF, Sticker) — als Ebene, kein natives Modal (siehe EditorLayer). */
|
||||||
|
export function PickerSheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: React.ReactNode }) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
return (
|
||||||
|
<EditorLayer visible={visible} onRequestClose={onClose} style={styles.backdrop}>
|
||||||
|
<Pressable style={styles.dismiss} onPress={onClose} accessibilityLabel={title} />
|
||||||
|
<View style={[styles.sheet, { paddingBottom: insets.bottom + 12 }]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<Pressable onPress={onClose} hitSlop={12} accessibilityRole="button">
|
||||||
|
<X color="#ffffff" size={22} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</EditorLayer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: { justifyContent: "flex-end", backgroundColor: "rgba(0,0,0,0.35)" },
|
||||||
|
dismiss: { flex: 1 },
|
||||||
|
sheet: { backgroundColor: "#1c1c1c", borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingTop: 12, paddingHorizontal: 16, maxHeight: "75%", gap: 12 },
|
||||||
|
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||||
|
title: { color: "#ffffff", fontFamily: fontFamily.bold, fontSize: 17 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { View, Pressable, StyleSheet } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { STORY_STICKERS } from "../../lib/story-stickers";
|
||||||
|
import { StickerContent } from "./OverlayContent";
|
||||||
|
import { PickerSheet } from "./PickerSheet";
|
||||||
|
|
||||||
|
export function StickerPicker({ visible, onSelect, onClose }: { visible: boolean; onSelect: (stickerId: string) => void; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<PickerSheet visible={visible} title={t("storyEditor.stickerTitle")} onClose={onClose}>
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{STORY_STICKERS.map((sticker) => (
|
||||||
|
<Pressable
|
||||||
|
key={sticker.id}
|
||||||
|
style={styles.cell}
|
||||||
|
onPress={() => onSelect(sticker.id)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={sticker.label}
|
||||||
|
>
|
||||||
|
<StickerContent stickerId={sticker.id} size={64} />
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</PickerSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
grid: { flexDirection: "row", flexWrap: "wrap", justifyContent: "space-between", rowGap: 12, paddingBottom: 8 },
|
||||||
|
cell: { width: "23%", aspectRatio: 1, alignItems: "center", justifyContent: "center", borderRadius: 12, backgroundColor: "#2a2a2a" },
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
|
||||||
|
import { STORY_BACKGROUNDS } from "../../lib/story-overlays";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Farbverlauf einer Text-Story. Über react-native-svg statt expo-linear-gradient:
|
||||||
|
* letzteres wäre ein neues natives Modul (= Store-Build), svg ist schon drin.
|
||||||
|
*/
|
||||||
|
export function StoryBackground({ backgroundKey, width, height, radius = 0 }: { backgroundKey: string; width: number; height: number; radius?: number }) {
|
||||||
|
const [from, to] = STORY_BACKGROUNDS[backgroundKey] ?? STORY_BACKGROUNDS.sunset;
|
||||||
|
const id = `bg-${backgroundKey}`;
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
<Defs>
|
||||||
|
<LinearGradient id={id} x1="0" y1="0" x2="0.35" y2="1">
|
||||||
|
<Stop offset="0" stopColor={from} />
|
||||||
|
<Stop offset="1" stopColor={to} />
|
||||||
|
</LinearGradient>
|
||||||
|
</Defs>
|
||||||
|
<Rect x={0} y={0} width={width} height={height} rx={radius} fill={`url(#${id})`} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loaded 100 of 424 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user