Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
32ba01f7cf | ||
|
|
78e16c9210 | ||
|
|
4d0aa7ab9e | ||
|
|
02662528cf | ||
|
|
959d67cc29 | ||
|
|
2cd13537f7 | ||
|
|
2ef439c752 | ||
|
|
de237f19a1 | ||
|
|
e4139a1b9b | ||
|
|
3b6735ebee | ||
|
|
4213d7029e | ||
|
|
0c3ab5c2a2 | ||
|
|
69e095ddcb | ||
|
|
1e7d76aa79 | ||
|
|
070c389786 | ||
|
|
71ac36b08a | ||
|
|
779f18966d | ||
|
|
17a17bd392 | ||
|
|
2b285de09e | ||
|
|
aa75fe612b | ||
|
|
ea3cf1dcd4 | ||
|
|
1e8e6bbd7d | ||
|
|
31b3f7b81b | ||
|
|
30c57b661c |
No files matched your search
@@ -66,11 +66,6 @@ ADMIN_OWNER_ID="user_xxxxxxxxxxxxxxxxxxxx"
|
||||
# Local dev: run `docker run -d -p 6379:6379 redis:7-alpine` or install Redis locally.
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# ── Invite Gate ───────────────────────────────────────────────
|
||||
# Set to "false" to disable the invite requirement after the Alpha/Beta phase.
|
||||
# Default: "true" (invite required — enforced in proxy.ts)
|
||||
INVITE_REQUIRED="true"
|
||||
|
||||
# ── Error Monitoring (Sentry) ───────────────────────────────────
|
||||
# Sentry Dashboard -> Settings -> Projects -> <project> -> Client Keys (DSN)
|
||||
# Same DSN value in both — server-side (src/instrumentation.ts) and
|
||||
|
||||
@@ -44,7 +44,7 @@ The client-side "which pet is currently active" state lives in `src/context/Acti
|
||||
### tRPC layer
|
||||
|
||||
- `src/trpc/init.ts` — context is `{ userId, prisma }` (userId from Clerk `auth()`); `protectedProcedure` throws `UNAUTHORIZED` if `userId` is null and narrows it to non-null in `ctx` for everything downstream.
|
||||
- `src/trpc/routers/_app.ts` — aggregates ~20 domain routers (pets, posts, stories, milestones, follows, blocks, reports, feed, reactions, comments, reposts, explore, search, notifications, videos, conversations/messages, health, admin, invites, ads). One router file per domain in `src/trpc/routers/`.
|
||||
- `src/trpc/routers/_app.ts` — aggregates ~20 domain routers (pets, posts, stories, milestones, follows, blocks, reports, feed, reactions, comments, reposts, explore, search, notifications, videos, conversations/messages, health, admin, referrals, referralAdmin, ads). One router file per domain in `src/trpc/routers/`.
|
||||
- `src/trpc/server.ts` exposes a server-side caller (`createTRPCCaller()`) used inside Server Components/layouts to prefetch without an HTTP round-trip (see `src/app/(app)/layout.tsx`). `src/trpc/client.tsx` + `query-client.ts` wire up the browser-side TanStack Query client.
|
||||
- Pet-ownership checks go through the shared `assertPetOwnership(prisma, petId, userId)` helper in `src/lib/assert-pet-ownership.ts` — don't reintroduce a local copy of this check in a new router.
|
||||
|
||||
@@ -55,14 +55,14 @@ The client-side "which pet is currently active" state lives in `src/context/Acti
|
||||
### Routing structure
|
||||
|
||||
- `(auth)` route group — sign-up/log-in/password reset, unauthenticated.
|
||||
- `(app)` route group — the authenticated shell (`layout.tsx` does a belt-and-suspenders `auth()` check, redirects to onboarding if the owner has zero pets, redirects banned owners to `/banned`, and consumes a pending invite cookie).
|
||||
- `(app)` route group — the authenticated shell (`layout.tsx` does a belt-and-suspenders `auth()` check, redirects to onboarding if the owner has zero pets, and redirects banned owners to `/banned`).
|
||||
- `/p/[secret]/...` — the admin/moderation panel. The path segment itself is the secret (`ADMIN_SECRET` env var); wrong or missing secret returns a bare `404`, not `403`, so the panel's existence isn't revealed.
|
||||
- `/health-card/[token]` — public, tokenized, no auth (shareable vet record).
|
||||
- Root-level `/impressum`, `/datenschutz`, `/nutzungsbedingungen` — German legal pages, not under `(app)`, not localized (this is a `next-intl` project but these three routes are fixed-language).
|
||||
|
||||
### `src/proxy.ts` (not `middleware.ts`)
|
||||
|
||||
Next.js 16 renamed `middleware.ts` to `proxy.ts` — this **is** the middleware; don't go looking for a `middleware.ts` that doesn't exist. It layers three concerns in one Clerk middleware: (1) invite gate — blocks `/sign-up` unless a `pf_invite` cookie is present, unless `INVITE_REQUIRED=false`; (2) admin secret gate — 404s `/p/*` unless the path segment matches `ADMIN_SECRET`; (3) `auth.protect()` for everything else not in the public route matcher.
|
||||
Next.js 16 renamed `middleware.ts` to `proxy.ts` — this **is** the middleware; don't go looking for a `middleware.ts` that doesn't exist. It layers two concerns in one Clerk middleware: (1) admin secret gate — 404s `/p/*` unless the path segment matches `ADMIN_SECRET`; (2) `auth.protect()` for everything else not in the public route matcher. (The old invite gate on `/sign-up` — `pf_invite` cookie, `INVITE_REQUIRED` — was removed with the referral program; sign-up is open.)
|
||||
|
||||
### Media & storage — code has diverged from the original stack plan
|
||||
|
||||
@@ -84,11 +84,23 @@ All uploads are presigned/direct-to-storage — media bytes never pass through t
|
||||
|
||||
### Admin / moderation panel
|
||||
|
||||
Gated by path secret (see `proxy.ts` above) *and* by role at the tRPC layer: `src/trpc/routers/admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-configured super-admin bootstrap) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Actions are written to `ModerationLog`. Pages live under `src/app/p/[secret]/{users,moderators,posts,reports,ads,invites}`.
|
||||
Gated by path secret (see `proxy.ts` above) *and* by role at the tRPC layer: `src/trpc/routers/admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-configured super-admin bootstrap) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Actions are written to `ModerationLog`. Pages live under `src/app/p/[secret]/{users,pets,moderators,posts,reports,ads,referrals,curator,…}`. The dashboard has a collapsible "Tierarten" card (`SpeciesDistributionCard`, `petAdmin.speciesDistribution`: pets per species incl. empty species and per breed, curator pet excluded). The "Tiere" page (`pets`, router `pet-admin.ts`) manages single pets: verified/founding toggles, clearing profile content, deleting a pet (reason is sent to the owner as a team DM on another of their pets, or by e-mail if it was their only pet) and the owner's referral ranks.
|
||||
|
||||
### Invite system
|
||||
**Never delete a pet with a bare `prisma.pet.delete`** — use `deletePetCompletely()` (`src/lib/delete-pet.ts`). `StoryView` (D-08), `Report.reporter` and `Repost.originalPost` have no cascade and would block the delete, and the denormalized counters on *other* pets' posts/stories must drop by this pet's paws/comments/paw-backs. `pets.delete`, `deleteOwnerAccount` and the admin delete all go through it.
|
||||
|
||||
`InviteCode` model (word-prefix + 4-char code, e.g. `PAWS-7K3M`, generated in `src/lib/invite-codes.ts`) gates sign-up via a `pf_invite` cookie set by `/join` and consumed by `/api/auth/consume-invite` right after sign-up (the `(app)` layout redirects there if the cookie is still present). `INVITE_REQUIRED=false` turns the whole gate off for post-beta public launch.
|
||||
### Download redirect `/app`
|
||||
|
||||
`src/app/app/route.ts` (public in `proxy.ts`) is the target for flyers/QR codes: Android → Play Store with sanitized `utm_*` passed as Play `referrer`, iOS → `APP_STORE_URL` in `src/lib/app-links.ts` once set (still `null` until the iOS release; until then the web start page), everything else → web start page; `?ref=<code>` → `/r/<code>`. Logic is pure in `src/lib/app-redirect.ts`; clicks are counted per day/source/platform in Redis (`appredirect:<date>` hashes, `src/lib/app-redirect-stats.ts`) and shown in the admin referral overview. Redis on the NAS is the `pawfeed-redis` container (not `glitchtip-redis`), password in `docker/.env` `REDIS_PASSWORD`.
|
||||
|
||||
### New-pet welcome & founding members
|
||||
|
||||
`pets.create` fires `welcomeNewPet()` (`src/lib/new-pet-welcome.ts`) in the background — it must never block pet creation. It (1) sets `Pet.isFoundingMember` via one guarded SQL update for the first 250 pets by `createdAt` (never revoked; badge = Sprout icon next to the name, web + mobile), (2) lets the curator pet "PawFeed Team" (`ensureTeamPet` in `src/lib/team-pet.ts`) follow the new pet, and (3) sends a welcome DM via `sendTeamMessage`. (2) and (3) are switchable and the DE/EN text (mandatory `{name}` placeholder) is editable in the admin panel under "Begrüßung" (`/p/[secret]/curator`, `CuratorSettings` id = 1, `curator-admin.ts`). Replies to the welcome DM land in the admin "Messages" team inbox.
|
||||
|
||||
### Referral program (Empfehlungsprogramm)
|
||||
|
||||
Registration is open; invites are a **Clerk-independent** referral layer on top. Every owner has one permanent, unlimited `Owner.referralCode` (e.g. `BUBI-7K3M`, prefix from the first pet's name — `src/lib/referral-code.ts`). A code is never part of the Clerk sign-up: it is redeemed **after** sign-up, in the onboarding step "Wie hast du von PawFeed erfahren?" (`AcquisitionStep`, web + mobile) or later on `/einladen` (mobile: `/invite`) within `ReferralSettings.redeemWindowDays`. `/r/[code]` is the public landing page (sets a `pf_ref` cookie only to prefill the web onboarding); old `/join?code=` links redirect there. Logic lives in `src/lib/referrals.ts`: redeem sets `Owner.invitedById` and makes both first pets follow each other; an invite **counts** (`referralQualifiedAt`, inviter's denormalized `qualifiedReferralCount` +1) only once the invitee has a pet **and** a first real post — `maybeQualifyReferral()` is called from `posts.create` and the Mux webhook on `READY`; a new post-creating mutation must call it too.
|
||||
|
||||
Ranks (`ReferralRank`) are data, fully editable in the admin panel (`/p/[secret]/referrals`, `referral-admin.ts`); only the perk catalog (`ReferralPerk`, `IMPLEMENTED_REFERRAL_PERKS` in `src/lib/referral-ranks.ts`) needs code. Perks are cumulative. Reached ranks are **grandfathered** via `ReferralRankAchievement` — raising a threshold never removes a rank. An admin can revoke a rank permanently (`revokedAt` on the achievement row — the row stays so award/recompute never re-grant it; it is ignored for badges/perks until "Freigeben") or grant one manually (`grantedManually`). `Owner.referralRankId`/`referralPerks` are a cache maintained only by `src/lib/referrals.ts` and the set-based `recomputeReferralRanks()` (`src/lib/referral-recompute.ts`, run after every admin rank change); feed cards read them via `petCardSelect`. The legacy `InviteCode` table is history only — nothing writes to it anymore. One-time setup after the schema push: `npx tsx prisma/scripts/referral-setup.ts` (+ `prisma/enable_rls_referrals.sql`).
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -102,7 +114,7 @@ Despite `next.config.ts`'s `output: "standalone"` and the original stack researc
|
||||
|
||||
**Runs as 3 load-balanced replicas, not a single container (since 2026-08-16, Gitea #26).** `docker/docker-compose.yml` defines `pawfeed-1`/`pawfeed-2`/`pawfeed-3` (via the `x-pawfeed-common` YAML anchor) instead of one `pawfeed` service — Next.js standalone output is single-process (`node server.js`), so one container only ever used one CPU core regardless of how many the NAS has. A load test on 2026-08-15 hit a hard ceiling at ~60–65 req/sec with one core pegged at 133% while 3 cores sat idle; splitting into 3 replicas fixed it (confirmed via a follow-up load test showing ~100% CPU spread evenly across all three). Nginx Proxy Manager (container `nginx-app-1` on this NAS, admin UI at `192.168.1.222:81`) load-balances across them via a `least_conn` upstream block — that block lives in `/Docker-Data/nginxproxymanager/data/nginx/custom/http_top.conf` on the NAS host (an nginx `upstream {}` is only valid in the `http{}` context, which NPM's per-host "Custom Nginx Configuration" field can't reach — that field only writes into the `server{}`/`location{}` context, and holds the `proxy_pass http://pawfeed_upstream;` override for the `pawfeed.org` Proxy Host instead). Full runbook, decisions, and risk notes: `docker/SCALING-ROADMAP.md`. **Deploying a config or code change means rebuilding all 3 replicas** — there is no separate single-container path anymore, and NPM's proxy target is `pawfeed_upstream`, not a bare `pawfeed:3000` hostname. Use `./docker/rolling-deploy.sh` for deploys against the live site — it rebuilds the shared `pawfeed:latest` image once, then restarts `pawfeed-1`/`2`/`3` one at a time, gated on each becoming `healthy` per the Docker `healthcheck` (`GET /api/health`, a dependency-free liveness check — see `src/app/api/health/route.ts`) before moving to the next, so at least 2 replicas always serve traffic. Verified zero-downtime on 2026-08-16 (continuous curl monitoring showed unbroken HTTP 200 through a full rolling deploy). `docker compose up -d --build` (`start.sh`) still works but restarts all 3 at once — only use it for first-time setup or when a brief outage is acceptable. `src/lib/prisma.ts`'s Prisma pg pool size is capped per-replica via `DB_POOL_MAX` (compose sets `10`) so 3 replicas stay well under the Supabase pooler's client-connection limit.
|
||||
|
||||
`src/app/api/cron/trim-feeds/route.ts` trims each pet's Redis feed sorted set to the 90-day retention window and expects a `CRON_SECRET` bearer token. `vercel.json`'s `crons` array that points at it is **inert** on this self-hosted deployment — nothing calls this route yet. It needs an external trigger (e.g. a NAS-side cron job hitting the route with the secret header) before it actually runs.
|
||||
**Cron jobs run from the NAS crontab** (user `daniel`, `crontab -l`), not from `vercel.json` (whose `crons` array is inert here). Each entry calls `docker/run-cron.sh /api/cron/<name>`, which reads `CRON_SECRET` from `docker/.env`, hits the route with the bearer token via the DDNS host (`--resolve` to localhost — plain `pawfeed.org` fails TLS from the NAS) and logs every run with status + body to `docker/cron.log`. Current schedule: `trim-feeds` 04:00 (Redis feeds → 90 days), `cleanup-stories` 04:30 (deletes stories older than 7 days incl. views, paws, comments, Supabase image and Mux asset — `src/lib/story-cleanup.ts`, max 200 per run), `anniversaries` 08:00, `vaccine-reminders` 08:15 (one-time reminder + push 30 days before `Vaccine.nextDueDate`, `src/lib/vaccine-reminders.ts`, guarded by `Vaccine.reminderSentAt`), `pet-suggestions` Mondays 09:00, plus `docker/backup-db.sh` 03:00. A new cron route needs a new crontab line on the NAS — it does not run otherwise.
|
||||
|
||||
### Directories that are not app code
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ A quiet, mostly neutral palette with one warm, confident accent — the accent i
|
||||
|
||||
## Typography
|
||||
|
||||
**Display/Body Font:** Inter (Google Font, `latin` subset, loaded via `next/font`), with a `system-ui` fallback stack. One family for the entire product — there is no separate display or mono face in active use.
|
||||
**Display/Body Font:** Inter (variable weight, `latin` subset, self-hosted from `src/app/fonts/` via `next/font/local` — no requests to Google, not even at build time), with a `system-ui` fallback stack. One family for the entire product — there is no separate display or mono face in active use.
|
||||
|
||||
**Character:** Confident but unfussy — a standard humanist grotesque doing hierarchy through weight and scale rather than personality. It reads as approachable and modern, never corporate-cold or ornamental.
|
||||
|
||||
|
||||
@@ -625,3 +625,5 @@ npx vitest # watch mode
|
||||
| A 401 (not 403) from an admin tRPC procedure usually means a stale Clerk session, not a permissions bug | `protectedProcedure` throws `UNAUTHORIZED` (401) when Clerk's session is missing/expired; `assertAdmin()` throws `FORBIDDEN` (403) when the session is valid but the role is too low. A browser tab left open and backgrounded for hours can let Clerk's silent token refresh lapse — reload the page before suspecting a code regression. |
|
||||
| `eas update` must be run with `--platform android` (`mobile/`), not `--platform all` | This app has no web target — `react-native-web` isn't installed — so the default `--platform all` fails bundling with `Unable to resolve module react-native-web/dist/index`. Previously published updates were all Android-only too (check `eas channel:view <channel>` to confirm before assuming this changed). |
|
||||
| `mobile/node_modules` can end up silently corrupted (missing nested files) after an interrupted/partial install, without `npm install` re-detecting or fixing it | Symptom: Metro bundling fails with `Unable to resolve module ./some/nested/path.js` inside an already-installed package (seen with `@expo/cli`'s `utils/telemetry`, then `@sentry/browser` and `@sentry-internal/browser-utils` in the same session) — `npm install` reports "up to date" and does nothing. Fix: `rm -rf node_modules && npm install` (a full reinstall), not a targeted one; deleting just the one broken package's folder can just surface the *next* corrupted package one bundle-failure at a time. |
|
||||
| Mobile Google SSO can break with `redirect url ... does not match an authorized redirect URI` even though `GoogleSignInButton.tsx`/`app.json`'s scheme are unchanged | Clerk keeps its authorized-redirect-URL allowlist server-side per instance (`GET`/`POST https://api.clerk.com/v1/redirect_urls`, auth via `CLERK_SECRET_KEY`), entirely outside this repo — it can be missing or reset independent of any code change. Check that list before debugging app code; add the missing entry (`{"url":"pawfeed://sso-callback"}` for this app's `"scheme": "pawfeed"`) via `POST /v1/redirect_urls` — takes effect immediately, no app deploy/rebuild needed. |
|
||||
| Admin music-track upload can fail even with a genuinely valid MP3/M4A/AAC file | Two independent causes, both hit in the same incident: (1) the Supabase bucket must be named **exactly** `music-tracks` (plural) — it was once created as `music-track` (singular), so every upload failed with a bucket-not-found error surfaced only as a generic "Upload fehlgeschlagen" toast; verify via `GET {SUPABASE_URL}/storage/v1/bucket` with the service-role key. (2) The upload form used to validate against the browser-reported `File.type`, which is sniffed from the OS's own MIME registry and is inconsistent for `.m4a`/`.aac` (e.g. Windows Chrome reports `.m4a` as `audio/x-m4a`, not `audio/mp4`) — it now derives the content-type from the file extension instead (`src/lib/music-content-types.ts`), matching what `MUSIC_ALLOWED_CONTENT_TYPES` (server-side, `supabase-storage.ts`) actually expects. |
|
||||
+131
-13
@@ -116,7 +116,8 @@
|
||||
"zoom": "Zoom",
|
||||
"cropCancel": "Abbrechen",
|
||||
"cropSave": "Übernehmen",
|
||||
"cropSaving": "Wird verarbeitet…"
|
||||
"cropSaving": "Wird verarbeitet…",
|
||||
"heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
|
||||
},
|
||||
"Onboarding": {
|
||||
"addFirstPet": "Füge dein erstes Tier hinzu",
|
||||
@@ -124,7 +125,8 @@
|
||||
"addPhoto": "Profilfoto hinzufügen",
|
||||
"addPhotoDesc": "Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.",
|
||||
"done": "Fertig",
|
||||
"skipForNow": "Jetzt überspringen"
|
||||
"skipForNow": "Jetzt überspringen",
|
||||
"loading": "Wird geladen"
|
||||
},
|
||||
"Language": {
|
||||
"switch": "Sprache",
|
||||
@@ -258,7 +260,8 @@
|
||||
"postError": "Kommentar konnte nicht gepostet werden. Bitte versuche es erneut.",
|
||||
"deleteError": "Kommentar konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||
"countSingular": "1 Kommentar",
|
||||
"countPlural": "{count} Kommentare"
|
||||
"countPlural": "{count} Kommentare",
|
||||
"readOnlyExpired": "Die Story ist abgelaufen. Kommentare kannst du noch lesen, aber nicht mehr schreiben."
|
||||
},
|
||||
"RelativeTime": {
|
||||
"justNow": "gerade eben",
|
||||
@@ -477,7 +480,15 @@
|
||||
"typePawBack": "hat deinen Post geteilt (Paw-Back)",
|
||||
"typeBirthday": "🎂 Alles Gute zum Geburtstag! Erstelle einen Jubiläums-Post.",
|
||||
"typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag.",
|
||||
"typePetSuggestion": "könnte dir gefallen"
|
||||
"typePetSuggestion": "könnte dir gefallen",
|
||||
"typeReferralJoined": "ist über deine Einladung zu PawFeed gekommen",
|
||||
"typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!",
|
||||
"typeVaccineDue": "💉 Die Impfung „{name}“ ist am {date} fällig – Zeit, einen Termin zu vereinbaren.",
|
||||
"typeStoryMention": "hat dich in einer Story markiert.",
|
||||
"typeStoryComment": "hat deine Story kommentiert.",
|
||||
"typeStoryReaction": "hat deiner Story eine Pfote gegeben.",
|
||||
"typeUnknown": "hat eine neue Mitteilung für dich.",
|
||||
"typePetDeceased": "ist leider verstorben. In Erinnerung 🕯️"
|
||||
},
|
||||
"PhotoPostForm": {
|
||||
"captionPlaceholder": "Bildunterschrift schreiben…",
|
||||
@@ -507,7 +518,8 @@
|
||||
"videoTooLong": "Story-Videos dürfen höchstens {max} Sekunden lang sein.",
|
||||
"videoUnreadable": "Videolänge konnte nicht gelesen werden — bitte versuch eine andere Datei.",
|
||||
"musicLabel": "Musik hinzufügen (optional)",
|
||||
"muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen"
|
||||
"muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen",
|
||||
"heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
|
||||
},
|
||||
"MilestoneForm": {
|
||||
"questionLabel": "Was ist der Meilenstein?",
|
||||
@@ -527,7 +539,8 @@
|
||||
"errorToast": "Meilenstein konnte nicht geteilt werden. Bitte versuch es erneut.",
|
||||
"fileTooLarge": "Foto muss unter 10 MB sein.",
|
||||
"invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.",
|
||||
"uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut."
|
||||
"uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut.",
|
||||
"heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
|
||||
},
|
||||
"MilestoneTypes": {
|
||||
"BIRTHDAY": "Geburtstag",
|
||||
@@ -575,12 +588,22 @@
|
||||
"mute": "Stummschalten",
|
||||
"unmute": "Ton einschalten",
|
||||
"videoError": "Video konnte nicht verarbeitet werden.",
|
||||
"openComments": "Kommentare öffnen"
|
||||
"openComments": "Kommentare öffnen",
|
||||
"deleteStory": "Story löschen",
|
||||
"deleteStoryTitle": "Story löschen?",
|
||||
"deleteStoryDescription": "Die Story wird mit allen Aufrufen, Pfoten und Kommentaren endgültig gelöscht.",
|
||||
"deleteStoryCancel": "Abbrechen",
|
||||
"deleteStoryConfirm": "Löschen",
|
||||
"storyDeleted": "Story gelöscht",
|
||||
"storyDeleteError": "Die Story konnte nicht gelöscht werden. Bitte versuch es noch einmal.",
|
||||
"expiredOwnerOnly": "Abgelaufen – nur für dich sichtbar",
|
||||
"pawCount": "{count, plural, one {# Pfote} other {# Pfoten}}"
|
||||
},
|
||||
"StoryTray": {
|
||||
"storiesAriaLabel": "Storys",
|
||||
"addStory": "Story hinzufügen",
|
||||
"newStoryTitle": "Neue Story"
|
||||
"newStoryTitle": "Neue Story",
|
||||
"yourStory": "Deine Story"
|
||||
},
|
||||
"RepostCard": {
|
||||
"pawedBackBy": "Paw-Back von"
|
||||
@@ -632,7 +655,8 @@
|
||||
"statsFollowers": "Follower",
|
||||
"statsFollowing": "Folgt",
|
||||
"adoptionStory": "Adoptionsgeschichte",
|
||||
"verified": "Verifiziert"
|
||||
"verified": "Verifiziert",
|
||||
"invitedBy": "Eingeladen von <link>{name}</link>"
|
||||
},
|
||||
"EditPet": {
|
||||
"title": "{name} bearbeiten",
|
||||
@@ -650,9 +674,9 @@
|
||||
"onlyMe": "Nur ich",
|
||||
"followerVisibilitySaved": "Follower-Sichtbarkeit gespeichert",
|
||||
"followerVisibilitySaveFailed": "Follower-Sichtbarkeit konnte nicht gespeichert werden",
|
||||
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {name} sehen.",
|
||||
"followerFollowersOnlyHint": "Nur Tiere, die {name} folgen, können die Follower- und Folgt-Listen sehen.",
|
||||
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {name} sehen.",
|
||||
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {name} sehen und {name} in Storys markieren.",
|
||||
"followerFollowersOnlyHint": "Nur Tiere, die {name} folgen, können die Follower- und Folgt-Listen sehen und {name} in Storys markieren.",
|
||||
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {name} sehen — markieren in Storys kann {name} niemand.",
|
||||
"verificationHeading": "Verifizierung",
|
||||
"verifiedStatus": "Dieses Tier ist verifiziert.",
|
||||
"pendingStatus": "Dein Verifizierungsantrag wird noch geprüft.",
|
||||
@@ -742,7 +766,11 @@
|
||||
"detailPoint2": "Gesundheitsdaten bleiben privat, außer du teilst sie bewusst per Link",
|
||||
"footerTagline": "Wo Haustiere die Stars sind.",
|
||||
"footerDesignedBy": "Designed & Powered by PawFeed",
|
||||
"footerSecurityBadge": "Sicherheits-Check: Note A+"
|
||||
"footerSecurityBadge": "Sicherheits-Check: Note A+",
|
||||
"heroAndroidHint": "Auch als App für Android",
|
||||
"playStoreAlt": "Jetzt bei Google Play",
|
||||
"appHeading": "PawFeed für die Hosentasche",
|
||||
"appBody": "Die Android-App bringt Feed, Storys und Nachrichten direkt aufs Handy — mit Push-Benachrichtigungen, wenn dein Tier ein neues Like oder einen neuen Follower bekommt."
|
||||
},
|
||||
"AboutPage": {
|
||||
"metaTitle": "Über uns — PawFeed",
|
||||
@@ -761,5 +789,95 @@
|
||||
"statSponsors": "Unterstützer:innen",
|
||||
"ctaHeading": "Lust, dabei zu sein?",
|
||||
"ctaButton": "Jetzt starten"
|
||||
},
|
||||
"Referrals": {
|
||||
"title": "Freunde einladen",
|
||||
"subtitle": "Teile deinen Link. Jedes Tier, das über deine Einladung kommt und seinen ersten Beitrag postet, bringt dich einen Rang weiter.",
|
||||
"programPaused": "Das Empfehlungsprogramm ist gerade pausiert. Dein Link funktioniert, neue Einladungen werden aber im Moment nicht gezählt.",
|
||||
"shareHeading": "Dein Einladungslink",
|
||||
"shareSubtitle": "Unbegrenzt oft nutzbar. Wer sich ohne Link registriert, kann deinen Code auch im Onboarding eingeben.",
|
||||
"yourCode": "Dein Code",
|
||||
"share": "Teilen",
|
||||
"copyLink": "Link kopieren",
|
||||
"copied": "Kopiert",
|
||||
"copiedToast": "Link kopiert",
|
||||
"shareTitle": "Komm zu PawFeed",
|
||||
"shareText": "Mein Tier ist auf PawFeed, dem sozialen Netzwerk für Haustiere. Mit meinem Code {code} sind wir direkt verbunden:",
|
||||
"ranksHeading": "Deine Ränge",
|
||||
"countedInvites": "{count, plural, =0 {Noch keine gezählte Einladung} one {# gezählte Einladung} other {# gezählte Einladungen}}",
|
||||
"nextRank": "Noch {remaining, plural, one {# Einladung} other {# Einladungen}} bis {name}",
|
||||
"allRanksReached": "Du hast alle Ränge erreicht. Danke!",
|
||||
"achieved": "Erreicht",
|
||||
"threshold": "{count, plural, one {# Einladung} other {# Einladungen}}",
|
||||
"inviteesHeading": "Über dich gekommen",
|
||||
"inviteesEmpty": "Noch niemand. Teile deinen Link mit Freunden, die selbst ein Tier haben.",
|
||||
"inviteeNoPet": "Noch kein Tier angelegt",
|
||||
"statusCounted": "Zählt",
|
||||
"statusWaiting": "Wartet auf ersten Beitrag",
|
||||
"statusInReview": "Wird geprüft",
|
||||
"statusRevoked": "Aberkannt",
|
||||
"redeemHeading": "Wurdest du eingeladen?",
|
||||
"redeemSubtitle": "Gib den Code der Person ein, die dich eingeladen hat. Das geht nur in den ersten Tagen nach der Registrierung.",
|
||||
"redeemLabel": "Einladungscode",
|
||||
"redeemCta": "Einlösen",
|
||||
"redeemedToast": "Eingelöst. Du und {name} folgt euch jetzt.",
|
||||
"redeemedToastGeneric": "Code eingelöst.",
|
||||
"errorPaused": "Das Empfehlungsprogramm ist gerade pausiert.",
|
||||
"errorInvalid": "Diesen Code gibt es nicht. Prüf die Schreibweise, z. B. BUBI-7K3M.",
|
||||
"errorOwnCode": "Das ist dein eigener Code. Gib den Code der Person ein, die dich eingeladen hat.",
|
||||
"errorAlreadyRedeemed": "Du hast schon einen Einladungscode eingelöst.",
|
||||
"errorWindowExpired": "Einladungscodes können nur in den ersten Tagen nach der Registrierung eingelöst werden.",
|
||||
"errorNoPet": "Leg zuerst ein Tier an, dann kannst du den Code einlösen.",
|
||||
"errorTooMany": "Zu viele Versuche. Bitte warte ein paar Minuten.",
|
||||
"errorGeneric": "Das hat nicht geklappt. Bitte versuch es noch einmal.",
|
||||
"rulesNote": "Eine Einladung zählt, sobald das eingeladene Tier angelegt ist und seinen ersten Beitrag veröffentlicht hat. Ränge sind digitale Auszeichnungen ohne Geldwert. Einmal erreichte Ränge bleiben erhalten, außer bei Missbrauch."
|
||||
},
|
||||
"ReferralOnboarding": {
|
||||
"title": "Wie hast du von PawFeed erfahren?",
|
||||
"subtitle": "Freiwillig. Es hilft uns zu verstehen, wo wir neue Tierfreunde erreichen.",
|
||||
"source_FRIENDS": "Freunde oder Familie",
|
||||
"source_TIKTOK": "TikTok",
|
||||
"source_INSTAGRAM": "Instagram",
|
||||
"source_YOUTUBE": "YouTube",
|
||||
"source_FACEBOOK": "Facebook",
|
||||
"source_SHELTER_OR_PARTNER": "Tierheim, Verein oder Laden",
|
||||
"source_STORE_SEARCH": "App Store oder Google Play",
|
||||
"source_PRESS": "Zeitung, Radio oder Blog",
|
||||
"source_OTHER": "Anders",
|
||||
"redeemed": "Eingelöst. Du und {name} folgt euch jetzt.",
|
||||
"redeemedGeneric": "Code eingelöst.",
|
||||
"continue": "Weiter",
|
||||
"skip": "Überspringen"
|
||||
},
|
||||
"ReferralLanding": {
|
||||
"titleWithPet": "{name} lädt dich zu PawFeed ein",
|
||||
"titleGeneric": "Du bist zu PawFeed eingeladen",
|
||||
"subtitle": "PawFeed ist das soziale Netzwerk, in dem dein Tier das Profil hat: Fotos, Kurzvideos, Storys und Impfpass an einem Ort.",
|
||||
"codeLabel": "Einladungscode",
|
||||
"codeHint": "Gib den Code nach der Registrierung im Onboarding ein, dann seid ihr direkt verbunden.",
|
||||
"webSignUp": "Im Browser registrieren"
|
||||
},
|
||||
"FoundingMember": {
|
||||
"label": "Gründungsmitglied",
|
||||
"tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed"
|
||||
},
|
||||
"InMemory": {
|
||||
"label": "In Erinnerung",
|
||||
"tooltip": "In Erinnerung – dieses Tier ist über die Regenbogenbrücke gegangen",
|
||||
"banner": "In Erinnerung an {name} · {date}",
|
||||
"settingsHeading": "Gedenkmodus",
|
||||
"settingsHint": "Wenn dein Tier verstorben ist, kannst du sein Profil als Erinnerung erhalten. Es bekommt das Abzeichen „In Erinnerung“, Beiträge bleiben sichtbar und du kannst weiter Erinnerungen teilen. Geburtstags-Mitteilungen, Impf-Erinnerungen und Vorschläge enden.",
|
||||
"activeHint": "{name} ist im Gedenkmodus. Du kannst das Datum korrigieren oder den Modus beenden.",
|
||||
"dateLabel": "Verstorben am",
|
||||
"activate": "Gedenkmodus aktivieren",
|
||||
"saveDate": "Datum speichern",
|
||||
"remove": "Gedenkmodus beenden",
|
||||
"confirmTitle": "{name} in Erinnerung halten?",
|
||||
"confirmDescription": "Das Profil bekommt das Abzeichen „In Erinnerung“. Wer deinem Tier folgt, erhält einmalig eine stille Mitteilung (ohne Push). Du kannst das jederzeit zurücknehmen.",
|
||||
"confirm": "Ja, aktivieren",
|
||||
"cancel": "Abbrechen",
|
||||
"saved": "Gedenkmodus gespeichert",
|
||||
"removed": "Gedenkmodus beendet",
|
||||
"saveFailed": "Konnte nicht gespeichert werden"
|
||||
}
|
||||
}
|
||||
+131
-13
@@ -116,7 +116,8 @@
|
||||
"zoom": "Zoom",
|
||||
"cropCancel": "Cancel",
|
||||
"cropSave": "Apply",
|
||||
"cropSaving": "Processing…"
|
||||
"cropSaving": "Processing…",
|
||||
"heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
|
||||
},
|
||||
"Onboarding": {
|
||||
"addFirstPet": "Add your first pet",
|
||||
@@ -124,7 +125,8 @@
|
||||
"addPhoto": "Add a profile photo",
|
||||
"addPhotoDesc": "Give your pet a face on PawFeed. You can always change this later.",
|
||||
"done": "Done",
|
||||
"skipForNow": "Skip for now"
|
||||
"skipForNow": "Skip for now",
|
||||
"loading": "Loading"
|
||||
},
|
||||
"Language": {
|
||||
"switch": "Language",
|
||||
@@ -258,7 +260,8 @@
|
||||
"postError": "Couldn't post comment. Please try again.",
|
||||
"deleteError": "Couldn't delete comment. Please try again.",
|
||||
"countSingular": "1 comment",
|
||||
"countPlural": "{count} comments"
|
||||
"countPlural": "{count} comments",
|
||||
"readOnlyExpired": "This story has expired. You can still read comments but not add new ones."
|
||||
},
|
||||
"RelativeTime": {
|
||||
"justNow": "now",
|
||||
@@ -477,7 +480,15 @@
|
||||
"typePawBack": "shared your post (Paw-Back)",
|
||||
"typeBirthday": "🎂 Happy Birthday! Create an anniversary post.",
|
||||
"typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary.",
|
||||
"typePetSuggestion": "might be a good match"
|
||||
"typePetSuggestion": "might be a good match",
|
||||
"typeReferralJoined": "joined PawFeed through your invite",
|
||||
"typeReferralRankUp": "You reached a new referral rank. Take a look!",
|
||||
"typeVaccineDue": "💉 The \"{name}\" vaccination is due on {date} – time to book an appointment.",
|
||||
"typeStoryMention": "tagged you in a story.",
|
||||
"typeStoryComment": "commented on your story.",
|
||||
"typeStoryReaction": "gave your story a paw.",
|
||||
"typeUnknown": "has a new notification for you.",
|
||||
"typePetDeceased": "has sadly passed away. In memory 🕯️"
|
||||
},
|
||||
"PhotoPostForm": {
|
||||
"captionPlaceholder": "Write a caption…",
|
||||
@@ -507,7 +518,8 @@
|
||||
"videoTooLong": "Story videos can be at most {max} seconds long.",
|
||||
"videoUnreadable": "Couldn't read the video length — please try a different file.",
|
||||
"musicLabel": "Add music (optional)",
|
||||
"muteOriginalLabel": "Mute original audio, play this track instead"
|
||||
"muteOriginalLabel": "Mute original audio, play this track instead",
|
||||
"heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
|
||||
},
|
||||
"MilestoneForm": {
|
||||
"questionLabel": "What's the milestone?",
|
||||
@@ -527,7 +539,8 @@
|
||||
"errorToast": "Couldn't share this milestone. Please try again.",
|
||||
"fileTooLarge": "Photo must be under 10 MB.",
|
||||
"invalidType": "Upload a JPG, PNG, or WebP image.",
|
||||
"uploadFailed": "Upload failed — please try again."
|
||||
"uploadFailed": "Upload failed — please try again.",
|
||||
"heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
|
||||
},
|
||||
"MilestoneTypes": {
|
||||
"BIRTHDAY": "Birthday",
|
||||
@@ -575,12 +588,22 @@
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"videoError": "Video could not be processed.",
|
||||
"openComments": "Open comments"
|
||||
"openComments": "Open comments",
|
||||
"deleteStory": "Delete story",
|
||||
"deleteStoryTitle": "Delete story?",
|
||||
"deleteStoryDescription": "The story and all its views, paws and comments will be deleted permanently.",
|
||||
"deleteStoryCancel": "Cancel",
|
||||
"deleteStoryConfirm": "Delete",
|
||||
"storyDeleted": "Story deleted",
|
||||
"storyDeleteError": "The story could not be deleted. Please try again.",
|
||||
"expiredOwnerOnly": "Expired – only visible to you",
|
||||
"pawCount": "{count, plural, one {# paw} other {# paws}}"
|
||||
},
|
||||
"StoryTray": {
|
||||
"storiesAriaLabel": "Stories",
|
||||
"addStory": "Add story",
|
||||
"newStoryTitle": "New Story"
|
||||
"newStoryTitle": "New Story",
|
||||
"yourStory": "Your story"
|
||||
},
|
||||
"RepostCard": {
|
||||
"pawedBackBy": "Pawed-back by"
|
||||
@@ -632,7 +655,8 @@
|
||||
"statsFollowers": "followers",
|
||||
"statsFollowing": "following",
|
||||
"adoptionStory": "Adoption story",
|
||||
"verified": "Verified"
|
||||
"verified": "Verified",
|
||||
"invitedBy": "Invited by <link>{name}</link>"
|
||||
},
|
||||
"EditPet": {
|
||||
"title": "Edit {name}",
|
||||
@@ -650,9 +674,9 @@
|
||||
"onlyMe": "Only me",
|
||||
"followerVisibilitySaved": "Follower privacy setting saved",
|
||||
"followerVisibilitySaveFailed": "Failed to save follower privacy setting",
|
||||
"followerEveryoneHint": "Anyone can see {name}'s followers and following lists.",
|
||||
"followerFollowersOnlyHint": "Only pets that follow {name} can see the followers and following lists.",
|
||||
"followerNobodyHint": "Only you can see {name}'s followers and following lists.",
|
||||
"followerEveryoneHint": "Anyone can see {name}'s followers and following lists and tag {name} in stories.",
|
||||
"followerFollowersOnlyHint": "Only pets that follow {name} can see the followers and following lists and tag {name} in stories.",
|
||||
"followerNobodyHint": "Only you can see {name}'s followers and following lists — nobody can tag {name} in stories.",
|
||||
"verificationHeading": "Verification",
|
||||
"verifiedStatus": "This pet is verified.",
|
||||
"pendingStatus": "Your verification request is pending review.",
|
||||
@@ -742,7 +766,11 @@
|
||||
"detailPoint2": "Health data stays private unless you deliberately share it via link",
|
||||
"footerTagline": "Where pets are the stars.",
|
||||
"footerDesignedBy": "Designed & Powered by PawFeed",
|
||||
"footerSecurityBadge": "Security check: Grade A+"
|
||||
"footerSecurityBadge": "Security check: Grade A+",
|
||||
"heroAndroidHint": "Also available as an Android app",
|
||||
"playStoreAlt": "Get it on Google Play",
|
||||
"appHeading": "PawFeed in your pocket",
|
||||
"appBody": "The Android app puts the feed, stories, and messages right on your phone — with push notifications when your pet gets a new like or follower."
|
||||
},
|
||||
"AboutPage": {
|
||||
"metaTitle": "About us — PawFeed",
|
||||
@@ -761,5 +789,95 @@
|
||||
"statSponsors": "Supporters",
|
||||
"ctaHeading": "Want to be part of it?",
|
||||
"ctaButton": "Get started"
|
||||
},
|
||||
"Referrals": {
|
||||
"title": "Invite friends",
|
||||
"subtitle": "Share your link. Every pet that joins through your invite and publishes its first post moves you up a rank.",
|
||||
"programPaused": "The referral program is paused right now. Your link still works, but new invites are not counted at the moment.",
|
||||
"shareHeading": "Your invite link",
|
||||
"shareSubtitle": "Use it as often as you like. People who sign up without the link can still enter your code during onboarding.",
|
||||
"yourCode": "Your code",
|
||||
"share": "Share",
|
||||
"copyLink": "Copy link",
|
||||
"copied": "Copied",
|
||||
"copiedToast": "Link copied",
|
||||
"shareTitle": "Join PawFeed",
|
||||
"shareText": "My pet is on PawFeed, the social network for pets. Use my code {code} and we are connected right away:",
|
||||
"ranksHeading": "Your ranks",
|
||||
"countedInvites": "{count, plural, =0 {No counted invites yet} one {# counted invite} other {# counted invites}}",
|
||||
"nextRank": "{remaining, plural, one {# more invite} other {# more invites}} until {name}",
|
||||
"allRanksReached": "You reached every rank. Thank you!",
|
||||
"achieved": "Reached",
|
||||
"threshold": "{count, plural, one {# invite} other {# invites}}",
|
||||
"inviteesHeading": "Joined through you",
|
||||
"inviteesEmpty": "Nobody yet. Share your link with friends who have a pet.",
|
||||
"inviteeNoPet": "No pet created yet",
|
||||
"statusCounted": "Counts",
|
||||
"statusWaiting": "Waiting for first post",
|
||||
"statusInReview": "Under review",
|
||||
"statusRevoked": "Revoked",
|
||||
"redeemHeading": "Were you invited?",
|
||||
"redeemSubtitle": "Enter the code of the person who invited you. This only works during the first days after signing up.",
|
||||
"redeemLabel": "Invite code",
|
||||
"redeemCta": "Redeem",
|
||||
"redeemedToast": "Redeemed. You and {name} now follow each other.",
|
||||
"redeemedToastGeneric": "Code redeemed.",
|
||||
"errorPaused": "The referral program is paused right now.",
|
||||
"errorInvalid": "That code does not exist. Check the spelling, e.g. BUBI-7K3M.",
|
||||
"errorOwnCode": "That is your own code. Enter the code of the person who invited you.",
|
||||
"errorAlreadyRedeemed": "You already redeemed an invite code.",
|
||||
"errorWindowExpired": "Invite codes can only be redeemed during the first days after signing up.",
|
||||
"errorNoPet": "Create a pet first, then you can redeem the code.",
|
||||
"errorTooMany": "Too many attempts. Please wait a few minutes.",
|
||||
"errorGeneric": "That did not work. Please try again.",
|
||||
"rulesNote": "An invite counts once the invited pet is created and has published its first post. Ranks are digital awards without monetary value. Ranks you reach stay with you unless the program is abused."
|
||||
},
|
||||
"ReferralOnboarding": {
|
||||
"title": "How did you hear about PawFeed?",
|
||||
"subtitle": "Optional. It helps us understand where we reach new pet lovers.",
|
||||
"source_FRIENDS": "Friends or family",
|
||||
"source_TIKTOK": "TikTok",
|
||||
"source_INSTAGRAM": "Instagram",
|
||||
"source_YOUTUBE": "YouTube",
|
||||
"source_FACEBOOK": "Facebook",
|
||||
"source_SHELTER_OR_PARTNER": "Shelter, club or shop",
|
||||
"source_STORE_SEARCH": "App Store or Google Play",
|
||||
"source_PRESS": "Newspaper, radio or blog",
|
||||
"source_OTHER": "Other",
|
||||
"redeemed": "Redeemed. You and {name} now follow each other.",
|
||||
"redeemedGeneric": "Code redeemed.",
|
||||
"continue": "Continue",
|
||||
"skip": "Skip"
|
||||
},
|
||||
"ReferralLanding": {
|
||||
"titleWithPet": "{name} invites you to PawFeed",
|
||||
"titleGeneric": "You are invited to PawFeed",
|
||||
"subtitle": "PawFeed is the social network where your pet has the profile: photos, short videos, stories and vaccination record in one place.",
|
||||
"codeLabel": "Invite code",
|
||||
"codeHint": "Enter the code during onboarding after signing up and you are connected right away.",
|
||||
"webSignUp": "Sign up in the browser"
|
||||
},
|
||||
"FoundingMember": {
|
||||
"label": "Founding member",
|
||||
"tooltip": "Founding member – one of the first 250 pets on PawFeed"
|
||||
},
|
||||
"InMemory": {
|
||||
"label": "In memory",
|
||||
"tooltip": "In memory – this pet has crossed the rainbow bridge",
|
||||
"banner": "In memory of {name} · {date}",
|
||||
"settingsHeading": "Memorial mode",
|
||||
"settingsHint": "If your pet has passed away, you can keep their profile as a memorial. It gets the “In memory” badge, posts stay visible and you can keep sharing memories. Birthday notifications, vaccine reminders and suggestions stop.",
|
||||
"activeHint": "{name} is in memorial mode. You can correct the date or end the mode.",
|
||||
"dateLabel": "Passed away on",
|
||||
"activate": "Turn on memorial mode",
|
||||
"saveDate": "Save date",
|
||||
"remove": "End memorial mode",
|
||||
"confirmTitle": "Keep {name} in memory?",
|
||||
"confirmDescription": "The profile gets the “In memory” badge. Everyone following your pet receives a single quiet notification (no push). You can undo this at any time.",
|
||||
"confirm": "Yes, turn on",
|
||||
"cancel": "Cancel",
|
||||
"saved": "Memorial mode saved",
|
||||
"removed": "Memorial mode ended",
|
||||
"saveFailed": "Could not be saved"
|
||||
}
|
||||
}
|
||||
@@ -48,3 +48,6 @@ yarn-error.*
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
# Local tester/debug screenshots (e.g. from appetize.io) — not app assets
|
||||
/debug
|
||||
+17
-3
@@ -8,8 +8,13 @@
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "org.pawfeed.app"
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "org.pawfeed.app",
|
||||
"buildNumber": "3",
|
||||
"usesAppleSignIn": true,
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "org.pawfeed.app",
|
||||
@@ -64,7 +69,16 @@
|
||||
}
|
||||
],
|
||||
"expo-web-browser",
|
||||
"@sentry/react-native"
|
||||
"expo-apple-authentication",
|
||||
"@sentry/react-native",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"deploymentTarget": "17.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"extra": {
|
||||
"router": {},
|
||||
|
||||
@@ -152,13 +152,28 @@ function SettingsHeaderButton() {
|
||||
);
|
||||
}
|
||||
|
||||
// found.md: a push for a new message arrived but the speech bubble showed
|
||||
// nothing. Polling keeps the badge current while the app stays open; reading
|
||||
// a conversation invalidates conversations.* (see messages/[conversationId]).
|
||||
const UNREAD_MESSAGES_POLL_MS = 30 * 1000;
|
||||
|
||||
function MessagesHeaderButton() {
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const { data } = useQuery({
|
||||
...trpc.conversations.unreadCount.queryOptions({ petId: activePetId ?? "" }),
|
||||
enabled: !!activePetId,
|
||||
refetchInterval: UNREAD_MESSAGES_POLL_MS,
|
||||
});
|
||||
return (
|
||||
<Pressable onPress={() => router.push("/messages")} hitSlop={8} style={styles.headerButton}>
|
||||
<MessageCircle color={colors.ink} size={22} />
|
||||
<View>
|
||||
<MessageCircle color={colors.ink} size={22} />
|
||||
<UnreadBadge count={data?.count ?? 0} styles={styles} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -172,16 +187,20 @@ function NotificationsTabIcon({ color, size }: { color: ColorValue; size: number
|
||||
...trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" }),
|
||||
enabled: !!activePetId,
|
||||
});
|
||||
const count = data?.count ?? 0;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Bell color={color} size={size} />
|
||||
{count > 0 && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{count > 9 ? "9+" : count}</Text>
|
||||
</View>
|
||||
)}
|
||||
<UnreadBadge count={data?.count ?? 0} styles={styles} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UnreadBadge({ count, styles }: { count: number; styles: ReturnType<typeof makeStyles> }) {
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{count > 9 ? "9+" : count}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ function ExplorePetCard({ pet }: { pet: ExplorePet }) {
|
||||
return (
|
||||
<Pressable style={styles.cardPress} onPress={() => router.push(`/pets/${pet.id}`)}>
|
||||
<Card style={styles.card}>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" owner={pet.owner} />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{formatPetName(pet)}
|
||||
</Text>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useActivePet } from "../../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../../src/context/ThemeContext";
|
||||
import { PetAvatar } from "../../../src/components/PetAvatar";
|
||||
import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
|
||||
import { isoToDisplayDate } from "../../../src/lib/date-format";
|
||||
import { notificationRoute } from "../../../src/lib/notification-route";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
|
||||
import type { NotificationItem } from "../../../src/api/types";
|
||||
|
||||
@@ -20,6 +22,11 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
MENTION: "notifications.mention",
|
||||
PAW_BACK: "notifications.pawBack",
|
||||
PET_SUGGESTION: "notifications.petSuggestion",
|
||||
REFERRAL_JOINED: "notifications.referralJoined",
|
||||
STORY_MENTION: "notifications.storyMention",
|
||||
STORY_COMMENT: "notifications.storyComment",
|
||||
STORY_REACTION: "notifications.storyReaction",
|
||||
PET_DECEASED: "notifications.petDeceased",
|
||||
};
|
||||
|
||||
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
|
||||
@@ -28,6 +35,7 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
const SELF_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
BIRTHDAY: "notifications.birthday",
|
||||
ADOPTION_DAY: "notifications.adoptionDay",
|
||||
REFERRAL_RANK_UP: "notifications.referralRankUp",
|
||||
};
|
||||
|
||||
// Suffix only — the actor's name is rendered separately as its own tappable
|
||||
@@ -35,41 +43,51 @@ const SELF_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
// independently of the row's own tap target, ported from web's
|
||||
// notifications/page.tsx (name as a <Link>, rest as plain text).
|
||||
function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
|
||||
if (item.type === "VACCINE_DUE") {
|
||||
if (!item.vaccine?.nextDueDate) return t("notifications.vaccineDueGeneric");
|
||||
const iso = new Date(item.vaccine.nextDueDate).toISOString().slice(0, 10);
|
||||
return t("notifications.vaccineDue", { name: item.vaccine.name, date: isoToDisplayDate(iso) });
|
||||
}
|
||||
const selfKey = SELF_MESSAGE_KEYS[item.type];
|
||||
if (selfKey) return t(selfKey);
|
||||
const actorKey = ACTOR_MESSAGE_KEYS[item.type];
|
||||
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
|
||||
* the actor's profile, BIRTHDAY/ADOPTION_DAY to this pet's own health page
|
||||
* (the anniversary is about *this* pet, there's no other actor to visit),
|
||||
* and REACTION/COMMENT/MENTION/PAW_BACK go to the post. Web opens the post
|
||||
* 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).
|
||||
* Ziel einer Mitteilung — gemeinsame Regel mit dem Antippen von Push-Mitteilungen
|
||||
* (src/lib/notification-route.ts): Nachricht → Gespräch, Kommentar/Pfote/Erwähnung
|
||||
* → Beitrag, Story-Mitteilungen → die Story, Follow/Vorschlag → Profil,
|
||||
* Geburtstag/Impfung → Gesundheit. Nur in Event-Handlern aufrufen (Date.now()).
|
||||
*/
|
||||
function getNotificationRoute(item: NotificationItem): string | null {
|
||||
switch (item.type) {
|
||||
case "FOLLOW":
|
||||
case "PET_SUGGESTION":
|
||||
return item.actorPet ? `/pets/${item.actorPet.id}` : null;
|
||||
case "BIRTHDAY":
|
||||
case "ADOPTION_DAY":
|
||||
return item.recipientPetId ? `/pets/${item.recipientPetId}/health` : null;
|
||||
case "REACTION":
|
||||
case "COMMENT":
|
||||
case "MENTION":
|
||||
case "PAW_BACK":
|
||||
return item.post?.id ? `/posts/${item.post.id}/comments` : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return notificationRoute(
|
||||
{
|
||||
type: item.type,
|
||||
recipientPetId: item.recipientPetId,
|
||||
actorPetId: item.actorPet?.id ?? null,
|
||||
postId: item.post?.id ?? null,
|
||||
conversationId: item.conversationId,
|
||||
storyId: item.story?.id ?? null,
|
||||
storyPetId: item.story?.petId ?? null,
|
||||
storyPetName: item.story?.pet.name ?? null,
|
||||
storyPetAvatarKey: item.story?.pet.avatarKey ?? null,
|
||||
storyExpiresAt: item.story?.expiresAt ?? null,
|
||||
},
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
export default function NotificationsScreen() {
|
||||
const { t } = useTranslation();
|
||||
@@ -152,16 +170,28 @@ export default function NotificationsScreen() {
|
||||
}}
|
||||
>
|
||||
{item.actorPet ? (
|
||||
<PetAvatar name={item.actorPet.name} avatarKey={item.actorPet.avatarKey} />
|
||||
<PetAvatar name={item.actorPet.name} avatarKey={item.actorPet.avatarKey} owner={item.actorPet.owner} />
|
||||
) : (
|
||||
<View style={styles.selfIcon}>
|
||||
<Text style={styles.selfIconText}>{item.type === "BIRTHDAY" ? "🎂" : "🏠"}</Text>
|
||||
<Text style={styles.selfIconText}>{SELF_ICONS[item.type] ?? "🏠"}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.bodyText}>
|
||||
{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)}{" "}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -80,11 +80,11 @@ function PetRow({
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<Card style={styles.row}>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" owner={pet.owner} />
|
||||
<View style={styles.rowText}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
||||
<PetIdentityBadges isVerified={pet.isVerified} />
|
||||
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} />
|
||||
</View>
|
||||
<Text style={styles.breed}>{pet.breed?.name ?? pet.species.name}</Text>
|
||||
</View>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { fontFamily } from "../../src/theme/tokens";
|
||||
import { useNotificationTapRouting } from "../../src/hooks/useNotificationTapRouting";
|
||||
|
||||
function CloseModalButton() {
|
||||
const router = useRouter();
|
||||
@@ -17,6 +18,8 @@ function CloseModalButton() {
|
||||
|
||||
export default function AppLayout() {
|
||||
const { t } = useTranslation();
|
||||
// Push-Mitteilung angetippt → direkt zum Ziel (Gespräch, Beitrag, Story …)
|
||||
useNotificationTapRouting();
|
||||
const { colors } = useTheme();
|
||||
// Plain prop objects, not StyleSheet.create — safe to recompute on every
|
||||
// render, unlike the module-level StyleSheet.create calls most other
|
||||
@@ -31,6 +34,9 @@ export default function AppLayout() {
|
||||
// found.md: "kein Zurückbutton" on the health screen wasn't a missing
|
||||
// button, it was a black-on-black one).
|
||||
headerTintColor: colors.ink,
|
||||
// iOS labels the back button with the previous screen's title — here the
|
||||
// untitled `(tabs)` group, so it literally read "< (tabs)". Chevron only.
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
@@ -47,6 +53,8 @@ export default function AppLayout() {
|
||||
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: t("screens.conversation") }} />
|
||||
<Stack.Screen name="search" options={{ ...headerOptions, title: t("screens.search") }} />
|
||||
<Stack.Screen name="settings" options={{ ...headerOptions, title: t("screens.settings") }} />
|
||||
<Stack.Screen name="invite" options={{ ...headerOptions, title: t("screens.invite") }} />
|
||||
<Stack.Screen name="delete-account" options={{ ...headerOptions, title: t("screens.deleteAccount") }} />
|
||||
<Stack.Screen
|
||||
name="post-new"
|
||||
options={{
|
||||
@@ -65,6 +73,11 @@ export default function AppLayout() {
|
||||
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
|
||||
name="milestone-new"
|
||||
options={{
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, ScrollView, Alert, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@clerk/expo";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { TriangleAlert } from "lucide-react-native";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { Button } from "../../src/components/Button";
|
||||
import { TextField } from "../../src/components/TextField";
|
||||
import { env } from "../../src/lib/env";
|
||||
import { getTypography, radii, type ColorTokens, type Typography } from "../../src/theme/tokens";
|
||||
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
|
||||
|
||||
const DELETED_ITEMS = ["itemPets", "itemPosts", "itemMedia", "itemMessages", "itemHealth", "itemAccount"] as const;
|
||||
|
||||
/**
|
||||
* In-app account deletion — required by App Store Guideline 5.1.1(v) for any
|
||||
* app that lets users create an account. Port of web's
|
||||
* src/components/account/DeleteAccountDialog.tsx: same warning list, same
|
||||
* "type one of your pets' names" confirmation, and the same backend route
|
||||
* (POST /api/account/delete → deleteOwnerAccount(), which also deletes the
|
||||
* Clerk user). That route reads Clerk's auth() like tRPC does, so the mobile
|
||||
* session token in the Authorization header authenticates it.
|
||||
*/
|
||||
export default function DeleteAccountScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
const { getToken, signOut } = useAuth();
|
||||
const { pets, clearActivePetId } = useActivePet();
|
||||
const queryClient = useQueryClient();
|
||||
const [petName, setPetName] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isValidName = pets.some((pet) => pet.name.toLowerCase() === petName.trim().toLowerCase());
|
||||
|
||||
const deleteAccount = async () => {
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${env.apiUrl}/api/account/delete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ petNameConfirmation: petName.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? t("deleteAccount.genericError"));
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
setError(err instanceof Error ? err.message : t("deleteAccount.genericError"));
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// The Clerk user no longer exists server-side, so signOut() may reject —
|
||||
// either way the local session is cleared and useProtectedRoute() sends
|
||||
// the user back to sign-in.
|
||||
queryClient.clear();
|
||||
clearActivePetId();
|
||||
Alert.alert(t("deleteAccount.doneTitle"));
|
||||
await signOut().catch(() => undefined);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
Alert.alert(t("deleteAccount.finalConfirmTitle"), t("deleteAccount.finalConfirmMessage"), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: t("deleteAccount.confirmDelete"), style: "destructive", onPress: () => void deleteAccount() },
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
||||
<ScrollView contentContainerStyle={[styles.content, bottomPadding]} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.titleRow}>
|
||||
<TriangleAlert color={colors.alertRed} size={22} />
|
||||
<Text style={styles.title}>{t("deleteAccount.warningTitle")}</Text>
|
||||
</View>
|
||||
<Text style={styles.intro}>{t("deleteAccount.warningIntro")}</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
{DELETED_ITEMS.map((item) => (
|
||||
<Text key={item} style={styles.item}>
|
||||
<Text style={styles.itemMark}>✕ </Text>
|
||||
{t(`deleteAccount.${item}`)}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={styles.intro}>{t("deleteAccount.confirmIntro")}</Text>
|
||||
<TextField
|
||||
placeholder={t("deleteAccount.inputPlaceholder")}
|
||||
value={petName}
|
||||
onChangeText={setPetName}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isDeleting}
|
||||
/>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button
|
||||
title={isDeleting ? t("deleteAccount.deleting") : t("deleteAccount.confirmDelete")}
|
||||
disabled={!isValidName || isDeleting}
|
||||
onPress={confirmDelete}
|
||||
style={styles.deleteButton}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
flex: { flex: 1, backgroundColor: colors.porchWhite },
|
||||
content: { padding: 20, gap: 12, flexGrow: 1, backgroundColor: colors.porchWhite },
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
title: { ...typography.title, fontSize: 18, color: colors.alertRed },
|
||||
intro: { ...typography.body, color: colors.quietGray },
|
||||
card: {
|
||||
gap: 8,
|
||||
padding: 14,
|
||||
borderRadius: radii.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
},
|
||||
item: { ...typography.body },
|
||||
itemMark: { color: colors.alertRed, fontWeight: "700" },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
deleteButton: { marginTop: 8, backgroundColor: colors.alertRed },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useMemo } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Share, StyleSheet, Text, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Share2 } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
|
||||
import { useReferralRanks } from "../../src/hooks/useReferralRanks";
|
||||
import { env } from "../../src/lib/env";
|
||||
import { PetAvatar } from "../../src/components/PetAvatar";
|
||||
import { RankLadder } from "../../src/components/referrals/RankLadder";
|
||||
import { RedeemCodeForm } from "../../src/components/referrals/RedeemCodeForm";
|
||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
|
||||
const STATUS_KEYS = {
|
||||
counted: "referrals.statusCounted",
|
||||
waiting: "referrals.statusWaiting",
|
||||
"in-review": "referrals.statusInReview",
|
||||
revoked: "referrals.statusRevoked",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Port of web's /einladen — eigener Link mit nativem Teilen-Dialog (React
|
||||
* Natives eingebautes Share, kein zusätzliches natives Modul → per OTA
|
||||
* auslieferbar), Rangfortschritt, eingeladene Tiere, nachträgliche Code-Eingabe.
|
||||
*/
|
||||
export default function InviteScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
const { ranks } = useReferralRanks();
|
||||
const { data, isLoading } = useQuery(trpc.referrals.me.queryOptions());
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const link = `${env.apiUrl}/r/${data.code}`;
|
||||
const achieved = new Set(data.achievedRankIds);
|
||||
|
||||
function handleShare() {
|
||||
void Share.share({ message: `${t("referrals.shareText", { code: data!.code })} ${link}` });
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.content, bottomPadding]}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.subtitle}>{t("referrals.subtitle")}</Text>
|
||||
|
||||
{!data.programActive && <Text style={styles.notice}>{t("referrals.programPaused")}</Text>}
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.heading}>{t("referrals.shareHeading")}</Text>
|
||||
<Text style={styles.muted}>{t("referrals.shareSubtitle")}</Text>
|
||||
<View style={styles.codeBox}>
|
||||
<Text style={styles.codeLabel}>{t("referrals.yourCode")}</Text>
|
||||
<Text style={styles.code} selectable>{data.code}</Text>
|
||||
</View>
|
||||
<Pressable style={styles.shareButton} onPress={handleShare} accessibilityRole="button">
|
||||
<Share2 size={18} color="#ffffff" />
|
||||
<Text style={styles.shareText}>{t("referrals.share")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<RankLadder ranks={ranks} achievedRankIds={achieved} qualifiedCount={data.qualifiedCount} nextRankId={data.nextRankId} />
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.heading}>{t("referrals.inviteesHeading")}</Text>
|
||||
{data.invitees.length === 0 ? (
|
||||
<Text style={styles.muted}>{t("referrals.inviteesEmpty")}</Text>
|
||||
) : (
|
||||
data.invitees.map((invitee, index) => (
|
||||
<Pressable
|
||||
key={invitee.pet?.id ?? `invitee-${index}`}
|
||||
style={styles.inviteeRow}
|
||||
disabled={!invitee.pet}
|
||||
onPress={() => invitee.pet && router.push(`/pets/${invitee.pet.id}`)}
|
||||
>
|
||||
{invitee.pet && <PetAvatar name={invitee.pet.name} avatarKey={invitee.pet.avatarKey} size="sm" />}
|
||||
<Text style={styles.inviteeName} numberOfLines={1}>{invitee.pet?.name ?? t("referrals.inviteeNoPet")}</Text>
|
||||
<Text style={styles.muted}>{t(STATUS_KEYS[invitee.status])}</Text>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
{data.canRedeem && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.heading}>{t("referrals.redeemHeading")}</Text>
|
||||
<Text style={styles.muted}>{t("referrals.redeemSubtitle")}</Text>
|
||||
<RedeemCodeForm />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.footnote}>{t("referrals.rulesNote")}</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite },
|
||||
content: { padding: 16, gap: 16, flexGrow: 1, backgroundColor: colors.porchWhite },
|
||||
subtitle: { ...typography.body, color: colors.quietGray },
|
||||
notice: { ...typography.body, fontSize: 13, padding: 12, borderRadius: radii.xl, backgroundColor: colors.marigoldTint, color: colors.ink },
|
||||
card: { gap: 10, padding: 16, borderRadius: radii["2xl"], borderWidth: 1, borderColor: colors.hairlineGray, backgroundColor: colors.pureWhite },
|
||||
heading: { ...typography.title, fontSize: 16 },
|
||||
muted: { ...typography.body, fontSize: 13, color: colors.quietGray },
|
||||
codeBox: { alignItems: "center", paddingVertical: 12, borderRadius: radii.xl, backgroundColor: colors.whisperGray },
|
||||
codeLabel: { ...typography.body, fontSize: 11, letterSpacing: 1, textTransform: "uppercase", color: colors.quietGray },
|
||||
code: { ...typography.title, fontSize: 24, letterSpacing: 2 },
|
||||
shareButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, height: 48, borderRadius: radii.lg, backgroundColor: colors.marigoldOrange },
|
||||
shareText: { ...typography.body, fontWeight: "600", color: "#ffffff" },
|
||||
inviteeRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
|
||||
inviteeName: { ...typography.body, flex: 1, fontWeight: "600" },
|
||||
footnote: { ...typography.body, fontSize: 12, color: colors.quietGray },
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { PetAvatar } from "../../../src/components/PetAvatar";
|
||||
import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
|
||||
import type { Conversation } from "../../../src/api/types";
|
||||
import { useBottomSafePadding } from "../../../src/hooks/useBottomSafePadding";
|
||||
|
||||
/** Conversations list (conversations.list) — taps a row to open the chat detail/compose screen. */
|
||||
export default function MessagesScreen() {
|
||||
@@ -19,6 +20,7 @@ export default function MessagesScreen() {
|
||||
const { activePetId } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
...trpc.conversations.list.queryOptions({ petId: activePetId ?? "" }),
|
||||
@@ -45,7 +47,7 @@ export default function MessagesScreen() {
|
||||
<FlatList
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
contentContainerStyle={[styles.list, bottomPadding]}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
renderItem={({ item }) => (
|
||||
<ConversationRow conversation={item} onPress={() => router.push(`/messages/${item.id}`)} />
|
||||
@@ -64,7 +66,7 @@ function ConversationRow({ conversation, onPress }: { conversation: Conversation
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
return (
|
||||
<Pressable style={styles.row} onPress={onPress}>
|
||||
<PetAvatar name={conversation.otherPet.name} avatarKey={conversation.otherPet.avatarKey} />
|
||||
<PetAvatar name={conversation.otherPet.name} avatarKey={conversation.otherPet.avatarKey} owner={conversation.otherPet.owner} />
|
||||
<View style={styles.rowText}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(conversation.otherPet)}</Text>
|
||||
|
||||
@@ -88,7 +88,11 @@ export default function NewMilestoneScreen() {
|
||||
const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending;
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.container}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.label}>{t("milestones.typeLabel")}</Text>
|
||||
<View style={styles.chipRow}>
|
||||
{USER_INITIATED_MILESTONE_TYPES.map((type) => {
|
||||
|
||||
@@ -8,15 +8,20 @@ import { useTRPC } from "../../../../src/api/trpc";
|
||||
import { useActivePet } from "../../../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../../../src/context/ThemeContext";
|
||||
import { TextField } from "../../../../src/components/TextField";
|
||||
import { DateField } from "../../../../src/components/DateField";
|
||||
import { Button } from "../../../../src/components/Button";
|
||||
import { BreedPicker } from "../../../../src/components/pet/BreedPicker";
|
||||
import { PrivacySettings } from "../../../../src/components/pet/PrivacySettings";
|
||||
import { MemorialSettings } from "../../../../src/components/pet/MemorialSettings";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
|
||||
import { displayDateToIso, isoToDisplayDate } from "../../../../src/lib/date-format";
|
||||
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
||||
|
||||
/**
|
||||
* Edit form (name, nickname, breed, bio, adoption story, birthday, adoption
|
||||
* date) — a subset of web's src/app/(app)/pets/[petId]/edit/page.tsx.
|
||||
* DM-policy/follower-visibility/avatar upload aren't editable here yet.
|
||||
* Privacy (DM policy, follower visibility) lives in PrivacySettings and saves
|
||||
* on its own; avatar upload isn't editable here yet.
|
||||
* Species itself stays fixed (only its breed is re-selectable) — found.md
|
||||
* asked specifically for breed correction, not a full species change.
|
||||
*/
|
||||
@@ -29,6 +34,7 @@ export default function EditPetScreen() {
|
||||
const { activePetId, clearActivePetId } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
|
||||
const { data: pet, isLoading } = useQuery(trpc.pets.byId.queryOptions({ petId }));
|
||||
|
||||
@@ -122,7 +128,9 @@ export default function EditPetScreen() {
|
||||
<FlatList
|
||||
data={EMPTY_DATA}
|
||||
renderItem={null}
|
||||
contentContainerStyle={styles.container}
|
||||
contentContainerStyle={[styles.container, bottomPadding]}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<Text style={styles.label}>{t("editPet.nameLabel")}</Text>
|
||||
@@ -154,15 +162,24 @@ export default function EditPetScreen() {
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>{t("editPet.birthdayLabel")}</Text>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
|
||||
|
||||
<Text style={styles.label}>{t("editPet.adoptedAtLabel")}</Text>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button title={t("common.save")} disabled={updateMutation.isPending} onPress={handleSave} />
|
||||
|
||||
<PrivacySettings
|
||||
petId={pet.id}
|
||||
petName={pet.name}
|
||||
dmPolicy={pet.dmPolicy}
|
||||
followerVisibility={pet.followerVisibility}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTheme } from "../../../../src/context/ThemeContext";
|
||||
import { PetAvatar } from "../../../../src/components/PetAvatar";
|
||||
import { formatPetName } from "../../../../src/lib/format";
|
||||
import { type ColorTokens } from "../../../../src/theme/tokens";
|
||||
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
||||
|
||||
export default function FollowersScreen() {
|
||||
const { t } = useTranslation();
|
||||
@@ -17,6 +18,7 @@ export default function FollowersScreen() {
|
||||
const trpc = useTRPC();
|
||||
const { colors, typography } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
const { data, isLoading } = useQuery(trpc.follows.listFollowers.queryOptions({ petId }));
|
||||
|
||||
if (isLoading) {
|
||||
@@ -31,11 +33,11 @@ export default function FollowersScreen() {
|
||||
<FlatList
|
||||
data={data?.items ?? []}
|
||||
keyExtractor={(item) => item.follower.id}
|
||||
contentContainerStyle={styles.list}
|
||||
contentContainerStyle={[styles.list, bottomPadding]}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.row} onPress={() => router.push(`/pets/${item.follower.id}`)}>
|
||||
<PetAvatar name={item.follower.name} avatarKey={item.follower.avatarKey} />
|
||||
<PetAvatar name={item.follower.name} avatarKey={item.follower.avatarKey} owner={item.follower.owner} />
|
||||
<Text style={typography.body}>{formatPetName(item.follower)}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTheme } from "../../../../src/context/ThemeContext";
|
||||
import { PetAvatar } from "../../../../src/components/PetAvatar";
|
||||
import { formatPetName } from "../../../../src/lib/format";
|
||||
import { type ColorTokens } from "../../../../src/theme/tokens";
|
||||
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
||||
|
||||
export default function FollowingScreen() {
|
||||
const { t } = useTranslation();
|
||||
@@ -17,6 +18,7 @@ export default function FollowingScreen() {
|
||||
const trpc = useTRPC();
|
||||
const { colors, typography } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
const { data, isLoading } = useQuery(trpc.follows.listFollowing.queryOptions({ petId }));
|
||||
|
||||
if (isLoading) {
|
||||
@@ -31,11 +33,11 @@ export default function FollowingScreen() {
|
||||
<FlatList
|
||||
data={data?.items ?? []}
|
||||
keyExtractor={(item) => item.followee.id}
|
||||
contentContainerStyle={styles.list}
|
||||
contentContainerStyle={[styles.list, bottomPadding]}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.row} onPress={() => router.push(`/pets/${item.followee.id}`)}>
|
||||
<PetAvatar name={item.followee.name} avatarKey={item.followee.avatarKey} />
|
||||
<PetAvatar name={item.followee.name} avatarKey={item.followee.avatarKey} owner={item.followee.owner} />
|
||||
<Text style={typography.body}>{formatPetName(item.followee)}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { VetVisitSection } from "../../../../../src/components/health/VetVisitSe
|
||||
import { VaccineSection } from "../../../../../src/components/health/VaccineSection";
|
||||
import { useTheme } from "../../../../../src/context/ThemeContext";
|
||||
import { type ColorTokens } from "../../../../../src/theme/tokens";
|
||||
import { useBottomSafePadding } from "../../../../../src/hooks/useBottomSafePadding";
|
||||
|
||||
/**
|
||||
* Port of web's src/app/(app)/pets/[petId]/health/page.tsx, scoped to the
|
||||
@@ -18,9 +19,14 @@ export default function PetHealthScreen() {
|
||||
const { petId } = useLocalSearchParams<{ petId: string }>();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, bottomPadding]}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<HealthConsentGate petId={petId}>
|
||||
<WeightLogSection petId={petId} />
|
||||
<VetVisitSection petId={petId} />
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, FlatList, Pressable, ActivityIndicator, Alert, StyleSheet } from "react-native";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { HeartPulse, Trophy, MessageCircle } from "lucide-react-native";
|
||||
import { HeartPulse, Trophy, MessageCircle, MoreHorizontal, Ribbon } from "lucide-react-native";
|
||||
import { useTRPC } from "../../../../src/api/trpc";
|
||||
import { useActivePet } from "../../../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../../../src/context/ThemeContext";
|
||||
import { PetAvatar } from "../../../../src/components/PetAvatar";
|
||||
import { ReferralAvatarRing } from "../../../../src/components/referrals/ReferralAvatarRing";
|
||||
import { PetIdentityBadges } from "../../../../src/components/PetIdentityBadges";
|
||||
import { PostCard } from "../../../../src/components/PostCard";
|
||||
import { Button } from "../../../../src/components/Button";
|
||||
import { FollowButton } from "../../../../src/components/FollowButton";
|
||||
import { ReportSheet } from "../../../../src/components/ReportSheet";
|
||||
import { useBlockPet } from "../../../../src/hooks/useBlockPet";
|
||||
import { useVisiblePostIds } from "../../../../src/hooks/useVisiblePostIds";
|
||||
import { formatPetName } from "../../../../src/lib/format";
|
||||
import { isoToDisplayDate } from "../../../../src/lib/date-format";
|
||||
import { getAvatarUrl } from "../../../../src/lib/media-url";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
|
||||
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
||||
|
||||
/**
|
||||
* Port of web's src/app/(app)/pets/[petId]/page.tsx: identity, bio/adoption
|
||||
@@ -29,9 +34,10 @@ export default function PetProfileScreen() {
|
||||
const { petId } = useLocalSearchParams<{ petId: string }>();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
const { activePetId, pets: ownPets } = useActivePet();
|
||||
const { colors, typography } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
const { visibleIds, viewabilityConfigCallbackPairs } = useVisiblePostIds();
|
||||
|
||||
const { data: pet, isLoading, error } = useQuery(trpc.pets.getProfile.queryOptions({ petId }));
|
||||
@@ -40,6 +46,11 @@ export default function PetProfileScreen() {
|
||||
const { data: postsPage } = useQuery(trpc.posts.byPetId.queryOptions({ petId }));
|
||||
|
||||
const isOwnPet = activePetId === petId;
|
||||
// Any of the owner's pets, not just the active one — blocking/reporting
|
||||
// your own other pet makes no sense.
|
||||
const isOwnedByMe = ownPets.some((ownPet) => ownPet.id === petId);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const { confirmBlock } = useBlockPet(() => router.back());
|
||||
|
||||
const getOrCreateConversation = useMutation(
|
||||
trpc.conversations.getOrCreate.mutationOptions({
|
||||
@@ -64,99 +75,158 @@ export default function PetProfileScreen() {
|
||||
}
|
||||
|
||||
const posts = postsPage?.posts ?? [];
|
||||
const canModerate = !isOwnedByMe && !!activePetId;
|
||||
|
||||
// Report + block for other pets (App Store Guideline 1.2), matching web's
|
||||
// ProfileActions "..." menu.
|
||||
const openMoreMenu = () => {
|
||||
if (!activePetId) return;
|
||||
Alert.alert(formatPetName(pet), undefined, [
|
||||
{ text: t("petProfile.reportPet"), onPress: () => setReportOpen(true) },
|
||||
{
|
||||
text: t("block.menuItem", { petName: pet.name }),
|
||||
style: "destructive",
|
||||
onPress: () => confirmBlock(activePetId, { id: pet.id, name: pet.name }),
|
||||
},
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={posts}
|
||||
keyExtractor={(post) => post.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<PostCard post={item} actingPetId={activePetId ?? petId} isVisible={visibleIds.has(item.id)} />
|
||||
<>
|
||||
{canModerate && (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerRight: () => (
|
||||
<Pressable onPress={openMoreMenu} hitSlop={12} accessibilityLabel={t("petProfile.moreActions")}>
|
||||
<MoreHorizontal color={colors.ink} size={22} />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<ReportSheet
|
||||
visible={reportOpen}
|
||||
onClose={() => setReportOpen(false)}
|
||||
reporterPetId={activePetId}
|
||||
targetPetId={pet.id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View style={styles.postSeparatorWrap}>
|
||||
<View style={styles.postSeparatorLine} />
|
||||
</View>
|
||||
)}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
{pet.avatarKey ? (
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/image-viewer",
|
||||
params: { images: JSON.stringify([getAvatarUrl(pet.avatarKey)]), index: "0" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
</Pressable>
|
||||
) : (
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
)}
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
||||
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} />
|
||||
<FlatList
|
||||
data={posts}
|
||||
keyExtractor={(post) => post.id}
|
||||
contentContainerStyle={[styles.list, bottomPadding]}
|
||||
renderItem={({ item }) => (
|
||||
<PostCard post={item} actingPetId={activePetId ?? petId} isVisible={visibleIds.has(item.id)} />
|
||||
)}
|
||||
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View style={styles.postSeparatorWrap}>
|
||||
<View style={styles.postSeparatorLine} />
|
||||
</View>
|
||||
<Text style={styles.species}>{pet.species.name}</Text>
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/followers`)}>
|
||||
<Text style={styles.statCount}>{followers?.items.length ?? 0}</Text>
|
||||
<Text style={styles.statLabel}>{t("petProfile.followerStat")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/following`)}>
|
||||
<Text style={styles.statCount}>{following?.items.length ?? 0}</Text>
|
||||
<Text style={styles.statLabel}>{t("petProfile.followingStat")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{isOwnPet ? (
|
||||
<Button title={t("common.edit")} variant="outline" onPress={() => router.push(`/pets/${petId}/edit`)} />
|
||||
) : (
|
||||
activePetId && <FollowButton followerPetId={activePetId} followeePetId={petId} />
|
||||
)}
|
||||
|
||||
<View style={styles.iconRow}>
|
||||
{isOwnPet && (
|
||||
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/health`)}>
|
||||
<HeartPulse color={colors.ink} size={18} />
|
||||
<Text style={styles.iconButtonText}>{t("petProfile.healthButton")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/milestones`)}>
|
||||
<Trophy color={colors.ink} size={18} />
|
||||
<Text style={styles.iconButtonText}>{t("petProfile.milestonesButton")}</Text>
|
||||
</Pressable>
|
||||
{!isOwnPet && activePetId && (
|
||||
)}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
<ReferralAvatarRing owner={pet.owner}>
|
||||
{pet.avatarKey ? (
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={() => getOrCreateConversation.mutate({ senderPetId: activePetId, recipientPetId: petId })}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/image-viewer",
|
||||
params: { images: JSON.stringify([getAvatarUrl(pet.avatarKey)]), index: "0" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<MessageCircle color={colors.ink} size={18} />
|
||||
<Text style={styles.iconButtonText}>{t("petProfile.messageButton")}</Text>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
</Pressable>
|
||||
) : (
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{pet.bio ? <Text style={styles.section}>{pet.bio}</Text> : null}
|
||||
{pet.adoptionStory ? (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>{t("petProfile.adoptionStoryTitle")}</Text>
|
||||
<Text style={styles.section}>{pet.adoptionStory}</Text>
|
||||
</ReferralAvatarRing>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
||||
<PetIdentityBadges isDeceased={!!pet.deceasedAt} isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
|
||||
</View>
|
||||
) : null}
|
||||
{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>
|
||||
{pet.invitedByPet && (
|
||||
<Text style={styles.invitedBy}>
|
||||
{t("referrals.invitedBy")}{" "}
|
||||
<Text style={styles.invitedByName} onPress={() => router.push(`/pets/${pet.invitedByPet?.id}`)}>
|
||||
{pet.invitedByPet.name}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.center}>
|
||||
<Text style={typography.body}>{t("petProfile.noPosts")}</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
<View style={styles.statsRow}>
|
||||
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/followers`)}>
|
||||
<Text style={styles.statCount}>{followers?.items.length ?? 0}</Text>
|
||||
<Text style={styles.statLabel}>{t("petProfile.followerStat")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/following`)}>
|
||||
<Text style={styles.statCount}>{following?.items.length ?? 0}</Text>
|
||||
<Text style={styles.statLabel}>{t("petProfile.followingStat")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{isOwnPet ? (
|
||||
<Button title={t("common.edit")} variant="outline" onPress={() => router.push(`/pets/${petId}/edit`)} />
|
||||
) : (
|
||||
activePetId && <FollowButton followerPetId={activePetId} followeePetId={petId} />
|
||||
)}
|
||||
|
||||
<View style={styles.iconRow}>
|
||||
{isOwnPet && (
|
||||
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/health`)}>
|
||||
<HeartPulse color={colors.ink} size={18} />
|
||||
<Text style={styles.iconButtonText}>{t("petProfile.healthButton")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/milestones`)}>
|
||||
<Trophy color={colors.ink} size={18} />
|
||||
<Text style={styles.iconButtonText}>{t("petProfile.milestonesButton")}</Text>
|
||||
</Pressable>
|
||||
{!isOwnPet && activePetId && (
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={() => getOrCreateConversation.mutate({ senderPetId: activePetId, recipientPetId: petId })}
|
||||
>
|
||||
<MessageCircle color={colors.ink} size={18} />
|
||||
<Text style={styles.iconButtonText}>{t("petProfile.messageButton")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{pet.bio ? <Text style={styles.section}>{pet.bio}</Text> : null}
|
||||
{pet.adoptionStory ? (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>{t("petProfile.adoptionStoryTitle")}</Text>
|
||||
<Text style={styles.section}>{pet.adoptionStory}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.center}>
|
||||
<Text style={typography.body}>{t("petProfile.noPosts")}</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,6 +240,21 @@ function makeStyles(colors: ColorTokens) {
|
||||
nameRow: { flexDirection: "row", alignItems: "center", marginTop: 8 },
|
||||
name: { ...typography.title },
|
||||
species: { ...typography.body, color: colors.quietGray },
|
||||
invitedBy: { ...typography.body, fontSize: 12, color: colors.quietGray },
|
||||
foundingHint: { ...typography.body, fontSize: 12, color: "#059669" },
|
||||
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 },
|
||||
invitedByName: { color: colors.ink, fontWeight: "600" },
|
||||
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
|
||||
stat: { alignItems: "center" },
|
||||
statCount: { ...typography.title, fontSize: 18 },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getMediaUrl } from "../../../../src/lib/media-url";
|
||||
import { MILESTONE_META, type MilestoneType } from "../../../../src/lib/milestone-meta";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
|
||||
import type { MilestonePost } from "../../../../src/api/types";
|
||||
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
||||
|
||||
/**
|
||||
* Port of web's Milestones profile tab (milestones.listByPet). Structured
|
||||
@@ -25,6 +26,7 @@ export default function MilestonesScreen() {
|
||||
const trpc = useTRPC();
|
||||
const { colors, typography } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
const isOwnPet = activePetId === petId;
|
||||
|
||||
const { data: milestones = [], isLoading } = useQuery(trpc.milestones.listByPet.queryOptions({ petId }));
|
||||
@@ -41,7 +43,7 @@ export default function MilestonesScreen() {
|
||||
<FlatList
|
||||
data={milestones}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
contentContainerStyle={[styles.list, bottomPadding]}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
renderItem={({ item }) => <MilestoneRow post={item} />}
|
||||
ListEmptyComponent={
|
||||
|
||||
@@ -14,7 +14,10 @@ import { TextField } from "../../src/components/TextField";
|
||||
import { Button } from "../../src/components/Button";
|
||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
|
||||
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
// HEIC/HEIF (iPhone, manche Android-Kameras) ist hier ausdrücklich erlaubt:
|
||||
// resizeForUpload() wandelt jedes Bild vor dem Upload in JPEG um, der Server
|
||||
// sieht also nie HEIC. Vorher wurden solche Fotos hier still aussortiert.
|
||||
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"];
|
||||
const MAX_IMAGES = 10;
|
||||
|
||||
type PickedImage = {
|
||||
@@ -92,9 +95,13 @@ export default function NewPostScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const accepted = assets
|
||||
.filter((asset) => ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "image/jpeg"))
|
||||
.slice(0, available);
|
||||
const supported = assets.filter((asset) =>
|
||||
ALLOWED_MIME_TYPES.includes((asset.mimeType ?? "image/jpeg").toLowerCase())
|
||||
);
|
||||
if (supported.length < assets.length) {
|
||||
Alert.alert(t("post.unsupportedFormat"));
|
||||
}
|
||||
const accepted = supported.slice(0, available);
|
||||
|
||||
if (accepted.length === 0) return;
|
||||
|
||||
@@ -155,7 +162,11 @@ export default function NewPostScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.container}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{images.length > 0 && (
|
||||
<View style={styles.grid}>
|
||||
{images.map((img, i) => (
|
||||
|
||||
@@ -154,7 +154,7 @@ function CommentRow({ comment, isOwn, onDelete, onPressPet }: CommentRowProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable onPress={onPressPet}>
|
||||
<PetAvatar name={comment.pet.name} avatarKey={comment.pet.avatarKey} size="sm" />
|
||||
<PetAvatar name={comment.pet.name} avatarKey={comment.pet.avatarKey} size="sm" owner={comment.pet.owner} />
|
||||
</Pressable>
|
||||
<View style={styles.rowBody}>
|
||||
<View style={styles.rowHeader}>
|
||||
|
||||
@@ -14,6 +14,8 @@ import { PetAvatar } from "../../src/components/PetAvatar";
|
||||
import { FollowButton } from "../../src/components/FollowButton";
|
||||
import { getMediaUrl } from "../../src/lib/media-url";
|
||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
|
||||
import type { ReferralOwnerFields } from "../../src/hooks/useReferralRanks";
|
||||
|
||||
type Tab = "pets" | "people" | "tags";
|
||||
type Styles = ReturnType<typeof makeStyles>;
|
||||
@@ -24,6 +26,7 @@ type SearchPet = {
|
||||
avatarKey: string | null;
|
||||
species: { name: string };
|
||||
breed: { name: string } | null;
|
||||
owner?: ReferralOwnerFields | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -39,6 +42,7 @@ export default function SearchScreen() {
|
||||
const { activePetId } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("pets");
|
||||
@@ -88,7 +92,7 @@ export default function SearchScreen() {
|
||||
<FlatList
|
||||
data={petResults?.pets ?? []}
|
||||
keyExtractor={(pet) => pet.id}
|
||||
contentContainerStyle={styles.list}
|
||||
contentContainerStyle={[styles.list, bottomPadding]}
|
||||
ListEmptyComponent={<EmptyState styles={styles} text={t("search.noPetsFor", { query: trimmed })} />}
|
||||
renderItem={({ item }) => (
|
||||
<PetRow styles={styles} pet={item} activePetId={activePetId} onPress={() => router.push(`/pets/${item.id}`)} />
|
||||
@@ -105,7 +109,7 @@ export default function SearchScreen() {
|
||||
<FlatList
|
||||
data={ownerResults?.owners ?? []}
|
||||
keyExtractor={(owner) => owner.id}
|
||||
contentContainerStyle={styles.list}
|
||||
contentContainerStyle={[styles.list, bottomPadding]}
|
||||
ListEmptyComponent={<EmptyState styles={styles} text={t("search.noPeopleFor", { query: trimmed })} />}
|
||||
renderItem={({ item: owner }) => {
|
||||
const displayName = owner.username
|
||||
@@ -205,7 +209,7 @@ function PetRow({
|
||||
return (
|
||||
<View style={[styles.petRow, indented && styles.petRowIndented]}>
|
||||
<Pressable style={styles.petRowMain} onPress={onPress}>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="sm" />
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="sm" owner={pet.owner} />
|
||||
<View style={styles.petRowText}>
|
||||
<Text style={styles.petName}>{pet.name}</Text>
|
||||
<Text style={styles.petSpecies}>{pet.breed?.name ?? pet.species.name}</Text>
|
||||
|
||||
@@ -4,10 +4,13 @@ import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { AppearanceSection } from "../../src/components/settings/AppearanceSection";
|
||||
import { LanguageSection } from "../../src/components/settings/LanguageSection";
|
||||
import { PetSuggestionsSection } from "../../src/components/settings/PetSuggestionsSection";
|
||||
import { InviteFriendsSection } from "../../src/components/settings/InviteFriendsSection";
|
||||
import { LegalSection } from "../../src/components/settings/LegalSection";
|
||||
import { LogoutSection } from "../../src/components/settings/LogoutSection";
|
||||
import { DeleteAccountSection } from "../../src/components/settings/DeleteAccountSection";
|
||||
import { FooterErrorBoundary } from "../../src/components/settings/FooterErrorBoundary";
|
||||
import type { ColorTokens } from "../../src/theme/tokens";
|
||||
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
|
||||
|
||||
/**
|
||||
* Dedicated Settings screen, reached via the gear icon in every tab's header
|
||||
@@ -34,15 +37,18 @@ import type { ColorTokens } from "../../src/theme/tokens";
|
||||
export default function SettingsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
|
||||
return (
|
||||
<FooterErrorBoundary>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<ScrollView contentContainerStyle={[styles.content, bottomPadding]}>
|
||||
<InviteFriendsSection />
|
||||
<AppearanceSection />
|
||||
<LanguageSection />
|
||||
<PetSuggestionsSection />
|
||||
<LegalSection />
|
||||
<LogoutSection />
|
||||
<DeleteAccountSection />
|
||||
</ScrollView>
|
||||
</FooterErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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 { uploadImageToSupabase, uploadFileToUrl } from "../../src/lib/upload";
|
||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||
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 resized = await resizeForUpload(media.uri, media.width, media.height);
|
||||
const size = await imageSize(resized.uri).catch(() => ({ width: media.width, height: media.height }));
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
await createMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
storageKey: key,
|
||||
blurhash: null,
|
||||
...editorFields,
|
||||
mediaWidth: size.width,
|
||||
mediaHeight: size.height,
|
||||
});
|
||||
await finish();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : t("onboarding.uploadError"));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadPercent(null);
|
||||
}
|
||||
},
|
||||
[media, activePetId, createTextMutation, createVideoUploadMutation, getPresignedUrlMutation, createMutation, finish, t]
|
||||
);
|
||||
|
||||
const isSubmitting =
|
||||
isUploading ||
|
||||
getPresignedUrlMutation.isPending ||
|
||||
createMutation.isPending ||
|
||||
createTextMutation.isPending ||
|
||||
createVideoUploadMutation.isPending;
|
||||
|
||||
if (!media || !activePetId) {
|
||||
return (
|
||||
<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,47 +1,48 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View, Text, Pressable, StyleSheet, Alert } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ImagePlus, Camera } from "lucide-react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ImagePlus, Camera, Type } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||
import { Button } from "../../src/components/Button";
|
||||
import { MAX_STORY_VIDEO_DURATION_SECS } from "../../src/lib/story-video-limits";
|
||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
|
||||
/**
|
||||
* Photo-only story creation (matches web's own "Phase 2" note in
|
||||
* stories.ts: "Photo-only in Phase 2" — video stories are a Mux upload
|
||||
* flow web itself only added later, not carried over here). Single image,
|
||||
* unlike post-new.tsx's multi-image picker — a story is one photo.
|
||||
* Story erstellen — Schritt 1: Foto, kurzes Video oder Text-Story wählen.
|
||||
* Bearbeiten und Teilen passieren im eigenen Vollbild-Screen story-editor.tsx
|
||||
* (dieser Screen ist auf iOS ein Sheet; den Editor darin einzublenden führte
|
||||
* 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() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { activePetId } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
|
||||
|
||||
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
|
||||
const editorEnabled = flags?.storyEditor ?? false;
|
||||
|
||||
const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions());
|
||||
|
||||
const createMutation = useMutation(
|
||||
trpc.stories.create.mutationOptions({
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
await queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||
router.back();
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
})
|
||||
const openEditor = useCallback(
|
||||
(params: { kind: "image" | "video" | "text"; uri?: string; width?: number; height?: number; mimeType?: string | null }) => {
|
||||
router.push({
|
||||
pathname: "/story-editor",
|
||||
params: {
|
||||
kind: params.kind,
|
||||
uri: params.uri ?? "",
|
||||
width: String(params.width ?? 0),
|
||||
height: String(params.height ?? 0),
|
||||
mimeType: params.mimeType ?? "",
|
||||
},
|
||||
});
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const pickFromGallery = useCallback(async () => {
|
||||
@@ -50,9 +51,23 @@ export default function NewStoryScreen() {
|
||||
Alert.alert(t("onboarding.galleryPermissionDenied"));
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 });
|
||||
if (!result.canceled) setAsset(result.assets[0]);
|
||||
}, []);
|
||||
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], quality: 0.8 });
|
||||
if (result.canceled) return;
|
||||
|
||||
// expo-image-picker meldet `duration` (ms) nur bei Videos — verlässlicher als ein MIME-Typ.
|
||||
const picked = result.assets[0];
|
||||
if (picked.duration != null && picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) {
|
||||
Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS }));
|
||||
return;
|
||||
}
|
||||
openEditor({
|
||||
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 permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
@@ -61,65 +76,44 @@ export default function NewStoryScreen() {
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchCameraAsync({ quality: 0.8 });
|
||||
if (!result.canceled) setAsset(result.assets[0]);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!asset || !activePetId) return;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
createMutation.mutate({ petId: activePetId, storageKey: key, blurhash: null });
|
||||
} catch {
|
||||
setError(t("onboarding.uploadError"));
|
||||
if (!result.canceled) {
|
||||
const photo = result.assets[0];
|
||||
openEditor({ kind: "image", uri: photo.uri, width: photo.width, height: photo.height });
|
||||
}
|
||||
}, [asset, activePetId, getPresignedUrlMutation, createMutation]);
|
||||
|
||||
const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending;
|
||||
}, [t, openEditor]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{asset ? (
|
||||
<Pressable style={styles.preview} onPress={() => setAsset(null)}>
|
||||
<RNImage source={{ uri: asset.uri }} style={styles.previewImage} />
|
||||
<Text style={styles.previewHint}>{t("story.changePhotoHint")}</Text>
|
||||
<View style={styles.pickerRow}>
|
||||
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
|
||||
<ImagePlus color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.pickerButton} onPress={takePhoto}>
|
||||
<Camera color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{editorEnabled && (
|
||||
<Pressable style={styles.textStoryButton} onPress={() => openEditor({ kind: "text" })}>
|
||||
<Type color={colors.pureWhite} size={22} />
|
||||
<Text style={styles.textStoryText}>{t("storyEditor.textStory")}</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.pickerRow}>
|
||||
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
|
||||
<ImagePlus color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.pickerButton} onPress={takePhoto}>
|
||||
<Camera color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button
|
||||
title={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
|
||||
onPress={handleSubmit}
|
||||
disabled={!asset || isSubmitting}
|
||||
/>
|
||||
{isSubmitting && <ActivityIndicator color={colors.marigoldOrange} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
function makeStyles(colors: ColorTokens, bottomInset: number) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, padding: 16, gap: 12, backgroundColor: colors.porchWhite },
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
paddingBottom: Math.max(16, bottomInset + 12),
|
||||
gap: 12,
|
||||
backgroundColor: colors.porchWhite,
|
||||
},
|
||||
pickerRow: { flexDirection: "row", gap: 12 },
|
||||
pickerButton: {
|
||||
flex: 1,
|
||||
@@ -133,9 +127,15 @@ function makeStyles(colors: ColorTokens) {
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
pickerText: { ...typography.body, color: colors.ink },
|
||||
preview: { flex: 1, borderRadius: radii.md, overflow: "hidden", backgroundColor: colors.hairlineGray, gap: 8 },
|
||||
previewImage: { width: "100%", flex: 1 },
|
||||
previewHint: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", paddingVertical: 6 },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
textStoryButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
height: 48,
|
||||
borderRadius: radii.lg,
|
||||
backgroundColor: colors.marigoldOrange,
|
||||
},
|
||||
textStoryText: { ...typography.body, color: colors.pureWhite, fontFamily: typography.title.fontFamily },
|
||||
});
|
||||
}
|
||||
@@ -1,52 +1,151 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||
import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator, type LayoutChangeEvent } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useVideoPlayer, VideoView } from "expo-video";
|
||||
import { useEventListener } from "expo";
|
||||
import { AlertCircle, Clock, Eye, MessageCircle, PawPrint, Trash2, X } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { PetAvatar } from "../../src/components/PetAvatar";
|
||||
import { StoryPawButton } from "../../src/components/stories/StoryPawButton";
|
||||
import { StoryCommentsSheet } from "../../src/components/stories/StoryCommentsSheet";
|
||||
import { StoryViewersSheet } from "../../src/components/stories/StoryViewersSheet";
|
||||
import { getMediaUrl } from "../../src/lib/media-url";
|
||||
import { StoryOverlayLayer } from "../../src/components/story-editor/StoryOverlayLayer";
|
||||
import { StoryBackground } from "../../src/components/story-editor/StoryBackground";
|
||||
import { containRect, storyMediaSize } from "../../src/lib/story-overlays";
|
||||
import { colors } from "../../src/theme/tokens";
|
||||
|
||||
const STORY_DURATION_MS = 5000;
|
||||
const IMAGE_STORY_DURATION_MS = 5000;
|
||||
|
||||
// "away" = über eine Markierung ins Tierprofil gewechselt — Story pausiert bis zur Rückkehr.
|
||||
type Sheet = "comments" | "viewers" | "confirm-delete" | "away" | null;
|
||||
|
||||
/**
|
||||
* Port of web's src/components/stories/StoryViewer.tsx, scoped to photo
|
||||
* stories only (no Mux video story playback — see story-new.tsx). Also
|
||||
* drops the owner-only viewer-count/list Sheet and the paw-reaction/comment
|
||||
* overlay (Gitea #29 additions on web) — fast-follows, not core to "view a
|
||||
* story". Tap the left third to go back a story, the rest to skip ahead;
|
||||
* a fixed timer still auto-advances either way.
|
||||
* Port of web's src/components/stories/StoryViewer.tsx. Tap the left third
|
||||
* to go back a story, the rest to skip ahead. Foto-Storys laufen über einen
|
||||
* festen 5-s-Timer; Video-Storys folgen der echten Wiedergabe (expo-video
|
||||
* `timeUpdate`) und springen bei `playToEnd` weiter, wie im Web. Pfote, Kommentare (Bottom-Sheet) und — nur für die eigene
|
||||
* Story — die Zuschauerliste und Löschen wie im Web; solange ein Sheet oder
|
||||
* die Lösch-Rückfrage offen ist, hält der Timer an.
|
||||
*/
|
||||
export default function StoryViewerScreen() {
|
||||
const { petId, petName, petAvatarKey } = useLocalSearchParams<{
|
||||
const { petId, petName: petNameParam, petAvatarKey: petAvatarKeyParam, storyId: initialStoryId } = useLocalSearchParams<{
|
||||
petId: string;
|
||||
petName: string;
|
||||
petAvatarKey?: string;
|
||||
/** Aus einer Mitteilung: direkt diese Story zeigen statt der ersten. */
|
||||
storyId?: string;
|
||||
}>();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { activePetId } = useActivePet();
|
||||
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 jumpedToInitialRef = useRef(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [openSheet, setOpenSheet] = useState<Sheet>(null);
|
||||
const advancedRef = useRef(false);
|
||||
const elapsedRef = useRef(0); // bereits abgelaufene Zeit der aktuellen Story (für Pause/Weiter)
|
||||
|
||||
const currentStory = stories[index];
|
||||
const isPaused = openSheet !== null;
|
||||
const isVideo = currentStory?.mediaType === "VIDEO";
|
||||
const isText = currentStory?.mediaType === "TEXT";
|
||||
const [screen, setScreen] = useState({ width: 0, height: 0 });
|
||||
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
|
||||
// Notschalter: Overlays nur zeigen, wenn der Story-Editor im Admin an ist.
|
||||
const showOverlays = flags?.storyEditor ?? false;
|
||||
|
||||
// Zurück aus dem Tierprofil (über eine Markierung) → Story läuft weiter.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setOpenSheet((sheet) => (sheet === "away" ? null : sheet));
|
||||
}, [])
|
||||
);
|
||||
const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions());
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
trpc.stories.delete.mutationOptions({
|
||||
onSuccess: () => {
|
||||
const remaining = stories.length - 1;
|
||||
setOpenSheet(null);
|
||||
void queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||
void queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
if (remaining <= 0) router.back();
|
||||
else setIndex((i) => Math.min(i, remaining - 1));
|
||||
},
|
||||
onError: () => {
|
||||
setOpenSheet(null);
|
||||
Alert.alert(t("storyViewer.deleteError"));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Story-ID beim Antippen festhalten: gelöscht wird genau die angezeigte Story.
|
||||
const confirmDelete = (storyId: string) => {
|
||||
setOpenSheet("confirm-delete");
|
||||
Alert.alert(
|
||||
t("storyViewer.deleteTitle"),
|
||||
t("storyViewer.deleteDescription"),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel", onPress: () => setOpenSheet(null) },
|
||||
{ text: t("storyViewer.deleteConfirm"), style: "destructive", onPress: () => deleteMutation.mutate({ storyId }) },
|
||||
],
|
||||
{ cancelable: true, onDismiss: () => setOpenSheet(null) }
|
||||
);
|
||||
};
|
||||
|
||||
// Solange ein Video noch verarbeitet wird, Status abfragen (Self-Heal wie VideoPlayer.tsx).
|
||||
const { data: videoStatusData } = useQuery({
|
||||
...trpc.stories.getVideoStatus.queryOptions({ storyId: currentStory?.id ?? "" }),
|
||||
enabled: !!currentStory && isVideo && currentStory.videoStatus === "PROCESSING",
|
||||
refetchInterval: (query) => (query.state.data?.videoStatus === "PROCESSING" ? 3000 : false),
|
||||
});
|
||||
const videoStatus = videoStatusData?.videoStatus ?? currentStory?.videoStatus;
|
||||
const videoPlaybackId = videoStatusData?.muxPlaybackId ?? currentStory?.muxPlaybackId;
|
||||
|
||||
const { data: viewers = [] } = useQuery({
|
||||
...trpc.stories.getViewers.queryOptions({ storyId: currentStory?.id ?? "" }),
|
||||
enabled: isOwnStory && !!currentStory,
|
||||
});
|
||||
|
||||
const close = useCallback(() => {
|
||||
queryClient.invalidateQueries(trpc.stories.hasActiveStory.queryOptions({ petId }));
|
||||
queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
router.back();
|
||||
}, [queryClient, trpc, petId, router]);
|
||||
|
||||
const openPet = useCallback(
|
||||
(targetPetId: string) => {
|
||||
setOpenSheet(null);
|
||||
router.push(`/pets/${targetPetId}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const openMentionedPet = useCallback(
|
||||
(targetPetId: string) => {
|
||||
setOpenSheet("away");
|
||||
router.push(`/pets/${targetPetId}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
if (advancedRef.current) return;
|
||||
advancedRef.current = true;
|
||||
@@ -70,16 +169,25 @@ export default function StoryViewerScreen() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentStory?.id, activePetId]);
|
||||
|
||||
// Fixed-duration progress bar, auto-advances/closes when it fills.
|
||||
// Neue Story → Fortschritt zurücksetzen.
|
||||
useEffect(() => {
|
||||
if (!currentStory) return;
|
||||
setProgress(0);
|
||||
elapsedRef.current = 0;
|
||||
advancedRef.current = false;
|
||||
const startTime = Date.now();
|
||||
setProgress(0);
|
||||
}, [currentStory?.id]);
|
||||
|
||||
// Fester Timer nur für Foto-Storys; pausiert, solange ein Sheet offen ist.
|
||||
// Video-Storys steuern Fortschritt und Weiterspringen selbst (ReadyVideoStory).
|
||||
useEffect(() => {
|
||||
if (!currentStory || isVideo || isPaused) return;
|
||||
const resumedAt = Date.now();
|
||||
const elapsedBefore = elapsedRef.current;
|
||||
let rafId: number;
|
||||
|
||||
function tick() {
|
||||
const pct = Math.min(((Date.now() - startTime) / STORY_DURATION_MS) * 100, 100);
|
||||
const elapsed = elapsedBefore + (Date.now() - resumedAt);
|
||||
elapsedRef.current = elapsed;
|
||||
const pct = Math.min((elapsed / IMAGE_STORY_DURATION_MS) * 100, 100);
|
||||
setProgress(pct);
|
||||
if (pct < 100) rafId = requestAnimationFrame(tick);
|
||||
else advance();
|
||||
@@ -87,14 +195,36 @@ export default function StoryViewerScreen() {
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
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;
|
||||
|
||||
const mediaUrl = currentStory.storageKey ? getMediaUrl(currentStory.storageKey) : null;
|
||||
const refreshStories = () => void queryClient.invalidateQueries({ queryKey: listQuery.queryKey });
|
||||
// Medienfläche wie bei contentFit="contain" — darauf beziehen sich die Overlays.
|
||||
const mediaSize = storyMediaSize(currentStory);
|
||||
const rect = mediaSize ? containRect(screen.width, screen.height, mediaSize.width, mediaSize.height) : null;
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setScreen((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.backdrop}>
|
||||
<View style={styles.backdrop} onLayout={onLayout}>
|
||||
<View style={[styles.progressRow, { top: insets.top + 12 }]}>
|
||||
{stories.map((s, i) => (
|
||||
<View key={s.id} style={styles.progressTrack}>
|
||||
@@ -108,28 +238,210 @@ export default function StoryViewerScreen() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
{mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />}
|
||||
{isVideo ? (
|
||||
<VideoStory
|
||||
key={currentStory.id}
|
||||
status={videoStatus}
|
||||
playbackId={videoPlaybackId}
|
||||
paused={isPaused}
|
||||
onProgress={setProgress}
|
||||
onEnded={advance}
|
||||
/>
|
||||
) : isText ? (
|
||||
rect && (
|
||||
<View style={{ position: "absolute", left: rect.x, top: rect.y }}>
|
||||
<StoryBackground backgroundKey={currentStory.background ?? "sunset"} width={rect.width} height={rect.height} />
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />
|
||||
)}
|
||||
|
||||
<View style={styles.tapZones}>
|
||||
<Pressable style={styles.tapZoneLeft} onPress={goBack} />
|
||||
<Pressable style={styles.tapZoneRight} onPress={advance} />
|
||||
</View>
|
||||
|
||||
{showOverlays && rect && (
|
||||
<StoryOverlayLayer
|
||||
overlays={currentStory.overlays}
|
||||
rect={rect}
|
||||
mentionedPets={currentStory.mentionedPets}
|
||||
onPressMention={openMentionedPet}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={[styles.petInfo, { bottom: insets.bottom + 24 }]}>
|
||||
<PetAvatar name={petName} avatarKey={petAvatarKey || null} size="sm" />
|
||||
<Text style={styles.petName}>{petName}</Text>
|
||||
</View>
|
||||
|
||||
{currentStory.isExpired && (
|
||||
<View style={[styles.expiredRow, { top: insets.top + 56 }]} pointerEvents="none">
|
||||
<View style={styles.expiredBadge}>
|
||||
<Clock size={14} color={colors.pureWhite} />
|
||||
<Text style={styles.expiredText}>{t("storyViewer.expiredOwnerOnly")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isOwnStory && (
|
||||
<View style={[styles.ownerActions, { top: insets.top + 8 }]}>
|
||||
<Pressable
|
||||
style={styles.viewerCount}
|
||||
onPress={() => setOpenSheet("viewers")}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyViewer.viewersTitle", { count: viewers.length })}
|
||||
>
|
||||
<Eye size={16} color={colors.pureWhite} />
|
||||
<Text style={styles.viewerCountText}>{viewers.length}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.deleteButton}
|
||||
onPress={() => confirmDelete(currentStory.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyViewer.deleteStory")}
|
||||
hitSlop={8}
|
||||
>
|
||||
{deleteMutation.isPending ? <ActivityIndicator color={colors.pureWhite} size="small" /> : <Trash2 size={16} color={colors.pureWhite} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activePetId && (
|
||||
<View style={[styles.actions, { bottom: insets.bottom + 24 }]}>
|
||||
{currentStory.isExpired ? (
|
||||
<View style={styles.actionWrap} accessibilityLabel={t("storyViewer.pawCount", { count: currentStory.reactionCount })}>
|
||||
<View style={styles.actionCircle}>
|
||||
<PawPrint size={20} color={colors.pureWhite} />
|
||||
</View>
|
||||
{currentStory.reactionCount > 0 && <Text style={styles.actionCount}>{currentStory.reactionCount}</Text>}
|
||||
</View>
|
||||
) : (
|
||||
<StoryPawButton
|
||||
key={currentStory.id}
|
||||
storyId={currentStory.id}
|
||||
petId={activePetId}
|
||||
initialCount={currentStory.reactionCount}
|
||||
initialReacted={currentStory.reactedByViewer}
|
||||
/>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => setOpenSheet("comments")}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyViewer.openComments")}
|
||||
hitSlop={8}
|
||||
style={styles.actionWrap}
|
||||
>
|
||||
<View style={styles.actionCircle}>
|
||||
<MessageCircle size={20} color={colors.pureWhite} />
|
||||
</View>
|
||||
{currentStory.commentCount > 0 && <Text style={styles.actionCount}>{currentStory.commentCount}</Text>}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Pressable style={[styles.closeButton, { top: insets.top + 8 }]} onPress={close} hitSlop={12}>
|
||||
<X color={colors.pureWhite} size={22} />
|
||||
</Pressable>
|
||||
|
||||
{activePetId && (
|
||||
<StoryCommentsSheet
|
||||
storyId={currentStory.id}
|
||||
activePetId={activePetId}
|
||||
visible={openSheet === "comments"}
|
||||
onClose={() => setOpenSheet(null)}
|
||||
onChanged={refreshStories}
|
||||
onPressPet={openPet}
|
||||
readOnly={currentStory.isExpired}
|
||||
/>
|
||||
)}
|
||||
{isOwnStory && (
|
||||
<StoryViewersSheet
|
||||
storyId={currentStory.id}
|
||||
visible={openSheet === "viewers"}
|
||||
onClose={() => setOpenSheet(null)}
|
||||
onPressPet={openPet}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type VideoStatusValue = "PROCESSING" | "READY" | "ERROR";
|
||||
|
||||
interface VideoStoryProps {
|
||||
status: VideoStatusValue | null | undefined;
|
||||
playbackId: string | null | undefined;
|
||||
paused: boolean;
|
||||
onProgress: (pct: number) => void;
|
||||
onEnded: () => void;
|
||||
}
|
||||
|
||||
/** Zeigt den Verarbeitungs-/Fehlerzustand; der Player wird erst bei READY gemountet (wie VideoPlayer.tsx). */
|
||||
function VideoStory({ status, playbackId, ...playerProps }: VideoStoryProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (status === "READY" && playbackId) {
|
||||
return <ReadyVideoStory playbackId={playbackId} {...playerProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.statusBox}>
|
||||
{status === "ERROR" ? (
|
||||
<>
|
||||
<AlertCircle color={colors.alertRed} size={28} />
|
||||
<Text style={styles.statusLabel}>{t("video.error")}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
<Text style={styles.statusLabel}>{t("video.processing")}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt einmal ab (kein Loop) und springt bei `playToEnd` weiter.
|
||||
* `play()` erst bei `readyToPlay` — vorher ist `duration` 0 und `playToEnd`
|
||||
* feuerte sofort (Viewer schloss sich nach < 1 s, auf Emulator und Gerät
|
||||
* beobachtet). Offenes Sheet pausiert das Video wie den Foto-Timer.
|
||||
*/
|
||||
function ReadyVideoStory({ playbackId, paused, onProgress, onEnded }: Omit<VideoStoryProps, "status" | "playbackId"> & { playbackId: string }) {
|
||||
const player = useVideoPlayer(`https://stream.mux.com/${playbackId}.m3u8`, (p) => {
|
||||
p.loop = false;
|
||||
p.timeUpdateEventInterval = 0.25;
|
||||
});
|
||||
const pausedRef = useRef(paused);
|
||||
|
||||
useEffect(() => {
|
||||
pausedRef.current = paused;
|
||||
if (paused) player.pause();
|
||||
else if (player.status === "readyToPlay") player.play();
|
||||
}, [player, paused]);
|
||||
|
||||
useEventListener(player, "statusChange", ({ status }) => {
|
||||
if (status === "readyToPlay" && !pausedRef.current) player.play();
|
||||
});
|
||||
useEventListener(player, "timeUpdate", ({ currentTime }) => {
|
||||
if (player.duration > 0) onProgress((currentTime / player.duration) * 100);
|
||||
});
|
||||
useEventListener(player, "playToEnd", () => {
|
||||
onProgress(100);
|
||||
onEnded();
|
||||
});
|
||||
|
||||
return <VideoView player={player} style={styles.media} contentFit="contain" nativeControls={false} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: colors.ink },
|
||||
media: { flex: 1, width: "100%" },
|
||||
statusBox: { flex: 1, width: "100%", alignItems: "center", justifyContent: "center", gap: 8 },
|
||||
statusLabel: { color: colors.pureWhite, fontSize: 14 },
|
||||
tapZones: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, flexDirection: "row" },
|
||||
tapZoneLeft: { flex: 1 },
|
||||
tapZoneRight: { flex: 2 },
|
||||
@@ -145,6 +457,33 @@ const styles = StyleSheet.create({
|
||||
zIndex: 2,
|
||||
},
|
||||
petName: { color: colors.pureWhite, fontWeight: "600", fontSize: 14 },
|
||||
actions: { position: "absolute", right: 16, alignItems: "center", gap: 16, zIndex: 2 },
|
||||
actionWrap: { alignItems: "center", gap: 2 },
|
||||
actionCircle: { width: 44, height: 44, borderRadius: 22, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
|
||||
actionCount: { color: colors.pureWhite, fontSize: 12, fontWeight: "600", textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 3 },
|
||||
expiredRow: { position: "absolute", left: 0, right: 0, alignItems: "center", zIndex: 2 },
|
||||
expiredBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
},
|
||||
expiredText: { color: colors.pureWhite, fontSize: 12, fontWeight: "600" },
|
||||
ownerActions: { position: "absolute", left: 16, flexDirection: "row", alignItems: "center", gap: 8, zIndex: 2 },
|
||||
deleteButton: { width: 36, height: 36, borderRadius: 18, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
|
||||
viewerCount: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
},
|
||||
viewerCountText: { color: colors.pureWhite, fontSize: 13, fontWeight: "600" },
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TextField } from "../../src/components/TextField";
|
||||
import { PasswordField } from "../../src/components/PasswordField";
|
||||
import { LanguageSwitcher } from "../../src/components/LanguageSwitcher";
|
||||
import { GoogleSignInButton } from "../../src/components/GoogleSignInButton";
|
||||
import { AppleSignInButton } from "../../src/components/AppleSignInButton";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
|
||||
|
||||
@@ -107,6 +108,10 @@ export default function SignInScreen() {
|
||||
onError={setError}
|
||||
onNeedsConsent={() => setError(t("auth.googleNeedsSignUpError"))}
|
||||
/>
|
||||
<AppleSignInButton
|
||||
onError={setError}
|
||||
onNeedsConsent={() => setError(t("auth.appleNeedsSignUpError"))}
|
||||
/>
|
||||
<Text style={styles.divider}>{t("auth.orDivider")}</Text>
|
||||
<TextField
|
||||
autoCapitalize="none"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TextField } from "../../src/components/TextField";
|
||||
import { PasswordField } from "../../src/components/PasswordField";
|
||||
import { LanguageSwitcher } from "../../src/components/LanguageSwitcher";
|
||||
import { GoogleSignInButton } from "../../src/components/GoogleSignInButton";
|
||||
import { AppleSignInButton } from "../../src/components/AppleSignInButton";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { env } from "../../src/lib/env";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
|
||||
@@ -16,8 +17,9 @@ import { getTypography, type ColorTokens, type Typography } from "../../src/them
|
||||
/**
|
||||
* Email/password sign-up — the mobile app's other half of sign-in.tsx (that
|
||||
* screen only ever covered login, per its own comment "sign-up screens are
|
||||
* follow-up work"). No invite-code step: INVITE_REQUIRED is globally off on
|
||||
* the backend since launch (post-beta), unlike web's /join gate.
|
||||
* follow-up work"). No invite-code step here: registration is open, and a
|
||||
* referral code is redeemed later in onboarding (AcquisitionStep), never
|
||||
* as part of the Clerk sign-up itself.
|
||||
*
|
||||
* Uses Clerk's "Futures" API (signUp.password(), signUp.verifications,
|
||||
* signUp.finalize()), matching sign-in.tsx's pattern — see AGENTS.md before
|
||||
@@ -127,6 +129,7 @@ export default function SignUpScreen() {
|
||||
<LanguageSwitcher />
|
||||
<Text style={styles.title}>{t("auth.signUpTitle")}</Text>
|
||||
<GoogleSignInButton onError={setError} legalAccepted={legalAccepted} disabled={!legalAccepted} />
|
||||
<AppleSignInButton onError={setError} legalAccepted={legalAccepted} disabled={!legalAccepted} />
|
||||
<Text style={styles.divider}>{t("auth.orDivider")}</Text>
|
||||
<TextField
|
||||
placeholder={t("auth.firstNamePlaceholder")}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { FlatList } from "react-native";
|
||||
import { ActivityIndicator, FlatList, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { OnboardingGuidelines } from "../../src/components/onboarding/OnboardingGuidelines";
|
||||
@@ -7,12 +7,16 @@ import { UploadConsent } from "../../src/components/onboarding/UploadConsent";
|
||||
import { ArachnophobiaOptIn } from "../../src/components/onboarding/ArachnophobiaOptIn";
|
||||
import { PetCreateForm } from "../../src/components/onboarding/PetCreateForm";
|
||||
import { AvatarStep } from "../../src/components/onboarding/AvatarStep";
|
||||
import { AcquisitionStep } from "../../src/components/onboarding/AcquisitionStep";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
|
||||
/**
|
||||
* Port of web's src/app/onboarding/pet/page.tsx — same five-step flow and
|
||||
* step order: community guidelines -> upload-rights/data-processing consent
|
||||
* -> Arachnophobie-Helfer opt-in -> pet profile -> avatar photo. Lives as
|
||||
* Port of web's src/app/onboarding/pet/page.tsx — same step order:
|
||||
* community guidelines -> upload-rights/data-processing consent ->
|
||||
* Arachnophobie-Helfer opt-in -> pet profile -> "Wie hast du von PawFeed
|
||||
* erfahren?" + referral code (once per owner) -> avatar photo. Lives as
|
||||
* its own top-level route (not under (auth) or (app)) so app/_layout.tsx's
|
||||
* "zero pets -> redirect here" check (see OnboardingRedirect) doesn't loop.
|
||||
*/
|
||||
@@ -24,6 +28,15 @@ export default function OnboardingPetScreen() {
|
||||
const [uploadConsentGiven, setUploadConsentGiven] = useState(false);
|
||||
const [arachnophobiaStepDone, setArachnophobiaStepDone] = useState(false);
|
||||
const [createdPet, setCreatedPet] = useState<{ id: string; name: string } | null>(null);
|
||||
const [acquisitionStepDone, setAcquisitionStepDone] = useState(false);
|
||||
const trpc = useTRPC();
|
||||
// Erst nach dem Anlegen des Tiers abfragen — vorher existiert die Owner-Zeile ggf. noch nicht.
|
||||
const { data: referralState } = useQuery({
|
||||
...trpc.referrals.onboardingState.queryOptions(),
|
||||
enabled: !!createdPet,
|
||||
});
|
||||
const isReferralStateLoading = !!createdPet && !acquisitionStepDone && referralState === undefined;
|
||||
const showAcquisitionStep = !!createdPet && !acquisitionStepDone && !!referralState?.showStep;
|
||||
|
||||
const finish = () => router.replace("/(app)");
|
||||
|
||||
@@ -34,6 +47,14 @@ export default function OnboardingPetScreen() {
|
||||
content = <UploadConsent onContinue={() => setUploadConsentGiven(true)} />;
|
||||
} else if (!arachnophobiaStepDone) {
|
||||
content = <ArachnophobiaOptIn onContinue={() => setArachnophobiaStepDone(true)} />;
|
||||
} else if (isReferralStateLoading) {
|
||||
content = (
|
||||
<View style={{ padding: 48, alignItems: "center" }}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
);
|
||||
} else if (showAcquisitionStep) {
|
||||
content = <AcquisitionStep canRedeem={referralState?.canRedeem ?? false} onDone={() => setAcquisitionStepDone(true)} />;
|
||||
} else if (createdPet) {
|
||||
content = <AvatarStep petId={createdPet.id} petName={createdPet.name} onDone={finish} />;
|
||||
} else {
|
||||
@@ -58,6 +79,8 @@ export default function OnboardingPetScreen() {
|
||||
data={EMPTY_DATA}
|
||||
renderItem={null}
|
||||
contentContainerStyle={{ flexGrow: 1, backgroundColor: colors.porchWhite }}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListHeaderComponent={<>{content}</>}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { View, ActivityIndicator } from "react-native";
|
||||
import { useTheme } from "../src/context/ThemeContext";
|
||||
|
||||
/**
|
||||
* On Android, the custom tab used by Clerk's useSSO() fires the OAuth
|
||||
* redirect intent (pawfeed://sso-callback) at the app itself, not just at
|
||||
* expo-web-browser's pending openAuthSessionAsync() promise — so
|
||||
* expo-router's own Linking listener also sees the URL. Without a matching
|
||||
* route here, that shows Expo Router's "Unmatched Route" error screen to
|
||||
* the user for however long the real handshake (GoogleSignInButton's
|
||||
* startSSOFlow -> setActive -> router.replace("/(app)")) takes to resolve
|
||||
* and navigate away.
|
||||
*
|
||||
* This route exists purely to give expo-router something valid to render
|
||||
* during that window. It deliberately does no auth-state logic itself —
|
||||
* app/_layout.tsx's useProtectedRoute() already redirects signed-out users
|
||||
* away from any non-(auth) screen, and GoogleSignInButton takes over
|
||||
* navigation the moment the real SSO flow completes.
|
||||
*/
|
||||
export default function SsoCallback() {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite }}>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
+11
-1
@@ -20,6 +20,12 @@
|
||||
},
|
||||
"channel": "preview"
|
||||
},
|
||||
"preview-simulator": {
|
||||
"extends": "preview",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"android": {
|
||||
"buildType": "app-bundle"
|
||||
@@ -35,6 +41,10 @@
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleTeamId": "C72YB42R2D"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
-10
@@ -22,8 +22,9 @@ TODO Neu2:
|
||||
→ Zurück-Pfeil war nur unsichtbar (schwarz auf schwarzem Header im Dark Mode), jetzt auf allen Unterseiten sichtbar. Speicherproblem lag am Datumsformat (nächster Punkt) plus fehlender Fehlermeldung bei ungültiger Eingabe — beides behoben, End-to-End getestet.
|
||||
- [x] Datum wird Korrekt dargestellt, die eingabe ist aber, aktuelle ansicht JJJJ-MM-DD // korrekt wäre DD-MM-JJJJ
|
||||
→ Eingabefelder akzeptieren jetzt TT-MM-JJJJ, werden intern korrekt umgewandelt; ungültige Eingaben zeigen eine Fehlermeldung.
|
||||
- [ ] Gewichtsdaten werden nicht als Diagramm angezeigt (feature)
|
||||
- [x] Gewichtsdaten werden nicht als Diagramm angezeigt (feature)
|
||||
→ Echtes Feature, kein Bug — noch offen, Rückmeldung ob/wann gewünscht.
|
||||
→ **Umgesetzt (04.10.):** Neues `WeightChart` (`src/components/health/WeightChart.tsx`, Port des Web-Diagramms mit `react-native-svg`) erscheint ab 2 Einträgen über der Gewichtsliste. Die x-Achse ist zeitproportional (unregelmäßige Wiegetermine werden nicht verzerrt), die y-Achse hat runde Werte, Datumslabels überlappen nicht (bei über einem Jahr „Feb. 25“). Antippen wählt die nächste Messung, Wert + Datum stehen im Kopf; rechts die Veränderung seit der ersten Messung. Linienfarbe als neues Token `chartLine` (#ea580c), mit dem Dataviz-Validator gegen helle und dunkle Flächen geprüft (Kontrast ≥ 3:1). Geometrie in `src/lib/weight-chart.ts` mit 11 Tests; die Darstellung habe ich als Vorschau mit der echten Geometrie in Hell + Dunkel gerendert und geprüft. Commit `e60abba`, OTA Runtime 1.0.6: Android `16d95168-caf2-4c0d-a0c7-730acd1bfb96`, iOS `d8e82f19-2a66-4f21-9558-b16296a7ba3f`. Nicht auf einem Gerät getestet.
|
||||
- [x] Technische Frage, was wird beim Feed abgerufen? Zum vergleich in der Web umgebung wird mein feed(followerbezogen) und der trend (könnte dir auch gefallen) seperat eingeblendet, wird in der App eine kombination daraus gemacht ? falls ja super.
|
||||
→ Nein: Mobile Feed zeigt ausschließlich den chronologischen Follower-Feed (feed.getFeed, mode "chrono"). Web's separater "Trending"-Tab (explore.getTrending, "könnte dir auch gefallen") ist in der App noch gar nicht gebaut — bekannte Lücke, bereits in der README dokumentiert.
|
||||
- [x] unter meine Tiere, bitte Impressum datenschutz und agbs als link einbinden. sowie eine Versionsnummer der App
|
||||
@@ -38,7 +39,7 @@ TODO Neu2:
|
||||
→ Bei eigenem Beitrag öffnet "⋮" jetzt einen Lösch-Bestätigungsdialog statt der Report-Ansicht, bei fremden Beiträgen bleibt Report wie gewohnt; beides live bestätigt.
|
||||
- [x] im Feed fehlt ein Indikator wenn in einem Post mehrere Bilder hinterlegt sind. das wischen funktioniert, aber nur wenn man weiß das dort mehrere bilder hinterlegt sind. unwissende würde nur ein bild sehen können.
|
||||
→ Zähler-Badge oben rechts ("1 / 3") plus Punkte-Indikator unter dem Bild ergänzt (analog zur Web-Version), live bestätigt.
|
||||
- [x] Kommentar Fenster ist nicht im Darkmode
|
||||
- [x] Kommentar Fenster ist nicht im Darkmode
|
||||
- [x] Tier Anlegen ist nicht im Darkmode + Rassen werden als Dropdown angezeigt lassen sich aber nicht scrollen.
|
||||
→ Darkmode: PetCreateForm.tsx + onboarding/pet.tsx nutzten noch die statischen (Hell-only) `colors`/`typography` statt `useTheme()` — umgestellt auf das gleiche Muster wie sign-up.tsx/OptionPicker.tsx. Scroll-Bug: onboarding/pet.tsx nutzte als äußeren Container ein normales `ScrollView`, darin verschachtelt der Rassen-Dropdown (OptionPicker) sein eigenes `ScrollView` — zwei gleichachsige ScrollViews ineinander verschlucken auf Android die Scroll-Geste des inneren, auch mit `nestedScrollEnabled`. Fix: äußeren Container auf `FlatList` (leere Liste + `ListHeaderComponent`) umgestellt, exakt das bereits funktionierende Muster aus explore.tsx für dieselbe OptionPicker-Komponente. Typecheck + Lint sauber, i18n-Parität unverändert (243/243) — nicht live auf Android getestet (kein Emulator in dieser Session), nur strukturell gegen den bekannten Explore-Fix abgeglichen.
|
||||
- [x] Nach dem Speichern des ersten Tiers, und dem Hochladen des Profilfotos, wird man wieder zurück geworfen auf Tier erstellen. erst ein neustart der app lässt einen auf den feed zugreifen. (BUG)
|
||||
@@ -110,18 +111,101 @@ ErrorRecovery: exception encountered: Error: Missing required env var EXPO_PUBLI
|
||||
|
||||
**✅ Am Gerät bestätigt: Logout-Button erscheint, funktioniert.** Das OTA-Update kommt jetzt tatsächlich an und startet erfolgreich — die fehlenden EAS-Server-Env-Vars waren die echte, vollständige Ursache der gesamten Update-Odyssee dieser Session. `expo-dev-client`-Entfernung bleibt trotzdem sinnvoll (gehört nicht in Production), war aber nicht der entscheidende Fix. **TODO Neu6 damit abgeschlossen.**
|
||||
|
||||
Neu 7:
|
||||
- [ ] Datums eingabe unüblich mit - getrennt statt mit . ansicht am Handy wird meist durch die tastatur überblendet. können wir für die mobile eine data wheel picker nutzen ?
|
||||
Neu 7:
|
||||
- [ ] Datums eingabe unüblich mit - getrennt statt mit . ansicht am Handy wird meist durch die tastatur überblendet. können wir für die mobile eine data wheel picker nutzen ?
|
||||
→ Analyse erledigt, betrifft 4 Dateien (edit.tsx Geburtstag/Adoptionsdatum + 3 Health-Sektionen). Wheel-Picker braucht ein natives Modul (`@react-native-community/datetimepicker`) → kein einzelner Build nur dafür, stattdessen auf `PENDING-NATIVE-BUILD.md` verschoben und wird mit dem nächsten ohnehin anstehenden nativen Build mitgenommen. Der reine Trennzeichen-Fix ("-" → ".") ist davon unabhängig und OTA-fähig, sobald gewünscht.
|
||||
- [x] Ein User bemängelt, das die Regisrierung gut ist, aber beim eingeben des Passworts diese hinter der "Bildschrimtastatur" verschwindet, ausserdem gibt es keine Möglichkeit für den User die korrekte eingabe nochmal zu prüfen.
|
||||
→ **Trennzeichen erledigt (04.10.)**, siehe iOS-Abschnitt unten (`DateField`, TT.MM.JJJJ). Wheel-Picker weiter offen (nativer Build).
|
||||
- [x] Ein User bemängelt, das die Regisrierung gut ist, aber beim eingeben des Passworts diese hinter der "Bildschrimtastatur" verschwindet, ausserdem gibt es keine Möglichkeit für den User die korrekte eingabe nochmal zu prüfen.
|
||||
→ Beides im Code bestätigt (`app/(auth)/sign-up.tsx`, gleiches Muster auch in `sign-in.tsx` gefunden und mitbehoben): (1) Der Screen nutzte nur ein einfaches `View` als äußeren Container, kein `ScrollView`/`KeyboardAvoidingView` — auf kleinen Screens schob sich die Tastatur einfach über die unteren Felder. Fix: äußerer Container jetzt `KeyboardAvoidingView` (iOS: `padding`) + `ScrollView` (`keyboardShouldPersistTaps="handled"`, `contentContainerStyle` mit `flexGrow` statt `flex`, damit weiterhin zentriert bleibt aber scrollbar wird). (2) Neue geteilte `PasswordField`-Komponente (`src/components/PasswordField.tsx`) mit Auge/Auge-durchgestrichen-Toggle (lucide `Eye`/`EyeOff`) ersetzt das nackte `secureTextEntry`-`TextField` in beiden Screens. Kein natives Modul, OTA-fähig. Typecheck sauber (Mobile).
|
||||
- [x] Ein anderer User, meinte wäre es nicht schöner und aufgeräumter wenn die App Settings nicht unter MyPets stehen würden sondern einen eigenen Tab erhalten.
|
||||
- [x] Ein anderer User, meinte wäre es nicht schöner und aufgeräumter wenn die App Settings nicht unter MyPets stehen würden sondern einen eigenen Tab erhalten.
|
||||
→ Umgesetzt (Plan: `~/.claude/plans/mobile-settings-tab-extraction.md`, Gitea-Issue #36, Branch `feature/settings-header-icon`). Statt eines 5. Bottom-Tabs (ursprünglich vorgeschlagene Variante, siehe Mockup `mobile-settings-tab-mockup.png` im Plan-Ordner) wurde ein **Zahnrad-Icon im Header** gewählt, neben der Mailbox — auf Wunsch des Users, da Settings realistisch nur 1–2× im Halbjahr geöffnet wird und ein permanenter 5. Tab dafür unnötig Platz kostet (Mockup: `mobile-settings-header-icon-mockup.png`). Neuer Screen `app/(app)/settings.tsx` (Stack-Push wie "Suche", kein Modal, keine Tab-Bar sichtbar währenddessen), erreichbar über `SettingsHeaderButton` in `(tabs)/_layout.tsx` — erscheint auf allen 4 Tabs neben `MessagesHeaderButton`. Die 5 Footer-Sektionen aus `pets.tsx` (Darstellung/Sprache/Tier-Vorschläge/Rechtliches/Logout) wurden nach `src/components/settings/*.tsx` extrahiert (DRY, je eigene Datei). `pets.tsx` ist jetzt reine Tierliste. Nebenbei behoben: `FooterErrorBoundary` schützt jetzt den **gesamten** Settings-Screen statt nur den Logout-Button (Lücke aus der Code-Review beim OTA-Indikator-Feature). Typecheck + Lint sauber, i18n-Parität geprüft.
|
||||
- [x] Ein user hat die meldung erhalten, "you do not have premission to access this pet" - kannst du mir erklären woher das kommen könnte ?
|
||||
→ Ursache im Code gefunden (Web-seitig, nicht Mobile): Der Fehlertext kommt exakt aus `assertPetOwnership` (`src/lib/assert-pet-ownership.ts:19`), dem gemeinsamen Ownership-Check, den praktisch jede pet-bezogene Mutation zuerst durchläuft. Web speicherte die "aktive Tier"-ID in `localStorage["active_pet_id"]` (`src/context/ActivePetContext.tsx`) — anders als Mobile (siehe TODO Neu6: `LogoutSection` ruft dort bewusst `clearActivePetId()` auf) wurde dieser Wert auf Web **nirgends geleert**, auch nicht beim Sign-out über Clerks `<UserButton />`. `ActivePetInitializer` setzt nur dann einen Wert, wenn `activePetId` leer ist — er prüfte nie, ob die gespeicherte ID überhaupt noch zum aktuell eingeloggten Owner gehört. Konkretes Szenario: Nutzer A meldet sich ab, Nutzer B meldet sich im selben Browser an (gemeinsamer Rechner, oder A erstellt ein zweites Konto) — B's Session lud weiterhin A's alte `active_pet_id`, und jede Aktion schickte diese fremde Pet-ID ans Backend, das sie zu Recht mit genau dieser Fehlermeldung ablehnte. Gleiche Bug-Klasse wie der bereits auf Mobile behobene Logout-Fund, hier aber nie für Web nachgezogen.
|
||||
Fix: `clearActivePetId()` in `src/context/ActivePetContext.tsx` ergänzt (Pendant zu Mobile). Da `ActivePetProvider` im Root-Layout **außerhalb** von `ClerkThemeProvider` sitzt (Clerk-Hooks dort nicht verfügbar — bewusste Architektur laut `(app)/layout.tsx`-Kommentar, um den Clerk-Bundle aus öffentlichen Routen rauszuhalten), kein direkter `useAuth()`-Zugriff im Context selbst möglich. Stattdessen neue `SignOutCleanup`-Komponente (`src/components/layout/SignOutCleanup.tsx`), mounted in `(app)/layout.tsx` neben `ActivePetInitializer` — beobachtet `useAuth().isSignedIn` und ruft `clearActivePetId()` beim Wechsel von `true` auf `false`, statt einen Klick-Handler an Clerks `<UserButton/>` zu hängen (die bietet dafür keinen sauberen Hook-Punkt). Deckt damit jeden Sign-out ab, unabhängig vom Auslöser. Typecheck + voller Testlauf (209/210, 1 skip wie zuvor) sauber, keine neuen Lint-Fehler.
|
||||
**Gemerged nach `main` in Commit `30c57b6`** (Merge von `feature/settings-header-icon`, Issue #36 geschlossen), end-to-end auf einem Android-Emulator verifiziert (Zahnrad öffnet Einstellungen korrekt, Zurück-Navigation funktioniert, keine Tab-Bar sichtbar währenddessen, "Meine Tiere" zeigt nur noch die Tierliste) und per OTA live veröffentlicht (Update-Group `f31e3b54`).
|
||||
- [x] Ein user hat die meldung erhalten, "you do not have premission to access this pet" - kannst du mir erklären woher das kommen könnte ?
|
||||
→ Ursache im Code gefunden (Web-seitig, nicht Mobile): Der Fehlertext kommt exakt aus `assertPetOwnership` (`src/lib/assert-pet-ownership.ts:19`), dem gemeinsamen Ownership-Check, den praktisch jede pet-bezogene Mutation zuerst durchläuft. Web speicherte die "aktive Tier"-ID in `localStorage["active_pet_id"]` (`src/context/ActivePetContext.tsx`) — anders als Mobile (siehe TODO Neu6: `LogoutSection` ruft dort bewusst `clearActivePetId()` auf) wurde dieser Wert auf Web **nirgends geleert**, auch nicht beim Sign-out über Clerks `<UserButton />`. `ActivePetInitializer` setzt nur dann einen Wert, wenn `activePetId` leer ist — er prüfte nie, ob die gespeicherte ID überhaupt noch zum aktuell eingeloggten Owner gehört. Konkretes Szenario: Nutzer A meldet sich ab, Nutzer B meldet sich im selben Browser an (gemeinsamer Rechner, oder A erstellt ein zweites Konto) — B's Session lud weiterhin A's alte `active_pet_id`, und jede Aktion schickte diese fremde Pet-ID ans Backend, das sie zu Recht mit genau dieser Fehlermeldung ablehnte. Gleiche Bug-Klasse wie der bereits auf Mobile behobene Logout-Fund, hier aber nie für Web nachgezogen.
|
||||
Fix: `clearActivePetId()` in `src/context/ActivePetContext.tsx` ergänzt (Pendant zu Mobile). Da `ActivePetProvider` im Root-Layout **außerhalb** von `ClerkThemeProvider` sitzt (Clerk-Hooks dort nicht verfügbar — bewusste Architektur laut `(app)/layout.tsx`-Kommentar, um den Clerk-Bundle aus öffentlichen Routen rauszuhalten), kein direkter `useAuth()`-Zugriff im Context selbst möglich. Stattdessen neue `SignOutCleanup`-Komponente (`src/components/layout/SignOutCleanup.tsx`), mounted in `(app)/layout.tsx` neben `ActivePetInitializer` — beobachtet `useAuth().isSignedIn` und ruft `clearActivePetId()` beim Wechsel von `true` auf `false`, statt einen Klick-Handler an Clerks `<UserButton/>` zu hängen (die bietet dafür keinen sauberen Hook-Punkt). Deckt damit jeden Sign-out ab, unabhängig vom Auslöser. Typecheck + voller Testlauf (209/210, 1 skip wie zuvor) sauber, keine neuen Lint-Fehler.
|
||||
- [x] Nutzer Feedback - DarkMode - Neuer Beitrag - Text die getippt werden sind weiß auf weißem grund.
|
||||
→ Ursache gefunden: `app/(app)/post-new.tsx` gehörte noch zu den nicht auf Dark Mode migrierten Screens — es importierte die statischen, Light-only `colors`/`typography` aus `theme/tokens.ts` direkt, statt sie per `useTheme()` zu beziehen (gleiche Bug-Klasse wie die bereits behobenen Fälle aus dem Darkmode-Audit, siehe README/CLAUDE.md-Kommentar in `theme/tokens.ts`: "dark-mode support has to be added file-by-file"). Der Container-Hintergrund blieb dadurch immer hell (`colors.porchWhite`), während die geteilte `TextField`-Komponente **korrekt** theme-abhängige Textfarbe zieht (`colors.ink`, im Dark Mode hell) — macht bei aktivem Dark Mode aus hellem Text auf hellem Hintergrund den gemeldeten "weiß auf weiß"-Effekt in der Caption-Eingabe.
|
||||
Fix: `post-new.tsx` auf `useTheme()` + `makeStyles(colors)`-Muster umgestellt (identisch zu `milestone-new.tsx`). Gleiches Muster/gleicher Fund auch in `story-new.tsx` entdeckt (dort ohne Texteingabe, macht sich als falscher heller Hintergrund statt unlesbarem Text bemerkbar) — gleich mitbehoben.
|
||||
Typecheck + Lint sauber (0 neue Warnungen, 6 vorbestehende `exhaustive-deps`-Warnungen unverändert).
|
||||
**Nachtrag:** `app/(app)/search.tsx` (gleicher Fehler im Suchfeld) auf Nachfrage ebenfalls behoben — dort bekommen die separaten Unterkomponenten (`TabButton`, `PetRow`, `Loading`, `EmptyState`) `styles`/`colors` jetzt als Props von der Hauptkomponente durchgereicht, statt selbst die statischen Tokens zu importieren. Typecheck + Lint sauber (1 vorbestehende, unveränderte `alt-text`-Warnung bei `expo-image`).
|
||||
**Nachtrag:** `app/(app)/search.tsx` (gleicher Fehler im Suchfeld) auf Nachfrage ebenfalls behoben — dort bekommen die separaten Unterkomponenten (`TabButton`, `PetRow`, `Loading`, `EmptyState`) `styles`/`colors` jetzt als Props von der Hauptkomponente durchgereicht, statt selbst die statischen Tokens zu importieren. Typecheck + Lint sauber (1 vorbestehende, unveränderte `alt-text`-Warnung bei `expo-image`).
|
||||
- [x] Google-SSO-Login ging nicht mehr, obwohl das schon mal gelöst war ("The current redirect url passed in the sign in or sign up request does not match an authorized redirect URI for this instance... pawfeed://sso-callback").
|
||||
→ **Kein Code-Bug** — `GoogleSignInButton.tsx`/`useWarmUpBrowser.ts` (Commit `99575dd`) waren unverändert korrekt. Ursache lag außerhalb des Repos: Clerk führt pro Instanz eine eigene, serverseitige Allowlist autorisierter Redirect-URLs (`GET/POST https://api.clerk.com/v1/redirect_urls`, Auth via `CLERK_SECRET_KEY`) — komplett getrennt vom App-Code, kann also unabhängig vom letzten "war doch schon gelöst"-Stand geändert/geleert worden sein. Registriert war nur ein falscher/veralteter Eintrag `clerk://org.pawfeed.app2.callback` (vermutlich Rest einer früheren Bundle-ID-Iteration mit einer "2" drin) — `pawfeed://sso-callback` (aus `app.json`s `"scheme": "pawfeed"`) fehlte komplett.
|
||||
Fix: fehlenden Eintrag per Clerk Backend API ergänzt (`POST /v1/redirect_urls`, `{"url":"pawfeed://sso-callback"}`) — live sofort wirksam, kein App-Deploy/Rebuild nötig. Am Emulator bis zum Google-Consent-Screen verifiziert (öffnet korrekt, leitet zu pawfeed.org weiter), kompletten Rücksprung dann vom User auf echtem Gerät bestätigt.
|
||||
**Merke für künftige Sessions:** wenn SSO trotz unverändertem Code plötzlich bricht, zuerst die Clerk-Redirect-URL-Allowlist prüfen, bevor im App-Code gesucht wird — die liegt nicht in git und kann von außerhalb dieser Session geändert worden sein.
|
||||
- [x] Story Ring - ist abgeschnitten in der Mobil-App.
|
||||
→ Ursache: Die zwei mittleren "Zehen" der Pfote (`cy: 1, r: 10`) ragen in der 0–100-viewBox bis y = -9. Web zeichnet das SVG mit `overflow: visible`, `react-native-svg` schneidet dagegen an der viewBox ab. Fix in `src/components/PawRing.tsx`: viewBox nach oben erweitert (`0 -10 100 110`), Box entsprechend höher (`getPawRingBox()`); der "Story hinzufügen"-Knopf in `StoryTray.tsx` nutzt dieselbe Box und sitzt jetzt auf gleicher Höhe. Commit `2ef439c`, per OTA für Android veröffentlicht (Update-Group `4dc6f797`), noch nicht am Gerät bestätigt.
|
||||
- [x] Mehrere Nutzer melden nach Google-Login die Meldung "Unmatched Route pawfeed://sso-callback".
|
||||
→ Anderer Bug als der frühere Clerk-Redirect-URL-Allowlist-Fund oben (der Eintrag ist weiterhin korrekt registriert) — diesmal ein echter Code-Bug, spezifisch Android. `useSSO()` (`@clerk/expo`) baut die Redirect-URI selbst via `AuthSession.makeRedirectUri({ path: "sso-callback" })` → `pawfeed://sso-callback`, und `WebBrowser.openAuthSessionAsync()` fängt die Rückleitung normalerweise über eine eigene Custom-Tab-Session ab (das ist der Zweck von `WebBrowser.maybeCompleteAuthSession()` in `app/_layout.tsx`). Auf Android liefert das OS den Redirect-Intent aber zusätzlich als normalen Deep-Link an die App — den sieht auch Expo Routers eigener Linking-Listener, und weil es dafür keine passende Route gab, zeigte Expo Router kurz seinen eingebauten "Unmatched Route"-Fehlerschirm, bevor `GoogleSignInButton`s `router.replace("/(app)")` (nach erfolgreichem `setActive()`) übernahm — bei langsameren Geräten/Netzwerken blieb der Fehlerschirm sichtbar hängen statt wegzuflippen. Bekanntes Muster bei Clerk+Expo-Router (u. a. expo/expo#22662, mehrere Community-Fixes mit identischer Lösung).
|
||||
Fix: neue Datei `app/sso-callback.tsx` — reiner Lade-Spinner (kein eigenes Redirect/Auth-Logic), gibt Expo Router für dieses kurze Zeitfenster ein gültiges Ziel. Die bestehende `useProtectedRoute()`-Umleitung in `app/_layout.tsx` übernimmt danach wie gewohnt (leitet nicht-eingeloggte Nutzer automatisch weiter, sobald sie außerhalb der `(auth)`-Gruppe landen), `GoogleSignInButton` navigiert bei Erfolg wie bisher aktiv weiter. Reine JS/TSX-Änderung, kein natives Modul betroffen → OTA-fähig, kein neuer Store-Build nötig. Typecheck + Lint sauber (kein Live-Test auf Emulator/Gerät möglich in dieser Session).
|
||||
**Gemerged nach `main`** (Fast-Forward von `fix/sso-callback-unmatched-route`, Commit `17a17bd`, unabhängig von der noch offenen `feature/mobile-video-stories`-Merge-Entscheidung) und **per OTA live veröffentlicht** (`npx eas-cli update --branch production --environment production --platform android`, Update-Group `df2a3ce9-7eba-4cb8-bf10-7d478a4a8a60`, Runtime-Version `1.0.6`). Installierte Apps ziehen das automatisch beim nächsten Start.
|
||||
**Stolperstein beim Publish:** Das lokale (gitignorete) `mobile/android/`-Verzeichnis vom Device-Test der letzten Session ließ `eas update` das Projekt fälschlich als "Bare Workflow" erkennen (`CommandError: runtime version policies are not supported` statt der konfigurierten `"policy": "appVersion"`) — Fix war, `android/` kurz umzubenennen/wegzuschieben, Update zu publishen, danach zurückzubenennen. Für künftige OTA-Publishes nach einem lokalen `expo run:android`-Test: dasselbe Problem erwarten.
|
||||
- [x] Im Pet-Profil ist der Erste Post nicht Klickbar da er in die Gesten-Steuerung rein ragt.
|
||||
→ Ursache: Android läuft edge-to-edge, und die Stack-Screens (ohne Tab-Leiste darunter) hatten unten keinen Safe-Area-Abstand — der letzte Inhalt lag unter der Gesten-Leiste. Fix: neuer Hook `src/hooks/useBottomSafePadding.ts` (paddingBottom = 16 + insets.bottom), eingebaut in Tierprofil, Follower, Folgt, Gesundheit, Meilensteine, Profil bearbeiten, Einstellungen, Suche, Nachrichten-Liste und Konto löschen. Commit `de237f1`, per OTA für Android veröffentlicht (Update-Group `4dc6f797`), noch nicht am Gerät bestätigt. Gilt automatisch auch für iOS (Home-Indicator).
|
||||
- [x] Nutzer-Feedback: Nach dem Posten einer Story erscheint man selbst nicht als Ring — man kann die eigene Story nicht ansehen.
|
||||
→ Ursache (Web **und** App gleich): Die Story-Leiste zeigt nur die „+“-Kachel und Storys gefolgter Tiere (`follows.getPetsWithActiveStories`); das eigene aktive Tier ist darin nie enthalten. Fix: zusätzlicher Ring „Deine Story“ direkt nach der „+“-Kachel, solange das aktive Tier eine aktive Story hat (`stories.hasActiveStory`, wird nach dem Posten schon invalidiert). Web: `src/components/stories/StoryTray.tsx` (öffnet den StoryViewer — dort sieht der Owner auch die Zuschauerliste); App: `src/components/StoryTray.tsx`. Commit `204d5f6`, live (Web + OTA Android `58e99a29`, iOS `2aaa3975`). Typecheck/Tests/Build grün; die 2 Lint-Fehler „Cannot access refs during render“ in der Web-StoryTray bestanden schon vorher (horizontales Scrollen).
|
||||
**Story-Kommentare:** Backend (`stories.addComment/listComments/deleteComment`, `toggleReaction`, `getViewers`) und Web-UI (`StoryCommentsOverlay`) existieren bereits — nur der App-Viewer (`app/(app)/story-viewer.tsx`) hat Kommentare, Reaktionen und Zuschauerliste beim Portieren bewusst ausgelassen. Offen als eigener Punkt.
|
||||
- [x] Story-Kommentare, Pfoten-Reaktionen und Zuschauerliste in der App (Folge-Punkt zum Story-Feedback).
|
||||
→ Neuer App-Viewer (`app/(app)/story-viewer.tsx`) mit Pfote (`src/components/stories/StoryPawButton.tsx`), Kommentar-Bottom-Sheet (`StoryCommentsSheet.tsx`, Schreiben + eigene löschen) und — nur bei der eigenen Story — Zuschauerliste (`StoryViewersSheet.tsx`, Augen-Symbol oben links). Solange ein Sheet offen ist, pausiert der Story-Timer (anders als im Web, damit die Story beim Tippen nicht weiterspringt). Nutzt die vorhandenen Server-Prozeduren; einzige Server-Änderung: `stories.listActive` nimmt optional `viewerPetId` und liefert `reactedByViewer`, damit die Pfote gefüllt startet, wenn man schon reagiert hat — abwärtskompatibel, und der Web-Viewer nutzt es jetzt auch (bekannte Einschränkung "Pfote startet immer leer" behoben). Tests in `src/__tests__/stories.test.ts`, alle 268 Tests + Build + App-Typecheck grün. Nicht auf einem Gerät getestet; Tastaturverhalten im Kommentar-Sheet (Android `KeyboardAvoidingView` behavior "height" im Modal) bitte bei Testern beobachten. Weiterhin offen: keine Benachrichtigung bei Story-Kommentaren/-Pfoten (Notification hat keine `storyId`, bräuchte Schemaänderung); Video-Storys spielt der App-Viewer weiterhin nicht ab. Commit `ccebfa4`, live (Web + OTA Runtime 1.0.6: Android `a3941052-a3ec-4c3c-a875-24ad3dcd1c98`, iOS `5e855c5b-f6db-44d0-8350-6b0b91308804`).
|
||||
-
|
||||
|
||||
|
||||
|
||||
!!! iOS - Founds beginnen hier - nicht mit Android vertauschen !!!
|
||||
|
||||
- [x] Nutzer-Feedback: Storys und Beiträge mit Bildern im HEIF/HEIC-Format (Apple) lassen sich nicht erstellen.
|
||||
→ **Kein reines Serverproblem.** Der Server nimmt bewusst nur JPEG/PNG/WebP an (`ALLOWED_CONTENT_TYPES` in `src/lib/supabase-storage.ts`), und die Bytes gehen direkt vom Client zu Supabase — umgewandelt wurde HEIC nirgends. Zwei Fundstellen:
|
||||
1. **App, Beiträge:** `app/(app)/post-new.tsx` filterte gewählte Fotos nach ihrem *Original*-MIME-Typ (`image/jpeg|png|webp`) und warf `image/heic`/`image/heif` still raus — obwohl `resizeForUpload()` danach sowieso jedes Bild in JPEG umwandelt. Storys/Meilensteine/Avatar in der App filtern nicht und waren nicht betroffen.
|
||||
2. **Web, alle Bild-Uploads** (Beiträge, Storys, Meilensteine, Tierbild): nur JPEG/PNG/WebP erlaubt; ein `.heic` vom Mac oder eine rohe HEIC-Datei aus iOS-Safari (Storys nutzen `accept="image/*,video/*"`, das Safaris automatische HEIC→JPEG-Umwandlung aushebelt) wurde abgelehnt.
|
||||
Fix: App — HEIC/HEIF in `post-new.tsx` zugelassen, plus Hinweis-Alert, falls doch einmal Bilder übersprungen werden (`post.unsupportedFormat`). Web — neues `src/lib/heic-to-jpeg.ts` wandelt HEIC vor dem Upload per Browser-Dekodierung (Canvas → JPEG 0,9) um, eingebaut in `MultiImageUpload`, `StoryForm`, `MilestoneForm`, `AvatarUpload`; `accept` um HEIC ergänzt, damit der Mac-Dateidialog sie anbietet. Bewusst **ohne** Zusatzbibliothek (heic-to: LGPL-3.0, ~26 MB; heic2any: seit 2023 ungepflegt): Safari (iOS 17+/macOS 14+) dekodiert HEIC selbst; Browser ohne HEIC-Support (z. B. Chrome/Windows) bekommen eine klare Meldung „Öffne PawFeed in Safari oder speichere das Foto als JPG“ (`heicUnsupported`). Tests in `src/__tests__/heic-to-jpeg.test.ts`, alle 266 Tests + Build grün. Nicht auf einem echten iPhone getestet. Commit `1bf0f92`, Web per Rolling-Deploy live, OTA Runtime 1.0.6: Android `1b4baa80-a35a-49b7-ac6e-f44ebe93b4db`, iOS `c31382ee-83b7-4a81-a06d-1a2e53180d7d`. **Vom Nutzer bestätigt (02.10.2026): funktioniert.**
|
||||
|
||||
- [x] Nutzerfeedback - Story löschbar ? wie kann man die story löschen, falls man mal was falsches in die story geladen hat ?
|
||||
→ Gab es bisher nirgends. Neu: Server `stories.delete` (nur Owner; löscht Aufrufe — `StoryView` hat bewusst kein Cascade —, dann die Story, Pfoten/Kommentare per Cascade; danach Bild in Supabase Storage bzw. Mux-Asset best-effort). Web: Papierkorb neben der Zuschauerzahl in der eigenen Story + Rückfrage (`AlertDialog`), Bild-Timer pausiert während der Rückfrage, gelöscht wird die beim Antippen gemerkte Story-ID. App: gleicher Papierkorb + native Rückfrage, Timer pausiert. Nach dem Löschen springt der Viewer zur nächsten Story bzw. schließt, wenn keine mehr da ist. 5 neue Tests in `src/__tests__/stories.test.ts`. Nicht auf einem Gerät getestet. Commit `81d9c6d`, live (Web + OTA Runtime 1.0.6: Android `49757586-c443-4952-a679-c66a4225ef82`, iOS `48d22b11-abc9-4c75-be52-a122b0130c5e`).
|
||||
- [x] Storys nur für Ersteller länger einsehbar wegen den Kommentaren (7Tage)
|
||||
→ **Umgesetzt (02.10.):** Der Ersteller sieht seine Storys 7 Tage lang (danach löscht der Aufräum-Job sie), alle anderen weiterhin nur 24 Stunden. Server: `stories.listActive` liefert dem Owner die Storys der letzten 7 Tage mit `isExpired`; `stories.hasActiveStory` meldet mit `includeOwnRecent` (nur die Story-Leiste setzt das) einen grauen Ring `expiredOnly`, damit Profilbilder im Feed nicht ebenfalls grau umrandet werden. Web + App: grauer Ring „Deine Story“, Hinweis „Abgelaufen – nur für dich sichtbar“ im Viewer, Kommentare bei abgelaufenen Storys nur lesbar, Pfote nur als Zähler; Zuschauerliste und Löschen bleiben. Keine Schemaänderung. 4 neue Tests. Nicht auf einem Gerät getestet. Commit `2c0680c`, live (Web + OTA Runtime 1.0.6: Android `5b324ee8-4821-4ca5-b6e7-6a02635a41f7`, iOS `a430ccd0-f2bb-452f-b3cd-45e94910901b`).
|
||||
→ Noch offen. Befund: abgelaufene Storys werden nie gelöscht (Einträge, Bilder, Mux-Videos bleiben liegen) — Vorschlag: grauer Ring "Deine Story" 7 Tage für den Ersteller, danach Aufräum-Cron (braucht einen Auslöser auf dem NAS, die bestehenden Cron-Routen haben keinen) + Satz unter "Speicherdauer" in der Datenschutzerklärung. **Teil erledigt (02.10.):** Aufräum-Job gebaut — `/api/cron/cleanup-stories` löscht Storys älter als 7 Tage komplett (Aufrufe, Pfoten, Kommentare, Bild, Mux-Video; gemeinsame Funktion `src/lib/story-cleanup.ts`, auch von „Story löschen“ genutzt), täglich 04:30 über die bestehende NAS-Crontab (`docker/run-cron.sh`, Log in `docker/cron.log`); Datenschutzerklärung Abschnitt 6 ergänzt. Offen bleibt nur die 7-Tage-Ansicht für den Ersteller.
|
||||
|
||||
|
||||
|
||||
- [x] Video-Storys in der App (Upload + Wiedergabe)
|
||||
→ **Umgesetzt (03.10.):** Branch `feature/mobile-video-stories` (Gitea #37, 20.09. auf Emulator + Gerät getestet) auf den seit 02.10. umgebauten Story-Viewer portiert. Galerie-Picker nimmt Fotos und Videos (max. 30 s, Prüfung vor dem Upload), Upload direkt zu Mux über `stories.createVideoUpload`; Viewer spielt READY-Videos über `expo-video` ab (Fortschritt aus `timeUpdate`, weiter bei `playToEnd`, `play()` erst bei `readyToPlay`), zeigt „wird verarbeitet“/Fehler und pausiert das Video, solange Kommentare/Zuschauer/Löschen offen sind. Nur JS (`expo-video` ist seit dem 1.0.6-Build nativ drin). App-Typecheck grün; der Port selbst (Pause bei offenem Sheet) **nicht auf einem Gerät getestet**. Kamera nimmt weiterhin nur Fotos auf (Video-Aufnahme bräuchte Mikrofon-Berechtigung = neuer Build). Commit `69e2c13`, OTA Runtime 1.0.6: Android `665f2bf0-7e19-4b38-a07f-5da03459a55c`, iOS `963e721c-3c7c-4d45-b08a-b3c49969e5a2`.
|
||||
- [x] Nutzer-Feedback: Beim Video-Story-Upload sieht man nur einen Spinner — Fortschritt nicht erkennbar.
|
||||
→ **Umgesetzt (03.10.):** Beim Video zuerst „Upload wird vorbereitet …“, dann Fortschrittsbalken mit Prozent („Video wird hochgeladen … 45 %“) aus `File.upload` `onProgress` (expo-file-system 57.0.7, nativ im 1.0.6-Build — Android meldet alle 100 ms). Vorschau während des Uploads gesperrt. Fotos behalten den Spinner (Upload dauert dort nur Sekundenbruchteile). App-Typecheck grün, nicht auf einem Gerät getestet. Commit `5606a81`, OTA Runtime 1.0.6: Android `000908ec-4aac-4bc3-80c7-a86ea438d1c2`, iOS `cd0481b9-84d3-4734-987b-c1b418d51c61`.
|
||||
|
||||
- [x] Feedback kam über iOS, betrifft aber das System, bei Impfungen die man anlegt, sollte ein Wiederholungstermin nach dem Abklingen der Impfung eingebunden werden, mit einem Reminder -1Monat vor ablauf damit man einen neuen Termin vereinbaren kann zum Impfen.
|
||||
→ **Umgesetzt und live (04.10.).** Den Folgetermin gab es schon (`Vaccine.nextDueDate`, Web + App), es fehlte nur die Erinnerung. Neu: täglicher Cron `/api/cron/vaccine-reminders` (`src/lib/vaccine-reminders.ts`) erinnert **einmalig 30 Tage vor** dem Folgetermin per Mitteilung `VACCINE_DUE` + Push („Die Impfung „Tollwut“ für Bubi ist am 01.11.2026 fällig …“). Bereits überfällige Termine werden nicht nachträglich erinnert (die zeigt die Gesundheitsseite als „überfällig“). Schema: `NotificationType.VACCINE_DUE`, `Notification.vaccineId` (Cascade), `Vaccine.reminderSentAt` (Schutz gegen doppelten Versand, wird bei Fehler wieder freigegeben). Web + App zeigen die Mitteilung mit 💉, Tippen öffnet die Gesundheitsseite. App: Schnellauswahl „+1 Jahr / +3 Jahre“ für den Folgetermin plus Hinweis „Mit Folgetermin erinnern wir dich 30 Tage vorher“. 8 neue Tests, Typecheck Web + App grün. Commit `c74bbff`, App-Teil per OTA Runtime 1.0.6: Android `64144379-e335-45ca-bb05-bc12155da54c`, iOS `b8f31fed-aa02-41a2-97c3-3707d02857ce`. Schema per `db push` auf dem NAS (rein additiv), Rolling-Deploy (`7ab74f8`; der erste Versuch brach ab, weil ein Test aus `src/__tests__` nach `mobile/` importierte, das im Docker-Build fehlt — Test liegt jetzt in `mobile/src/lib/date-format.test.ts`), Crontab täglich 08:15. Testaufruf: HTTP 200, `sent: 0` (derzeit nichts fällig). Der echte Versand inkl. Push ist noch nicht mit einer fälligen Impfung beobachtet, die App nicht auf einem Gerät getestet.
|
||||
- [x] Feedback iOS Datums eingabe läuft noch mittels - Trennung statt . ( option für iOS - Data Wheel Picker)
|
||||
→ **Trennzeichen umgesetzt (04.10.):** Neue `DateField`-Komponente (`src/components/DateField.tsx`) für alle Datumsfelder (Geburtstag, Adoptionstag, Gewicht, Tierarzt, Impfungen): Zifferntastatur, die Punkte setzt die App beim Tippen selbst (TT.MM.JJJJ; die iOS-Zifferntastatur hat keinen Punkt). Ältere Eingaben mit „-“ oder „/“ und einstellige Tage/Monate werden weiter akzeptiert. Tests in `src/__tests__/mobile-date-format.test.ts`. Commit `c74bbff`, OTA wie oben. Nicht auf einem Gerät getestet. **Wheel-Picker weiterhin offen** — braucht ein natives Modul, steht in `PENDING-NATIVE-BUILD.md` für den nächsten Build.
|
||||
- [x] Nutzer-Feedback iOS (Screenshot `feedback-ios/WhatsApp Image 2026-10-04 at 12.50.58.jpeg`): Beim Antippen eines Datumsfelds in „Profil bearbeiten“ verdeckt die Tastatur das Feld, die Eingabe ist nicht zu sehen.
|
||||
→ **Behoben (04.10.):** Der Screen hatte keinen Tastatur-Ausgleich (nur `FlatList`, kein `KeyboardAvoidingView`). Neu: `automaticallyAdjustKeyboardInsets` (iOS: unterer Inset in Tastaturhöhe **und** automatisches Scrollen zum aktiven Feld, im RN-Quellcode `RCTScrollViewComponentView.mm` geprüft) plus `keyboardShouldPersistTaps="handled"`. Gleiche Lücke auch in Gesundheit (Gewicht/Impfung/Tierarzt), Neuer Beitrag, Neuer Meilenstein, Tier anlegen/Onboarding und Einladen gefunden und mitbehoben. Android unverändert (Prop gilt nur für iOS, dort nicht gemeldet). Typecheck sauber. Commit `333ba16`, OTA nur iOS Runtime 1.0.6 `718ede5a-91c4-4e4e-b444-174ab3af66a2`. Nicht auf einem Gerät getestet.
|
||||
- [x] Feedback iOS - Rassen suche ist als Dropdown, ohne Suchfunktion. Bei Hunden und Katzen ist die Liste lang, die Suche würde verkürzt werden, wenn man eintippen könnte um das Dropdown zu kürzen.
|
||||
→ **Umgesetzt (04.10.):** `OptionPicker` zeigt ab 10 Einträgen oben ein Suchfeld („Suchen …“), das beim Tippen filtert — ohne Groß-/Kleinschreibung und Akzente („frise“ findet „Bichon Frisé“, `src/lib/filter-options.ts` + Tests). Öffnet sich die Tastatur, rückt die Liste nach oben, damit sie nicht verdeckt wird. Wirkt in Tier anlegen, Profil bearbeiten und Explore-Filter; die kurze Tierarten-Liste bleibt ohne Suchfeld. Typecheck sauber. Commit `01459c7`, OTA Runtime 1.0.6: Android `02f677cc-406b-4475-9601-e61b8a4b963a`, iOS `708be8d6-9786-44ad-a3c9-c305a3f93dd2`. Nicht auf einem Gerät getestet — bitte besonders das Hochrücken über der Tastatur prüfen.
|
||||
- [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).
|
||||
Generated
+32
-4
@@ -17,8 +17,11 @@
|
||||
"@trpc/client": "11.17.0",
|
||||
"@trpc/tanstack-react-query": "11.17.0",
|
||||
"expo": "~57.0.22",
|
||||
"expo-apple-authentication": "~57.0.2",
|
||||
"expo-auth-session": "~57.0.12",
|
||||
"expo-build-properties": "~57.0.22",
|
||||
"expo-constants": "~57.0.16",
|
||||
"expo-crypto": "~57.0.3",
|
||||
"expo-dev-client": "~57.0.19",
|
||||
"expo-file-system": "~57.0.6",
|
||||
"expo-font": "~57.0.3",
|
||||
@@ -6311,6 +6314,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-apple-authentication": {
|
||||
"version": "57.0.2",
|
||||
"resolved": "https://registry.npmjs.org/expo-apple-authentication/-/expo-apple-authentication-57.0.2.tgz",
|
||||
"integrity": "sha512-00KnlyRceRKski+ljZz8q8JhJZ4D3ZScU+Y0RqS6ElJ8GaCWfd7Qh5tMr47yIU/dKDrOnkzkFUM+6vyHS5HI7Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-application": {
|
||||
"version": "57.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.3.tgz",
|
||||
@@ -6353,11 +6367,16 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-auth-session/node_modules/expo-crypto": {
|
||||
"version": "57.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.3.tgz",
|
||||
"integrity": "sha512-SAWqEfF37nc8ot7bXsVmu9AFYwQdC2kGvH/eNAHiR3bmlcf1Vv8wu9AnGKT+a90r3T8YfwMpZP4QAYlo4vCn9w==",
|
||||
"node_modules/expo-build-properties": {
|
||||
"version": "57.0.22",
|
||||
"resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.22.tgz",
|
||||
"integrity": "sha512-A5oRf2I02XDRu2NnqKo7Au5kUu244unvyoRMUsB+xvg/BOlsR+G/oyDFGkjbda1cJbeKm9eqposIXbA2ri5UrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/schema-utils": "^57.0.2",
|
||||
"resolve-from": "^5.0.0",
|
||||
"semver": "^7.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
@@ -6375,6 +6394,15 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-crypto": {
|
||||
"version": "57.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.3.tgz",
|
||||
"integrity": "sha512-SAWqEfF37nc8ot7bXsVmu9AFYwQdC2kGvH/eNAHiR3bmlcf1Vv8wu9AnGKT+a90r3T8YfwMpZP4QAYlo4vCn9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-dev-client": {
|
||||
"version": "57.0.19",
|
||||
"resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.19.tgz",
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
"@trpc/client": "11.17.0",
|
||||
"@trpc/tanstack-react-query": "11.17.0",
|
||||
"expo": "~57.0.22",
|
||||
"expo-apple-authentication": "~57.0.2",
|
||||
"expo-auth-session": "~57.0.12",
|
||||
"expo-build-properties": "~57.0.22",
|
||||
"expo-constants": "~57.0.16",
|
||||
"expo-crypto": "~57.0.3",
|
||||
"expo-dev-client": "~57.0.19",
|
||||
"expo-file-system": "~57.0.6",
|
||||
"expo-font": "~57.0.3",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Platform, View, StyleSheet } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as AppleAuthentication from "expo-apple-authentication";
|
||||
import { useSignInWithApple } from "@clerk/expo/apple";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { radii } from "../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
onError: (message: string) => void;
|
||||
/** Same contract as GoogleSignInButton's `legalAccepted` — see there. */
|
||||
legalAccepted?: boolean;
|
||||
/** Same contract as GoogleSignInButton's `onNeedsConsent` — see there. */
|
||||
onNeedsConsent?: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Sign in with Apple" — required by App Store Review Guideline 4.8 because
|
||||
* the app offers Google login. iOS only: renders nothing on Android or on an
|
||||
* iOS device where Apple Authentication isn't available.
|
||||
*
|
||||
* Uses Clerk's native `useSignInWithApple()` (expo-apple-authentication
|
||||
* sheet + `oauth_token_apple` token exchange) instead of the browser-based
|
||||
* `oauth_apple` SSO flow — Apple expects the native sheet, and it avoids the
|
||||
* redirect-URL pitfalls the Google SSO flow had.
|
||||
*
|
||||
* Uses Apple's own AppleAuthenticationButton (HIG-mandated look), not a
|
||||
* custom Pressable like GoogleSignInButton. Legal-consent handling mirrors
|
||||
* GoogleSignInButton: Clerk's sign-up comes back `missing_requirements`
|
||||
* with `legal_accepted` for brand-new accounts.
|
||||
*/
|
||||
export function AppleSignInButton(props: Props) {
|
||||
const [isAvailable, setIsAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "ios") return;
|
||||
AppleAuthentication.isAvailableAsync().then(setIsAvailable).catch(() => setIsAvailable(false));
|
||||
}, []);
|
||||
|
||||
if (!isAvailable) return null;
|
||||
return <AppleSignInButtonInner {...props} />;
|
||||
}
|
||||
|
||||
function AppleSignInButtonInner({ onError, legalAccepted, onNeedsConsent, disabled }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { scheme } = useTheme();
|
||||
const router = useRouter();
|
||||
const { startAppleAuthenticationFlow } = useSignInWithApple();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const onPress = async () => {
|
||||
setIsPending(true);
|
||||
try {
|
||||
const result = await startAppleAuthenticationFlow();
|
||||
const { setActive, signUp } = result;
|
||||
let createdSessionId = result.createdSessionId;
|
||||
let missingFields = signUp?.status === "missing_requirements" ? signUp.missingFields : [];
|
||||
|
||||
if (!createdSessionId && signUp && missingFields.includes("legal_accepted")) {
|
||||
if (!legalAccepted) {
|
||||
onNeedsConsent?.();
|
||||
return;
|
||||
}
|
||||
const updatedSignUp = await signUp.update({ legalAccepted: true });
|
||||
createdSessionId = updatedSignUp.createdSessionId;
|
||||
missingFields = updatedSignUp.status === "missing_requirements" ? updatedSignUp.missingFields : [];
|
||||
}
|
||||
|
||||
if (createdSessionId && setActive) {
|
||||
await setActive({ session: createdSessionId });
|
||||
router.replace("/(app)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Any other required field Apple can't supply (e.g. first/last name —
|
||||
// Apple only sends the name on the very first authorization) would
|
||||
// otherwise leave the button silently doing nothing.
|
||||
if (missingFields.length > 0) {
|
||||
Sentry.captureMessage(`Apple sign-up missing requirements: ${missingFields.join(", ")}`);
|
||||
onError(t("auth.appleSignInError"));
|
||||
}
|
||||
// No createdSessionId and nothing missing: user cancelled the Apple sheet.
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
onError(err instanceof Error ? err.message : t("auth.appleSignInError"));
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isInactive = isPending || !!disabled;
|
||||
|
||||
return (
|
||||
<View style={isInactive && styles.disabled} pointerEvents={isInactive ? "none" : "auto"}>
|
||||
<AppleAuthentication.AppleAuthenticationButton
|
||||
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
|
||||
buttonStyle={
|
||||
scheme === "dark"
|
||||
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
||||
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
|
||||
}
|
||||
cornerRadius={radii.lg}
|
||||
style={styles.button}
|
||||
onPress={onPress}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: { height: 48, width: "100%" },
|
||||
disabled: { opacity: 0.5 },
|
||||
});
|
||||
@@ -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))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, Pressable, ScrollView, ActivityIndicator, Modal, StyleSheet } from "react-native";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Keyboard,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { ChevronDown, Search } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
|
||||
import { radii, fontFamily, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
|
||||
import { filterOptionsByName } from "../lib/filter-options";
|
||||
|
||||
export type PickerOption = { id: string; name: string };
|
||||
|
||||
@@ -22,6 +35,11 @@ type Props = {
|
||||
const BUTTON_HEIGHT = 48;
|
||||
const LIST_GAP = 4;
|
||||
const MAX_LIST_HEIGHT = 220;
|
||||
// Ab so vielen Einträgen gibt es oben ein Suchfeld (found.md: Hunde-/Katzen-
|
||||
// Rassenlisten sind lang). Tierarten (kurze Liste) bleiben ohne.
|
||||
const SEARCH_MIN_OPTIONS = 10;
|
||||
const SEARCH_HEIGHT = 44;
|
||||
const MIN_LIST_TOP = 48;
|
||||
|
||||
type Anchor = { top: number; left: number; width: number };
|
||||
|
||||
@@ -64,6 +82,13 @@ export function OptionPicker({
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const triggerRef = useRef<View>(null);
|
||||
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const [listHeight, setListHeight] = useState(0);
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
|
||||
const hasSearch = options.length >= SEARCH_MIN_OPTIONS;
|
||||
const visibleOptions = useMemo(() => filterOptionsByName(options, query), [options, query]);
|
||||
|
||||
useEffect(() => {
|
||||
// No need to reset `anchor` to null when closing — the Modal already
|
||||
@@ -76,6 +101,38 @@ export function OptionPicker({
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
// The search field brings up the keyboard; a list anchored low on the
|
||||
// screen would end up underneath it, so it moves up while the keyboard is open.
|
||||
useEffect(() => {
|
||||
if (!open || !hasSearch) return;
|
||||
const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
|
||||
const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
|
||||
const showSub = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height));
|
||||
const hideSub = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0));
|
||||
return () => {
|
||||
showSub.remove();
|
||||
hideSub.remove();
|
||||
setKeyboardHeight(0);
|
||||
};
|
||||
}, [open, hasSearch]);
|
||||
|
||||
const close = () => {
|
||||
setQuery("");
|
||||
onToggle();
|
||||
};
|
||||
|
||||
const select = (option: PickerOption) => {
|
||||
setQuery("");
|
||||
onSelect(option);
|
||||
};
|
||||
|
||||
const listTop = (() => {
|
||||
if (!anchor) return 0;
|
||||
if (!keyboardHeight || !listHeight) return anchor.top;
|
||||
const maxTop = windowHeight - keyboardHeight - listHeight - LIST_GAP;
|
||||
return Math.max(MIN_LIST_TOP, Math.min(anchor.top, maxTop));
|
||||
})();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
@@ -87,7 +144,7 @@ export function OptionPicker({
|
||||
<Text style={selectedName ? styles.value : styles.placeholder}>{selectedName ?? placeholder}</Text>
|
||||
<ChevronDown color={colors.quietGray} size={18} />
|
||||
</Pressable>
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={onToggle}>
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={close}>
|
||||
{/*
|
||||
Backdrop and list are SIBLINGS, not parent/child. The list must
|
||||
not be a descendant of the backdrop Pressable at all — a Pressable
|
||||
@@ -99,17 +156,36 @@ export function OptionPicker({
|
||||
the list's bounds hit-tests into the list's own subtree and never
|
||||
reaches the backdrop's Pressable underneath at all.
|
||||
*/}
|
||||
<Pressable style={styles.backdrop} onPress={onToggle} />
|
||||
<Pressable style={styles.backdrop} onPress={close} />
|
||||
{anchor && (
|
||||
<View style={[styles.list, { top: anchor.top, left: anchor.left, width: anchor.width }]}>
|
||||
<View
|
||||
style={[styles.list, hasSearch && styles.listWithSearch, { top: listTop, left: anchor.left, width: anchor.width }]}
|
||||
onLayout={(e) => setListHeight(e.nativeEvent.layout.height)}
|
||||
>
|
||||
{hasSearch && (
|
||||
<View style={styles.searchRow}>
|
||||
<Search color={colors.quietGray} size={16} />
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
placeholderTextColor={colors.quietGray}
|
||||
style={styles.searchInput}
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={colors.marigoldOrange} style={styles.loading} />
|
||||
) : options.length === 0 ? (
|
||||
<Text style={styles.empty}>{emptyLabel ?? t("common.noResultsFound")}</Text>
|
||||
) : visibleOptions.length === 0 ? (
|
||||
<Text style={styles.empty}>{emptyLabel && !query ? emptyLabel : t("common.noResultsFound")}</Text>
|
||||
) : (
|
||||
<ScrollView style={styles.scrollView}>
|
||||
{options.map((option) => (
|
||||
<Pressable key={option.id} style={styles.row} onPress={() => onSelect(option)}>
|
||||
<ScrollView style={styles.scrollView} keyboardShouldPersistTaps="handled">
|
||||
{visibleOptions.map((option) => (
|
||||
<Pressable key={option.id} style={styles.row} onPress={() => select(option)}>
|
||||
<Text style={getTypography(colors).body}>{option.name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
@@ -154,7 +230,18 @@ function makeStyles(colors: ColorTokens) {
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
listWithSearch: { maxHeight: MAX_LIST_HEIGHT + SEARCH_HEIGHT },
|
||||
scrollView: { maxHeight: MAX_LIST_HEIGHT },
|
||||
searchRow: {
|
||||
height: SEARCH_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingHorizontal: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.hairlineGray,
|
||||
},
|
||||
searchInput: { flex: 1, height: SEARCH_HEIGHT, fontFamily: fontFamily.regular, fontSize: 14, color: colors.ink },
|
||||
row: { paddingHorizontal: 14, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
loading: { padding: 16 },
|
||||
empty: { ...typography.body, color: colors.quietGray, padding: 16 },
|
||||
|
||||
@@ -3,6 +3,7 @@ import Svg, { Circle } from "react-native-svg";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../api/trpc";
|
||||
import { useActivePet } from "../context/ActivePetContext";
|
||||
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
|
||||
|
||||
type Props = {
|
||||
petId: string;
|
||||
@@ -10,29 +11,56 @@ type Props = {
|
||||
/** Avatar size in px — used to scale the paw outline (default: 32) */
|
||||
size?: number;
|
||||
onPress?: () => void;
|
||||
/** Nur Story-Leiste: eigene abgelaufene Storys (≤ 7 Tage) als grauer Ring. */
|
||||
includeOwnRecent?: boolean;
|
||||
/**
|
||||
* Owner with the PROFILE_RING perk (found.md): an unseen story's paw takes
|
||||
* the rank colour instead of orange, and without a story a plain ring in
|
||||
* the rank colour is drawn. Seen stories stay gray either way.
|
||||
*/
|
||||
owner?: ReferralOwnerFields | null;
|
||||
};
|
||||
|
||||
const RING_PAD = 8;
|
||||
// The two middle toe beans (cy 1, r 10) reach up to y = -9 in the 0–100
|
||||
// viewBox. Web renders the SVG with `overflow: visible`; react-native-svg
|
||||
// clips at the viewBox instead, which cut the paw off at the top (found.md
|
||||
// "Story Ring ist abgeschnitten"). So the viewBox starts BEAN_OVERHANG units
|
||||
// higher and the box grows by the same proportion.
|
||||
const BEAN_OVERHANG = 10;
|
||||
const UNSEEN_COLOR = "#f97316";
|
||||
const SEEN_COLOR = "#9ca3af";
|
||||
|
||||
/** Outer box of a PawRing for a given avatar size — lets siblings (StoryTray's "add" tile) line up with it. */
|
||||
export function getPawRingBox(size: number) {
|
||||
const width = size + RING_PAD * 2;
|
||||
const overhang = (width * BEAN_OVERHANG) / 100;
|
||||
return { width, height: width + overhang, paddingTop: overhang };
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of web's src/components/stories/PawRing.tsx — a paw-print outline
|
||||
* (one big circle + four "toe bean" circles) drawn with react-native-svg
|
||||
* instead of inline SVG markup. Orange = unseen stories, gray = all seen,
|
||||
* no ring = no active story (STORY-02).
|
||||
*/
|
||||
export function PawRing({ petId, children, size = 32, onPress }: Props) {
|
||||
export function PawRing({ petId, children, size = 32, onPress, includeOwnRecent, owner }: Props) {
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
|
||||
const { data = { hasStory: false, allSeen: false } } = useQuery(
|
||||
trpc.stories.hasActiveStory.queryOptions({ petId, viewerPetId: activePetId ?? undefined })
|
||||
trpc.stories.hasActiveStory.queryOptions({
|
||||
petId,
|
||||
viewerPetId: activePetId ?? undefined,
|
||||
...(includeOwnRecent ? { includeOwnRecent: true } : {}),
|
||||
})
|
||||
);
|
||||
const { hasStory, allSeen } = data;
|
||||
const ringColor = allSeen ? SEEN_COLOR : UNSEEN_COLOR;
|
||||
const rank = useOwnerRankWithPerk(owner, "PROFILE_RING");
|
||||
const ringColor = allSeen ? SEEN_COLOR : (rank?.color ?? UNSEEN_COLOR);
|
||||
const showRankRingOnly = !hasStory && !!rank;
|
||||
|
||||
const outerSize = size + RING_PAD * 2;
|
||||
const box = getPawRingBox(size);
|
||||
const strokeWidth = Math.max(4, size / 8);
|
||||
const beans = [
|
||||
{ cx: 13, cy: 10, r: 8 },
|
||||
@@ -42,13 +70,31 @@ export function PawRing({ petId, children, size = 32, onPress }: Props) {
|
||||
];
|
||||
|
||||
const content = (
|
||||
<View style={{ width: outerSize, height: outerSize, alignItems: "center", justifyContent: "center" }}>
|
||||
<View
|
||||
style={{
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
paddingTop: box.paddingTop,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{showRankRingOnly && (
|
||||
<Svg
|
||||
width={box.width}
|
||||
height={box.height}
|
||||
viewBox={`0 ${-BEAN_OVERHANG} 100 ${100 + BEAN_OVERHANG}`}
|
||||
style={{ position: "absolute", top: 0, left: 0 }}
|
||||
>
|
||||
<Circle cx={50} cy={50} r={44} fill="none" stroke={rank.color} strokeWidth={strokeWidth * 0.6} />
|
||||
</Svg>
|
||||
)}
|
||||
{hasStory && (
|
||||
<Svg
|
||||
width={outerSize}
|
||||
height={outerSize}
|
||||
viewBox="0 0 100 100"
|
||||
style={{ position: "absolute" }}
|
||||
width={box.width}
|
||||
height={box.height}
|
||||
viewBox={`0 ${-BEAN_OVERHANG} 100 ${100 + BEAN_OVERHANG}`}
|
||||
style={{ position: "absolute", top: 0, left: 0 }}
|
||||
>
|
||||
<Circle cx={50} cy={50} r={44} fill="none" stroke={ringColor} strokeWidth={strokeWidth} />
|
||||
{beans.map((b, i) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Image } from "expo-image";
|
||||
import { getAvatarUrl } from "../lib/media-url";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { fontFamily, type ColorTokens } from "../theme/tokens";
|
||||
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
|
||||
|
||||
const SIZE_PRESETS = { sm: 32, md: 40, lg: 56 } as const;
|
||||
|
||||
@@ -11,28 +12,60 @@ type Props = {
|
||||
name: string;
|
||||
avatarKey?: string | null;
|
||||
size?: keyof typeof SIZE_PRESETS;
|
||||
/**
|
||||
* The pet's owner (referralRankId/referralPerks, part of petIdentitySelect).
|
||||
* With the PROFILE_RING perk the avatar gets a ring in the rank colour.
|
||||
* Don't pass it when a PawRing wraps this avatar — PawRing draws that ring.
|
||||
*/
|
||||
owner?: ReferralOwnerFields | null;
|
||||
};
|
||||
|
||||
// Ring sits *outside* the avatar (absolute overlay) so ranked and unranked
|
||||
// avatars keep the same footprint and list rows don't shift.
|
||||
const RING_WIDTH = { sm: 2, md: 2, lg: 3 } as const;
|
||||
const RING_GAP = 2;
|
||||
|
||||
/**
|
||||
* Port of web's src/components/ui/pet-avatar.tsx — image if the pet has one,
|
||||
* otherwise a colored initial fallback.
|
||||
*/
|
||||
export function PetAvatar({ name, avatarKey, size = "md" }: Props) {
|
||||
export function PetAvatar({ name, avatarKey, size = "md", owner }: Props) {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const rank = useOwnerRankWithPerk(owner, "PROFILE_RING");
|
||||
const px = SIZE_PRESETS[size];
|
||||
const uri = getAvatarUrl(avatarKey);
|
||||
const dimensionStyle = { width: px, height: px, borderRadius: px / 2 };
|
||||
|
||||
if (uri) {
|
||||
return <Image source={{ uri }} style={dimensionStyle} contentFit="cover" transition={150} />;
|
||||
}
|
||||
|
||||
return (
|
||||
const avatar = uri ? (
|
||||
<Image source={{ uri }} style={dimensionStyle} contentFit="cover" transition={150} />
|
||||
) : (
|
||||
<View style={[styles.fallback, dimensionStyle]}>
|
||||
<Text style={[styles.fallbackText, { fontSize: px * 0.4 }]}>{name.charAt(0).toUpperCase()}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!rank) return avatar;
|
||||
|
||||
const offset = RING_GAP + RING_WIDTH[size];
|
||||
return (
|
||||
<View style={{ width: px, height: px }}>
|
||||
{avatar}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -offset,
|
||||
left: -offset,
|
||||
width: px + offset * 2,
|
||||
height: px + offset * 2,
|
||||
borderRadius: (px + offset * 2) / 2,
|
||||
borderWidth: RING_WIDTH[size],
|
||||
borderColor: rank.color,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import { BadgeCheck, Sparkles } from "lucide-react-native";
|
||||
import { BadgeCheck, Ribbon, Sparkles, Sprout } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isActiveSponsor } from "../lib/sponsor";
|
||||
import { colors } from "../theme/tokens";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { ReferralIcon } from "../lib/referral-icons";
|
||||
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
|
||||
|
||||
type Props = {
|
||||
isVerified?: boolean;
|
||||
sponsor?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
|
||||
/** Eines der ersten 250 Tiere (Pet.isFoundingMember). */
|
||||
isFoundingMember?: boolean;
|
||||
/** Gedenkmodus „In Erinnerung“ (Pet.deceasedAt gesetzt). */
|
||||
isDeceased?: boolean;
|
||||
sponsor?: { sponsorSince?: Date | string | null; sponsorPeriodEnd?: Date | string | null } | null;
|
||||
/** Owner-Felder referralRankId/referralPerks — Rang-Abzeichen des Empfehlungsprogramms. */
|
||||
referral?: ReferralOwnerFields | null;
|
||||
};
|
||||
|
||||
/** Port of web's src/components/ui/pet-identity-badges.tsx. */
|
||||
export function PetIdentityBadges({ isVerified, sponsor }: Props) {
|
||||
const FOUNDING_COLOR = "#059669"; // emerald-600, wie im Web
|
||||
|
||||
export function PetIdentityBadges({ isVerified, isFoundingMember, isDeceased, sponsor, referral }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors: theme } = useTheme();
|
||||
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
|
||||
if (!isVerified && !showSponsor) return null;
|
||||
const rank = useOwnerRankWithPerk(referral, "NAME_BADGE");
|
||||
if (!isDeceased && !isVerified && !isFoundingMember && !showSponsor && !rank) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{isDeceased && <Ribbon size={14} color={theme.ink} fill={theme.ink} accessibilityLabel={t("inMemory.label")} />}
|
||||
{isVerified && <BadgeCheck size={14} color={colors.marigoldOrange} />}
|
||||
{showSponsor && <Sparkles size={14} color={colors.marigoldOrange} />}
|
||||
{isFoundingMember && <Sprout size={14} color={FOUNDING_COLOR} accessibilityLabel={t("foundingMember.label")} />}
|
||||
{rank && <ReferralIcon icon={rank.icon} size={14} color={rank.color} fill={rank.color} accessibilityLabel={rank.name} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ import { PawBackButton } from "./PawBackButton";
|
||||
import { RepostCard } from "./RepostCard";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
import { ReportSheet } from "./ReportSheet";
|
||||
import { useBlockPet } from "../hooks/useBlockPet";
|
||||
import { getMediaUrl } from "../lib/media-url";
|
||||
import { formatPetName, formatRelativeTime } from "../lib/format";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
|
||||
import type { ReferralOwnerFields } from "../hooks/useReferralRanks";
|
||||
|
||||
/**
|
||||
* Structural rather than imported from a single router's inferred output:
|
||||
@@ -38,7 +40,7 @@ type PostCardOriginalPost = {
|
||||
type?: string | null;
|
||||
images: PostCardImage[];
|
||||
milestone?: unknown;
|
||||
pet: { id: string; name: string; avatarKey?: string | null };
|
||||
pet: { id: string; name: string; avatarKey?: string | null; owner?: ReferralOwnerFields | null };
|
||||
};
|
||||
|
||||
export type PostCardPost = {
|
||||
@@ -58,7 +60,16 @@ export type PostCardPost = {
|
||||
nickname?: string | null;
|
||||
avatarKey?: string | null;
|
||||
isVerified?: boolean;
|
||||
owner?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
|
||||
deceasedAt?: 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?: {
|
||||
sponsorSince?: Date | string | null;
|
||||
sponsorPeriodEnd?: Date | string | null;
|
||||
referralRankId?: string | null;
|
||||
referralPerks?: string[] | null;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -90,6 +101,7 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
|
||||
const cardWidth = width - 32; // matches the 16px screen padding used by the feed list
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const isOwnPost = post.pet.id === actingPetId;
|
||||
const { confirmBlock } = useBlockPet();
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
trpc.posts.delete.mutationOptions({
|
||||
@@ -119,7 +131,17 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
|
||||
{ text: t("common.delete"), style: "destructive", onPress: () => deleteMutation.mutate({ postId: post.id }) },
|
||||
]);
|
||||
} else {
|
||||
setReportOpen(true);
|
||||
// Report + block (App Store Guideline 1.2), matching web PostCard's
|
||||
// "..." menu for other pets' posts.
|
||||
Alert.alert(formatPetName(post.pet), undefined, [
|
||||
{ text: t("report.title"), onPress: () => setReportOpen(true) },
|
||||
{
|
||||
text: t("block.menuItem", { petName: post.pet.name }),
|
||||
style: "destructive",
|
||||
onPress: () => confirmBlock(actingPetId, { id: post.pet.id, name: post.pet.name }),
|
||||
},
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,11 +149,11 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
|
||||
<View style={styles.card}>
|
||||
<View style={styles.headerRow}>
|
||||
<Pressable style={styles.header} onPress={() => router.push(`/pets/${post.pet.id}`)}>
|
||||
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} />
|
||||
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} owner={post.pet.owner} />
|
||||
<View style={styles.headerText}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
|
||||
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} />
|
||||
<PetIdentityBadges isDeceased={!!post.pet.deceasedAt} isVerified={post.pet.isVerified} isFoundingMember={post.pet.isFoundingMember} sponsor={post.pet.owner} referral={post.pet.owner} />
|
||||
{isTrending && (
|
||||
<View style={styles.trendingBadge}>
|
||||
<Flame size={11} color={colors.marigoldDeep} fill={colors.marigoldDeep} />
|
||||
|
||||
@@ -45,7 +45,7 @@ export function RepostCard({ post }: Props) {
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={styles.header} onPress={() => router.push(`/pets/${origPet.id}`)}>
|
||||
<PetAvatar name={origPet.name} avatarKey={origPet.avatarKey} size="sm" />
|
||||
<PetAvatar name={origPet.name} avatarKey={origPet.avatarKey} size="sm" owner={origPet.owner} />
|
||||
<Text style={styles.name}>{origPet.name}</Text>
|
||||
</Pressable>
|
||||
|
||||
|
||||
@@ -7,28 +7,42 @@ import { PlusCircle } from "lucide-react-native";
|
||||
import { useTRPC } from "../api/trpc";
|
||||
import { useActivePet } from "../context/ActivePetContext";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { PawRing } from "./PawRing";
|
||||
import { PawRing, getPawRingBox } from "./PawRing";
|
||||
import { PetAvatar } from "./PetAvatar";
|
||||
import { fontFamily, type ColorTokens } from "../theme/tokens";
|
||||
|
||||
const AVATAR_SIZE = 56;
|
||||
const RING_BOX = getPawRingBox(AVATAR_SIZE);
|
||||
|
||||
/**
|
||||
* Port of web's src/components/stories/StoryTray.tsx: an "add story" tile
|
||||
* for the active pet, then followed pets with an active story (each in a
|
||||
* PawRing). No viewer list, reactions, or comments on stories yet (Gitea
|
||||
* for the active pet, then the active pet's own story while it has one
|
||||
* ("Deine Story" — the followed-pets query never includes the viewer's own
|
||||
* pet), then followed pets with an active story (each in a PawRing). No viewer list, reactions, or comments on stories yet (Gitea
|
||||
* #29 on web) — that's a fast-follow, matching how earlier phases trimmed
|
||||
* AI-disclosure/mentions from ported forms. Photo stories only, no video
|
||||
* story upload/playback (mirrors mobile's post-creation scope decision).
|
||||
* AI-disclosure/mentions from ported forms. Photo and video stories both
|
||||
* play back the same way here — see story-viewer.tsx for the branch.
|
||||
*/
|
||||
export function StoryTray() {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const router = useRouter();
|
||||
const { activePetId } = useActivePet();
|
||||
const { activePetId, pets } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
const { data: ownStory } = useQuery({
|
||||
...trpc.stories.hasActiveStory.queryOptions({ petId: activePetId ?? "", includeOwnRecent: true }),
|
||||
enabled: !!activePetId,
|
||||
});
|
||||
const activePet = pets.find((pet) => pet.id === activePetId) ?? null;
|
||||
|
||||
const openViewer = (pet: { id: string; name: string; avatarKey?: string | null }) =>
|
||||
router.push({
|
||||
pathname: "/story-viewer",
|
||||
params: { petId: pet.id, petName: pet.name, petAvatarKey: pet.avatarKey ?? "" },
|
||||
});
|
||||
|
||||
const { data: followedWithStories = [] } = useQuery({
|
||||
...trpc.follows.getPetsWithActiveStories.queryOptions({ followerPetId: activePetId ?? "" }),
|
||||
enabled: !!activePetId,
|
||||
@@ -37,26 +51,30 @@ export function StoryTray() {
|
||||
return (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
|
||||
<Pressable style={styles.item} onPress={() => router.push("/story-new")}>
|
||||
<View style={styles.addButton}>
|
||||
<PlusCircle color={colors.pureWhite} size={26} />
|
||||
<View style={styles.addButtonBox}>
|
||||
<View style={styles.addButton}>
|
||||
<PlusCircle color={colors.pureWhite} size={26} />
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{t("feed.storyLabel")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{ownStory?.hasStory && activePet && (
|
||||
<Pressable style={styles.item} onPress={() => openViewer(activePet)}>
|
||||
<PawRing petId={activePet.id} size={AVATAR_SIZE} includeOwnRecent owner={activePet.owner}>
|
||||
<PetAvatar name={activePet.name} avatarKey={activePet.avatarKey} size="lg" />
|
||||
</PawRing>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{t("feed.yourStory")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{followedWithStories.map((pet) => (
|
||||
<Pressable
|
||||
key={pet.id}
|
||||
style={styles.item}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/story-viewer",
|
||||
params: { petId: pet.id, petName: pet.name, petAvatarKey: pet.avatarKey ?? "" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<PawRing petId={pet.id} size={AVATAR_SIZE}>
|
||||
<Pressable key={pet.id} style={styles.item} onPress={() => openViewer(pet)}>
|
||||
<PawRing petId={pet.id} size={AVATAR_SIZE} owner={pet.owner}>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
</PawRing>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
@@ -71,7 +89,15 @@ export function StoryTray() {
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
row: { gap: 16, paddingHorizontal: 16, paddingVertical: 12 },
|
||||
item: { alignItems: "center", gap: 4, width: 72 },
|
||||
item: { alignItems: "center", gap: 4, width: RING_BOX.width },
|
||||
// Same box as a PawRing so the "add" tile lines up with the story rings.
|
||||
addButtonBox: {
|
||||
width: RING_BOX.width,
|
||||
height: RING_BOX.height,
|
||||
paddingTop: RING_BOX.paddingTop,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
addButton: {
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
|
||||
@@ -6,10 +6,14 @@ import { Plus, Trash2 } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
import { addYearsToDisplayDate, displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
|
||||
/** Übliche Auffrisch-Intervalle (z. B. Tollwut 1–3 Jahre) als Schnellauswahl für den Folgetermin. */
|
||||
const NEXT_DUE_PRESET_YEARS = [1, 3];
|
||||
|
||||
/** Port of web's health.ts vaccine CRUD — see health/page.tsx's Impfungen tab. */
|
||||
export function VaccineSection({ petId }: { petId: string }) {
|
||||
@@ -80,8 +84,24 @@ export function VaccineSection({ petId }: { petId: string }) {
|
||||
{showForm && (
|
||||
<Card style={styles.form}>
|
||||
<TextField placeholder={t("health.vaccineNamePlaceholder")} value={name} onChangeText={setName} maxLength={100} />
|
||||
<TextField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
|
||||
<TextField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
|
||||
<DateField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
|
||||
<DateField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
|
||||
<View style={styles.presetRow}>
|
||||
{NEXT_DUE_PRESET_YEARS.map((years) => {
|
||||
const suggestion = addYearsToDisplayDate(dateGiven, years);
|
||||
return (
|
||||
<Pressable
|
||||
key={years}
|
||||
style={[styles.preset, !suggestion && styles.presetDisabled]}
|
||||
disabled={!suggestion}
|
||||
onPress={() => suggestion && setNextDueDate(suggestion)}
|
||||
>
|
||||
<Text style={styles.presetText}>{t("health.vaccineNextDuePreset", { count: years })}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Text style={styles.hint}>{t("health.vaccineReminderHint")}</Text>
|
||||
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
|
||||
<Button
|
||||
title={addMutation.isPending ? "…" : t("common.save")}
|
||||
@@ -134,5 +154,10 @@ function makeStyles(colors: ColorTokens) {
|
||||
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
overdue: { color: colors.alertRed },
|
||||
presetRow: { flexDirection: "row", gap: 8 },
|
||||
preset: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: colors.marigoldTint },
|
||||
presetDisabled: { opacity: 0.4 },
|
||||
presetText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray },
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Plus, Trash2 } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
@@ -72,7 +73,7 @@ export function VetVisitSection({ petId }: { petId: string }) {
|
||||
|
||||
{showForm && (
|
||||
<Card style={styles.form}>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
<TextField placeholder={t("health.vetReasonPlaceholder")} value={reason} onChangeText={setReason} maxLength={100} />
|
||||
<TextField placeholder={t("health.vetNamePlaceholder")} value={vetName} onChangeText={setVetName} maxLength={100} />
|
||||
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={500} multiline />
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, Pressable, StyleSheet, type LayoutChangeEvent } from "react-native";
|
||||
import Svg, { Path, Line, Circle, Text as SvgText, Defs, LinearGradient, Stop } from "react-native-svg";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { Card } from "../Card";
|
||||
import { getTypography, fontFamily, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { buildWeightChart, nearestPointIndex, type WeightEntry } from "../../lib/weight-chart";
|
||||
|
||||
const CHART_HEIGHT = 170;
|
||||
const BOX = { left: 40, right: 12, top: 12, bottom: 26 };
|
||||
const AXIS_FONT_SIZE = 10;
|
||||
const MAX_LABELED_Y_TICKS = 5;
|
||||
const MARKER_RADIUS = 4;
|
||||
const SELECTED_RADIUS = 6;
|
||||
const LONG_SPAN_MS = 300 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Weight history (found.md: Gewichtsdaten als Diagramm) — port of web's
|
||||
* src/components/health/WeightChart.tsx. Tap anywhere on the plot to select
|
||||
* the nearest weigh-in; its value and date show in the header (no floating
|
||||
* tooltip to collide with the edges on a phone). The newest entry is
|
||||
* selected by default. The entry list below the chart is the table view.
|
||||
*/
|
||||
export function WeightChart({ entries }: { entries: WeightEntry[] }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const chart = useMemo(
|
||||
() => (width > 0 ? buildWeightChart(entries, { width, height: CHART_HEIGHT, ...BOX }) : null),
|
||||
[entries, width]
|
||||
);
|
||||
|
||||
const formatKg = (kg: number, maximumFractionDigits = 2) =>
|
||||
`${kg.toLocaleString(i18n.language, { maximumFractionDigits })} kg`;
|
||||
const formatDate = (time: number, options: Intl.DateTimeFormatOptions) =>
|
||||
new Date(time).toLocaleDateString(i18n.language, { timeZone: "UTC", ...options });
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => setWidth(Math.round(e.nativeEvent.layout.width));
|
||||
|
||||
if (entries.length < 2) return null;
|
||||
|
||||
const points = chart?.points ?? [];
|
||||
const activeIndex = selectedIndex !== null && selectedIndex < points.length ? selectedIndex : points.length - 1;
|
||||
const active = points[activeIndex];
|
||||
const delta = chart?.deltaKg ?? 0;
|
||||
const deltaLabel = `${delta > 0 ? "+" : delta < 0 ? "−" : "±"}${formatKg(Math.abs(delta))}`;
|
||||
const spansLong = points.length > 1 && points[points.length - 1].time - points[0].time > LONG_SPAN_MS;
|
||||
const axisDateOptions: Intl.DateTimeFormatOptions = spansLong
|
||||
? { month: "short", year: "2-digit" }
|
||||
: { day: "2-digit", month: "2-digit" };
|
||||
|
||||
const summary =
|
||||
points.length > 1
|
||||
? t("health.weightChartA11y", {
|
||||
from: formatKg(points[0].kg),
|
||||
fromDate: formatDate(points[0].time, { dateStyle: "medium" }),
|
||||
to: formatKg(points[points.length - 1].kg),
|
||||
toDate: formatDate(points[points.length - 1].time, { dateStyle: "medium" }),
|
||||
})
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.eyebrow}>{t("health.weightChartTitle")}</Text>
|
||||
{active && (
|
||||
<Text style={styles.readout}>
|
||||
{formatKg(active.kg)}
|
||||
<Text style={styles.readoutDate}>{" "}{formatDate(active.time, { dateStyle: "medium" })}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.deltaChip}>
|
||||
<Text style={styles.deltaText}>{deltaLabel}</Text>
|
||||
<Text style={styles.deltaCaption}>{t("health.weightChartSinceFirst")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onLayout={onLayout}
|
||||
onPress={(e) => chart && setSelectedIndex(nearestPointIndex(chart.points, e.nativeEvent.locationX))}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={summary}
|
||||
style={styles.plot}
|
||||
>
|
||||
{chart && (
|
||||
<Svg width={width} height={CHART_HEIGHT}>
|
||||
<Defs>
|
||||
<LinearGradient id="weightArea" x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0" stopColor={colors.chartLine} stopOpacity={0.2} />
|
||||
<Stop offset="1" stopColor={colors.chartLine} stopOpacity={0} />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
|
||||
{chart.yTicks.map((tick, i) => (
|
||||
<Line
|
||||
key={`grid-${i}`}
|
||||
x1={BOX.left}
|
||||
x2={width - BOX.right}
|
||||
y1={tick.y}
|
||||
y2={tick.y}
|
||||
stroke={colors.hairlineGray}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
{chart.yTicks.map((tick, i) =>
|
||||
chart.yTicks.length <= MAX_LABELED_Y_TICKS || i % 2 === 0 ? (
|
||||
<SvgText
|
||||
key={`ylabel-${i}`}
|
||||
x={BOX.left - 6}
|
||||
y={tick.y + AXIS_FONT_SIZE / 3}
|
||||
fontSize={AXIS_FONT_SIZE}
|
||||
fontFamily={fontFamily.regular}
|
||||
fill={colors.quietGray}
|
||||
textAnchor="end"
|
||||
>
|
||||
{tick.kg.toLocaleString(i18n.language, {
|
||||
minimumFractionDigits: chart.yTickDecimals,
|
||||
maximumFractionDigits: chart.yTickDecimals,
|
||||
})}
|
||||
</SvgText>
|
||||
) : null
|
||||
)}
|
||||
|
||||
<Path d={chart.areaPath} fill="url(#weightArea)" />
|
||||
<Path
|
||||
d={chart.linePath}
|
||||
fill="none"
|
||||
stroke={colors.chartLine}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{active && (
|
||||
<Line
|
||||
x1={active.x}
|
||||
x2={active.x}
|
||||
y1={BOX.top}
|
||||
y2={chart.baselineY}
|
||||
stroke={colors.quietGray}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{chart.points.map((p, i) => (
|
||||
<Circle
|
||||
key={`pt-${i}`}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={i === activeIndex ? SELECTED_RADIUS : MARKER_RADIUS}
|
||||
fill={i === activeIndex ? colors.chartLine : colors.pureWhite}
|
||||
stroke={i === activeIndex ? colors.pureWhite : colors.chartLine}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
|
||||
{chart.xLabelIndices.map((i) => {
|
||||
const p = chart.points[i];
|
||||
const isFirst = i === chart.xLabelIndices[0] && p.x - BOX.left < 20;
|
||||
const isLast = i === chart.points.length - 1;
|
||||
return (
|
||||
<SvgText
|
||||
key={`xlabel-${i}`}
|
||||
x={p.x}
|
||||
y={CHART_HEIGHT - 6}
|
||||
fontSize={AXIS_FONT_SIZE}
|
||||
fontFamily={fontFamily.regular}
|
||||
fill={colors.quietGray}
|
||||
textAnchor={isLast ? "end" : isFirst ? "start" : "middle"}
|
||||
>
|
||||
{formatDate(p.time, axisDateOptions)}
|
||||
</SvgText>
|
||||
);
|
||||
})}
|
||||
</Svg>
|
||||
)}
|
||||
</Pressable>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
card: { gap: 8 },
|
||||
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", gap: 12 },
|
||||
eyebrow: { ...typography.label },
|
||||
readout: { ...typography.title, fontSize: 20, marginTop: 2 },
|
||||
readoutDate: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 12 },
|
||||
deltaChip: { alignItems: "flex-end" },
|
||||
deltaText: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
deltaCaption: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
plot: { height: CHART_HEIGHT },
|
||||
});
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import { Plus, Trash2 } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { WeightChart } from "./WeightChart";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
|
||||
@@ -65,7 +67,7 @@ export function WeightLogSection({ petId }: { petId: string }) {
|
||||
|
||||
{showForm && (
|
||||
<Card style={styles.form}>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
<TextField placeholder={t("health.weightKgPlaceholder")} value={weightKg} onChangeText={setWeightKg} keyboardType="decimal-pad" />
|
||||
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
|
||||
<Button
|
||||
@@ -81,20 +83,23 @@ export function WeightLogSection({ petId }: { petId: string }) {
|
||||
) : logs.length === 0 ? (
|
||||
<Text style={styles.empty}>{t("health.weightEmpty")}</Text>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<View key={log.id} style={styles.row}>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
|
||||
<Text style={styles.rowSecondary}>
|
||||
{new Date(log.date).toLocaleDateString(i18n.language)}
|
||||
{log.notes ? ` · ${log.notes}` : ""}
|
||||
</Text>
|
||||
<>
|
||||
<WeightChart entries={logs} />
|
||||
{logs.map((log) => (
|
||||
<View key={log.id} style={styles.row}>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
|
||||
<Text style={styles.rowSecondary}>
|
||||
{new Date(log.date).toLocaleDateString(i18n.language)}
|
||||
{log.notes ? ` · ${log.notes}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
|
||||
<Trash2 color={colors.quietGray} size={16} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
|
||||
<Trash2 color={colors.quietGray} size={16} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckCircle2 } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { Button } from "../Button";
|
||||
import { RedeemCodeForm } from "../referrals/RedeemCodeForm";
|
||||
import { radii, getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
const SOURCES = [
|
||||
"FRIENDS",
|
||||
"TIKTOK",
|
||||
"INSTAGRAM",
|
||||
"YOUTUBE",
|
||||
"FACEBOOK",
|
||||
"SHELTER_OR_PARTNER",
|
||||
"STORE_SEARCH",
|
||||
"PRESS",
|
||||
"OTHER",
|
||||
] as const;
|
||||
type Source = (typeof SOURCES)[number];
|
||||
|
||||
type Props = { canRedeem: boolean; onDone: () => void };
|
||||
|
||||
/**
|
||||
* Port of web's src/components/referrals/AcquisitionStep.tsx — freiwillige
|
||||
* Herkunftsangabe nach dem ersten Tier, bei „Freunde“ mit Code-Eingabe.
|
||||
* Kein Vorausfüllen wie im Web (dort per Cookie aus /r/<code>): nach dem
|
||||
* Store-Umweg gibt es in der App keinen Weg, den Link-Code mitzunehmen.
|
||||
*/
|
||||
export function AcquisitionStep({ canRedeem, onDone }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const trpc = useTRPC();
|
||||
const [source, setSource] = useState<Source | null>(null);
|
||||
const [inviterName, setInviterName] = useState<string | null | undefined>(undefined);
|
||||
|
||||
const saveSource = useMutation(trpc.referrals.setAcquisitionSource.mutationOptions({ onSettled: onDone }));
|
||||
|
||||
function handleContinue() {
|
||||
if (source) saveSource.mutate({ source });
|
||||
else onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{t("referrals.onboardingTitle")}</Text>
|
||||
<Text style={styles.subtitle}>{t("referrals.onboardingSubtitle")}</Text>
|
||||
|
||||
<View style={styles.chips} accessibilityRole="radiogroup">
|
||||
{SOURCES.map((option) => {
|
||||
const isSelected = source === option;
|
||||
return (
|
||||
<Pressable
|
||||
key={option}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ checked: isSelected }}
|
||||
onPress={() => setSource(option)}
|
||||
style={[styles.chip, isSelected && styles.chipSelected]}
|
||||
>
|
||||
<Text style={[styles.chipText, isSelected && styles.chipTextSelected]}>{t(`referrals.source_${option}`)}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{source === "FRIENDS" && canRedeem && (
|
||||
<View style={styles.redeemBox}>
|
||||
{inviterName !== undefined ? (
|
||||
<View style={styles.redeemedRow}>
|
||||
<CheckCircle2 size={18} color={colors.marigoldOrange} />
|
||||
<Text style={styles.redeemedText}>
|
||||
{inviterName ? t("referrals.redeemed", { name: inviterName }) : t("referrals.redeemedGeneric")}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<RedeemCodeForm onRedeemed={setInviterName} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Button title={saveSource.isPending ? "…" : t("common.continue")} disabled={saveSource.isPending} onPress={handleContinue} style={styles.cta} />
|
||||
<Button title={t("common.skip")} variant="outline" onPress={onDone} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { padding: 24, gap: 8 },
|
||||
title: { ...typography.title, fontSize: 18 },
|
||||
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 12 },
|
||||
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 12 },
|
||||
chip: { paddingHorizontal: 14, paddingVertical: 9, borderRadius: 999, borderWidth: 1, borderColor: colors.hairlineGray },
|
||||
chipSelected: { borderColor: colors.marigoldOrange, backgroundColor: colors.marigoldTint },
|
||||
chipText: { ...typography.body, fontSize: 14 },
|
||||
chipTextSelected: { color: colors.marigoldDeep, fontWeight: "600" },
|
||||
redeemBox: { padding: 14, borderRadius: radii.xl, borderWidth: 1, borderColor: colors.hairlineGray, marginBottom: 12 },
|
||||
redeemedRow: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
redeemedText: { ...typography.body, flex: 1 },
|
||||
cta: { marginTop: 4 },
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Bug, Check } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { Button } from "../Button";
|
||||
import { colors, radii, typography } from "../../theme/tokens";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
type Props = { onContinue: () => void };
|
||||
|
||||
@@ -16,6 +17,8 @@ type Props = { onContinue: () => void };
|
||||
*/
|
||||
export function ArachnophobiaOptIn({ onContinue }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const trpc = useTRPC();
|
||||
const [hideSpiders, setHideSpiders] = useState(false);
|
||||
|
||||
@@ -53,32 +56,35 @@ export function ArachnophobiaOptIn({ onContinue }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 24, gap: 4 },
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
|
||||
title: { ...typography.title, fontSize: 18 },
|
||||
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
|
||||
checkboxRow: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
padding: 14,
|
||||
borderRadius: radii.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
marginBottom: 16,
|
||||
},
|
||||
checkbox: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 5,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.hairlineGray,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 2,
|
||||
},
|
||||
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, marginBottom: 16 },
|
||||
cta: { marginTop: 4 },
|
||||
});
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { padding: 24, gap: 4 },
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
|
||||
title: { ...typography.title, fontSize: 18 },
|
||||
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
|
||||
checkboxRow: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
padding: 14,
|
||||
borderRadius: radii.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
marginBottom: 16,
|
||||
},
|
||||
checkbox: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 5,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.hairlineGray,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 2,
|
||||
},
|
||||
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, marginBottom: 16 },
|
||||
cta: { marginTop: 4 },
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -9,7 +9,8 @@ import { uploadImageToSupabase } from "../../lib/upload";
|
||||
import { resizeForUpload } from "../../lib/resize-image";
|
||||
import { PetAvatar } from "../PetAvatar";
|
||||
import { Button } from "../Button";
|
||||
import { colors, radii, typography } from "../../theme/tokens";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
type Props = {
|
||||
petId: string;
|
||||
@@ -26,6 +27,8 @@ type Props = {
|
||||
*/
|
||||
export function AvatarStep({ petId, petName, onDone }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const trpc = useTRPC();
|
||||
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
|
||||
const [uploadedKey, setUploadedKey] = useState<string | null>(null);
|
||||
@@ -120,36 +123,39 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 24, gap: 4, alignItems: "center" },
|
||||
title: { ...typography.title, fontSize: 18, textAlign: "center" },
|
||||
subtitle: { ...typography.body, color: colors.quietGray, textAlign: "center", marginBottom: 20 },
|
||||
previewWrap: { width: 96, height: 96, borderRadius: 48, overflow: "hidden", backgroundColor: colors.hairlineGray },
|
||||
preview: { width: "100%", height: "100%" },
|
||||
previewOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
error: { ...typography.body, color: colors.alertRed, marginTop: 12, textAlign: "center" },
|
||||
pickerRow: { flexDirection: "row", gap: 12, marginTop: 20, alignSelf: "stretch" },
|
||||
pickerButton: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
height: 48,
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
pickerText: { ...typography.body, color: colors.ink },
|
||||
cta: { marginTop: 20, alignSelf: "stretch" },
|
||||
skipLink: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray, marginTop: 12 },
|
||||
});
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { padding: 24, gap: 4, alignItems: "center" },
|
||||
title: { ...typography.title, fontSize: 18, textAlign: "center" },
|
||||
subtitle: { ...typography.body, color: colors.quietGray, textAlign: "center", marginBottom: 20 },
|
||||
previewWrap: { width: 96, height: 96, borderRadius: 48, overflow: "hidden", backgroundColor: colors.hairlineGray },
|
||||
preview: { width: "100%", height: "100%" },
|
||||
previewOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
error: { ...typography.body, color: colors.alertRed, marginTop: 12, textAlign: "center" },
|
||||
pickerRow: { flexDirection: "row", gap: 12, marginTop: 20, alignSelf: "stretch" },
|
||||
pickerButton: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
height: 48,
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
pickerText: { ...typography.body, color: colors.ink },
|
||||
cta: { marginTop: 20, alignSelf: "stretch" },
|
||||
skipLink: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray, marginTop: 12 },
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { View, Text, Pressable, ActivityIndicator, Linking, StyleSheet } from "react-native";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ScrollText } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { env } from "../../lib/env";
|
||||
import { Button } from "../Button";
|
||||
import { colors, radii, typography } from "../../theme/tokens";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
type Props = { onContinue: () => void };
|
||||
|
||||
@@ -18,6 +19,8 @@ type Props = { onContinue: () => void };
|
||||
*/
|
||||
export function OnboardingGuidelines({ onContinue }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -80,22 +83,25 @@ export function OnboardingGuidelines({ onContinue }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40 },
|
||||
container: { padding: 24, gap: 4 },
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
|
||||
title: { ...typography.title, fontSize: 18 },
|
||||
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
|
||||
card: {
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
borderRadius: radii.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
marginBottom: 16,
|
||||
},
|
||||
summary: { ...typography.body },
|
||||
link: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 8 },
|
||||
cta: { marginTop: 12 },
|
||||
});
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40 },
|
||||
container: { padding: 24, gap: 4 },
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
|
||||
title: { ...typography.title, fontSize: 18 },
|
||||
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
|
||||
card: {
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
borderRadius: radii.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
marginBottom: 16,
|
||||
},
|
||||
summary: { ...typography.body },
|
||||
link: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 8 },
|
||||
cta: { marginTop: 12 },
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { View, Text, Pressable, ActivityIndicator, Linking, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -6,7 +6,8 @@ import { ShieldCheck, Check } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { env } from "../../lib/env";
|
||||
import { Button } from "../Button";
|
||||
import { colors, radii, typography } from "../../theme/tokens";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
|
||||
type Props = { onContinue: () => void };
|
||||
|
||||
@@ -18,6 +19,8 @@ type Props = { onContinue: () => void };
|
||||
*/
|
||||
export function UploadConsent({ onContinue }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const [contentRightsChecked, setContentRightsChecked] = useState(false);
|
||||
@@ -85,6 +88,8 @@ export function UploadConsent({ onContinue }: Props) {
|
||||
}
|
||||
|
||||
function ConsentCheckbox({ checked, onToggle, label }: { checked: boolean; onToggle: () => void; label: string }) {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
return (
|
||||
<Pressable style={styles.checkboxRow} onPress={onToggle}>
|
||||
<View style={[styles.checkbox, checked && styles.checkboxChecked]}>
|
||||
@@ -95,33 +100,36 @@ function ConsentCheckbox({ checked, onToggle, label }: { checked: boolean; onTog
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40 },
|
||||
container: { padding: 24, gap: 4 },
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
|
||||
title: { ...typography.title, fontSize: 18 },
|
||||
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
|
||||
checkboxRow: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
padding: 14,
|
||||
borderRadius: radii.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
marginBottom: 12,
|
||||
},
|
||||
checkbox: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 5,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.hairlineGray,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 2,
|
||||
},
|
||||
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
|
||||
link: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 16, marginTop: 4 },
|
||||
cta: { marginTop: 4 },
|
||||
});
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40 },
|
||||
container: { padding: 24, gap: 4 },
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
|
||||
title: { ...typography.title, fontSize: 18 },
|
||||
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
|
||||
checkboxRow: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
padding: 14,
|
||||
borderRadius: radii.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
marginBottom: 12,
|
||||
},
|
||||
checkbox: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 5,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.hairlineGray,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 2,
|
||||
},
|
||||
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
|
||||
link: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 16, marginTop: 4 },
|
||||
cta: { marginTop: 4 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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"), [
|
||||
{ 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.label, textTransform: "none", letterSpacing: 0 },
|
||||
label: { ...typography.label, textTransform: "none", letterSpacing: 0, 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.label, textTransform: "none", letterSpacing: 0 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { ReferralIcon } from "../../lib/referral-icons";
|
||||
import type { ReferralRankView } from "../../hooks/useReferralRanks";
|
||||
import { radii, getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
ranks: ReferralRankView[];
|
||||
achievedRankIds: ReadonlySet<string>;
|
||||
qualifiedCount: number;
|
||||
nextRankId: string | null;
|
||||
};
|
||||
|
||||
/** Port of web's RankLadder — Fortschritt zum nächsten Rang plus alle aktiven Ränge. */
|
||||
export function RankLadder({ ranks, achievedRankIds, qualifiedCount, nextRankId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const activeRanks = ranks.filter((rank) => rank.isActive);
|
||||
const next = activeRanks.find((rank) => rank.id === nextRankId) ?? null;
|
||||
const progress = next ? Math.min(100, Math.round((qualifiedCount / next.threshold) * 100)) : 100;
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.heading}>{t("referrals.ranksHeading")}</Text>
|
||||
<Text style={styles.muted}>{t("referrals.countedInvites", { count: qualifiedCount })}</Text>
|
||||
</View>
|
||||
{next ? (
|
||||
<>
|
||||
<View style={styles.track} accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: next.threshold, now: qualifiedCount }}>
|
||||
<View style={[styles.fill, { width: `${progress}%`, backgroundColor: next.color }]} />
|
||||
</View>
|
||||
<Text style={styles.muted}>
|
||||
{t("referrals.nextRank", { count: Math.max(0, next.threshold - qualifiedCount), name: next.name })}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.muted}>{t("referrals.allRanksReached")}</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.list}>
|
||||
{activeRanks.map((rank) => {
|
||||
const isAchieved = achievedRankIds.has(rank.id);
|
||||
return (
|
||||
<View
|
||||
key={rank.id}
|
||||
style={[styles.row, isAchieved ? { borderColor: rank.color } : styles.rowPending]}
|
||||
>
|
||||
<View style={[styles.iconCircle, { borderColor: rank.color, backgroundColor: isAchieved ? rank.color : "transparent" }]}>
|
||||
<ReferralIcon icon={rank.icon} size={16} color={isAchieved ? "#ffffff" : rank.color} />
|
||||
</View>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rankName}>{rank.name}</Text>
|
||||
<Text style={styles.rankDescription}>{rank.description}</Text>
|
||||
</View>
|
||||
{isAchieved ? (
|
||||
<Check size={16} color={colors.marigoldOrange} accessibilityLabel={t("referrals.achieved")} />
|
||||
) : (
|
||||
<Text style={styles.muted}>{t("referrals.threshold", { count: rank.threshold })}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
card: { gap: 10, padding: 16, borderRadius: radii["2xl"], borderWidth: 1, borderColor: colors.hairlineGray, backgroundColor: colors.pureWhite },
|
||||
headerRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "baseline", gap: 8 },
|
||||
heading: { ...typography.title, fontSize: 16 },
|
||||
muted: { ...typography.body, fontSize: 13, color: colors.quietGray },
|
||||
track: { height: 8, borderRadius: 4, backgroundColor: colors.whisperGray, overflow: "hidden" },
|
||||
fill: { height: 8, borderRadius: 4 },
|
||||
list: { gap: 8, marginTop: 6 },
|
||||
row: { flexDirection: "row", alignItems: "center", gap: 12, padding: 10, borderRadius: radii.xl, borderWidth: 1 },
|
||||
rowPending: { borderColor: colors.hairlineGray, borderStyle: "dashed" },
|
||||
iconCircle: { width: 32, height: 32, borderRadius: 16, borderWidth: 2, alignItems: "center", justifyContent: "center" },
|
||||
rowText: { flex: 1 },
|
||||
rankName: { ...typography.body, fontWeight: "600" },
|
||||
rankDescription: { ...typography.body, fontSize: 12, color: colors.quietGray },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { Button } from "../Button";
|
||||
import { TextField } from "../TextField";
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
/** Fehler-Schlüssel aus REFERRAL_ERRORS (src/lib/referrals.ts im Web) → Übersetzungsschlüssel. */
|
||||
const ERROR_KEYS: Record<string, string> = {
|
||||
REFERRAL_PROGRAM_PAUSED: "referrals.errorPaused",
|
||||
REFERRAL_CODE_INVALID: "referrals.errorInvalid",
|
||||
REFERRAL_OWN_CODE: "referrals.errorOwnCode",
|
||||
REFERRAL_ALREADY_REDEEMED: "referrals.errorAlreadyRedeemed",
|
||||
REFERRAL_WINDOW_EXPIRED: "referrals.errorWindowExpired",
|
||||
REFERRAL_NO_PET: "referrals.errorNoPet",
|
||||
};
|
||||
|
||||
/** Port of web's RedeemCodeForm — genutzt im Onboarding und auf dem Einladen-Bildschirm. */
|
||||
export function RedeemCodeForm({ onRedeemed }: { onRedeemed?: (inviterPetName: string | null) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [code, setCode] = useState("");
|
||||
const [errorKey, setErrorKey] = useState<string | null>(null);
|
||||
|
||||
const redeem = useMutation(
|
||||
trpc.referrals.redeem.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setErrorKey(null);
|
||||
void qc.invalidateQueries({ queryKey: trpc.referrals.me.queryKey() });
|
||||
onRedeemed?.(result.inviterPetName);
|
||||
},
|
||||
onError: (err) => {
|
||||
const isRateLimited = err.data?.code === "TOO_MANY_REQUESTS";
|
||||
setErrorKey(isRateLimited ? "referrals.errorTooMany" : (ERROR_KEYS[err.message] ?? "referrals.errorGeneric"));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>{t("referrals.redeemLabel")}</Text>
|
||||
<TextField
|
||||
value={code}
|
||||
onChangeText={(value) => setCode(value.toUpperCase())}
|
||||
placeholder="BUBI-7K3M"
|
||||
autoCapitalize="characters"
|
||||
autoCorrect={false}
|
||||
maxLength={32}
|
||||
accessibilityLabel={t("referrals.redeemLabel")}
|
||||
/>
|
||||
{errorKey && <Text style={styles.error}>{t(errorKey)}</Text>}
|
||||
<Button
|
||||
title={redeem.isPending ? "…" : t("referrals.redeemCta")}
|
||||
disabled={!code.trim() || redeem.isPending}
|
||||
onPress={() => redeem.mutate({ code: code.trim() })}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { gap: 8 },
|
||||
label: { ...typography.body, fontWeight: "600" },
|
||||
error: { ...typography.body, fontSize: 13, color: colors.alertRed },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../../hooks/useReferralRanks";
|
||||
|
||||
const RING_WIDTH = 3;
|
||||
const RING_GAP = 2;
|
||||
|
||||
/**
|
||||
* Port of web's ReferralAvatarRing — Ring in der Rangfarbe um das Tierbild
|
||||
* im Profilkopf (Vorteil PROFILE_RING). Nur im Profil, nicht im Feed: dort
|
||||
* steht der Ring (PawRing) für Storys.
|
||||
*/
|
||||
export function ReferralAvatarRing({ owner, children }: { owner: ReferralOwnerFields | null | undefined; children: ReactNode }) {
|
||||
const rank = useOwnerRankWithPerk(owner, "PROFILE_RING");
|
||||
if (!rank) return <>{children}</>;
|
||||
return (
|
||||
<View style={{ borderWidth: RING_WIDTH, borderColor: rank.color, borderRadius: 999, padding: RING_GAP }}>{children}</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, Text, StyleSheet } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
|
||||
/**
|
||||
* Entry point to app/(app)/delete-account.tsx. Deliberately a quiet text
|
||||
* link below Log out (like web's DeleteAccountDialog trigger) rather than a
|
||||
* prominent button — it has to be findable (App Store Guideline 5.1.1(v)),
|
||||
* not inviting.
|
||||
*/
|
||||
export function DeleteAccountSection() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
return (
|
||||
<Pressable onPress={() => router.push("/delete-account")} hitSlop={8} style={styles.row}>
|
||||
<Text style={styles.text}>{t("deleteAccount.trigger")}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
row: { alignItems: "center", paddingVertical: 4 },
|
||||
text: { ...typography.body, fontSize: 13, color: colors.alertRed },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight, Gift } from "lucide-react-native";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
||||
|
||||
/** Einstieg zum Empfehlungsprogramm (Bildschirm /invite) aus den Einstellungen. */
|
||||
export function InviteFriendsSection() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>{t("referrals.title")}</Text>
|
||||
<Pressable style={styles.row} onPress={() => router.push("/invite")} accessibilityRole="button">
|
||||
<Gift size={20} color={colors.marigoldOrange} />
|
||||
<Text style={styles.label}>{t("referrals.settingsLabel")}</Text>
|
||||
<ChevronRight size={18} color={colors.quietGray} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
sectionTitle: sectionTitleStyle(colors),
|
||||
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 6 },
|
||||
label: { ...typography.body, flex: 1 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Send, Trash2, X } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { PetAvatar } from "../PetAvatar";
|
||||
import { TextField } from "../TextField";
|
||||
import { formatPetName, formatRelativeTime } from "../../lib/format";
|
||||
import { getTypography, radii, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
storyId: string;
|
||||
activePetId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
/** Wird nach Hinzufügen/Löschen aufgerufen, damit der Viewer den Zähler neu lädt. */
|
||||
onChanged: () => void;
|
||||
onPressPet: (petId: string) => void;
|
||||
/** Abgelaufene Story in der 7-Tage-Ansicht des Erstellers: nur lesen. */
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of web's StoryCommentsOverlay als Bottom-Sheet. Der Viewer pausiert die
|
||||
* Story, solange das Sheet offen ist (anders als im Web — auf dem Handy
|
||||
* verdeckt die Tastatur sonst die laufende Story und sie springt weiter,
|
||||
* während man noch tippt).
|
||||
*/
|
||||
export function StoryCommentsSheet({ storyId, activePetId, visible, onClose, onChanged, onPressPet, readOnly = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
const { data: comments = [], isLoading } = useQuery({
|
||||
...trpc.stories.listComments.queryOptions({ storyId }),
|
||||
enabled: visible,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
void qc.invalidateQueries({ queryKey: trpc.stories.listComments.queryKey({ storyId }) });
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const add = useMutation(
|
||||
trpc.stories.addComment.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setBody("");
|
||||
refresh();
|
||||
},
|
||||
})
|
||||
);
|
||||
const remove = useMutation(trpc.stories.deleteComment.mutationOptions({ onSuccess: refresh }));
|
||||
|
||||
const trimmed = body.trim();
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel={t("common.close")} />
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("storyViewer.commentsTitle")}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={12} accessibilityLabel={t("common.close")}>
|
||||
<X size={20} color={colors.ink} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<ActivityIndicator style={styles.loading} color={colors.marigoldOrange} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={comments}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
ListEmptyComponent={<Text style={styles.muted}>{t("comments.empty")}</Text>}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.row}>
|
||||
<Pressable onPress={() => onPressPet(item.pet.id)}>
|
||||
<PetAvatar name={item.pet.name} avatarKey={item.pet.avatarKey} size="sm" owner={item.pet.owner} />
|
||||
</Pressable>
|
||||
<View style={styles.rowBody}>
|
||||
<View style={styles.rowHeader}>
|
||||
<Text style={styles.petName} onPress={() => onPressPet(item.pet.id)}>
|
||||
{formatPetName(item.pet)}
|
||||
</Text>
|
||||
<Text style={styles.time}>{formatRelativeTime(item.createdAt)}</Text>
|
||||
</View>
|
||||
<Text style={styles.body}>{item.body}</Text>
|
||||
</View>
|
||||
{item.pet.id === activePetId && (
|
||||
<Pressable
|
||||
onPress={() => remove.mutate({ commentId: item.id, petId: activePetId })}
|
||||
hitSlop={8}
|
||||
accessibilityLabel={t("storyViewer.deleteComment")}
|
||||
>
|
||||
<Trash2 size={16} color={colors.quietGray} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{readOnly ? (
|
||||
<Text style={styles.readOnly}>{t("storyViewer.readOnlyExpired")}</Text>
|
||||
) : (
|
||||
<View style={styles.inputRow}>
|
||||
<TextField
|
||||
placeholder={t("comments.placeholder")}
|
||||
value={body}
|
||||
onChangeText={setBody}
|
||||
maxLength={500}
|
||||
style={styles.input}
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={() => trimmed && add.mutate({ storyId, petId: activePetId, body: trimmed })}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => add.mutate({ storyId, petId: activePetId, body: trimmed })}
|
||||
disabled={!trimmed || add.isPending}
|
||||
style={[styles.send, (!trimmed || add.isPending) && styles.sendDisabled]}
|
||||
accessibilityLabel={t("messages.send")}
|
||||
>
|
||||
{add.isPending ? <ActivityIndicator color="#ffffff" /> : <Send size={18} color="#ffffff" />}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
{add.isError && <Text style={styles.error}>{t("storyViewer.commentError")}</Text>}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens, insetBottom: number) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.35)" },
|
||||
sheet: {
|
||||
maxHeight: "65%",
|
||||
backgroundColor: colors.porchWhite,
|
||||
borderTopLeftRadius: radii["2xl"],
|
||||
borderTopRightRadius: radii["2xl"],
|
||||
paddingBottom: insetBottom + 12,
|
||||
},
|
||||
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 16, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
title: { ...typography.title, fontSize: 16 },
|
||||
loading: { padding: 24 },
|
||||
list: { padding: 16, gap: 14 },
|
||||
muted: { ...typography.body, color: colors.quietGray, textAlign: "center", paddingVertical: 16 },
|
||||
row: { flexDirection: "row", gap: 10, alignItems: "flex-start" },
|
||||
rowBody: { flex: 1, gap: 2 },
|
||||
rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 },
|
||||
petName: { ...typography.body, fontWeight: "600", fontSize: 13 },
|
||||
time: { ...typography.body, fontSize: 11, color: colors.quietGray },
|
||||
body: { ...typography.body },
|
||||
inputRow: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingTop: 10 },
|
||||
input: { flex: 1 },
|
||||
send: { width: 48, height: 48, borderRadius: radii.lg, backgroundColor: colors.marigoldOrange, alignItems: "center", justifyContent: "center" },
|
||||
sendDisabled: { opacity: 0.5 },
|
||||
readOnly: { ...typography.body, fontSize: 13, color: colors.quietGray, paddingHorizontal: 16, paddingTop: 10 },
|
||||
error: { ...typography.body, fontSize: 12, color: colors.alertRed, paddingHorizontal: 12, paddingTop: 6 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { PawPrint } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { colors } from "../../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
storyId: string;
|
||||
petId: string;
|
||||
initialCount: number;
|
||||
initialReacted: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of web's StoryPawButton — Pfote auf einer Story. Optimistisch: Zustand
|
||||
* und Zähler springen sofort um und werden bei einem Fehler zurückgesetzt.
|
||||
* Der Viewer setzt `key={story.id}`, damit der Zustand pro Story neu startet.
|
||||
*/
|
||||
export function StoryPawButton({ storyId, petId, initialCount, initialReacted }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const [reacted, setReacted] = useState(initialReacted);
|
||||
const [count, setCount] = useState(initialCount);
|
||||
|
||||
const toggle = useMutation(
|
||||
trpc.stories.toggleReaction.mutationOptions({
|
||||
onMutate: () => {
|
||||
const previous = { reacted, count };
|
||||
setReacted(!reacted);
|
||||
setCount(reacted ? Math.max(0, count - 1) : count + 1);
|
||||
return previous;
|
||||
},
|
||||
// Der Server ist maßgeblich: Zähler aus dem Stand vor dem Tippen + tatsächlicher Änderung.
|
||||
onSuccess: (data, _vars, previous) => {
|
||||
setReacted(data.reacted);
|
||||
if (!previous) return;
|
||||
const delta = data.reacted === previous.reacted ? 0 : data.reacted ? 1 : -1;
|
||||
setCount(Math.max(0, previous.count + delta));
|
||||
},
|
||||
onError: (_err, _vars, previous) => {
|
||||
if (previous) {
|
||||
setReacted(previous.reacted);
|
||||
setCount(previous.count);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !toggle.isPending && toggle.mutate({ storyId, petId })}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={reacted ? t("storyViewer.removePaw") : t("storyViewer.paw")}
|
||||
accessibilityState={{ selected: reacted }}
|
||||
hitSlop={8}
|
||||
style={styles.wrap}
|
||||
>
|
||||
<View style={styles.circle}>
|
||||
<PawPrint size={20} color={reacted ? colors.marigoldOrange : colors.pureWhite} fill={reacted ? colors.marigoldOrange : "transparent"} />
|
||||
</View>
|
||||
{count > 0 && <Text style={styles.count}>{count}</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { alignItems: "center", gap: 2 },
|
||||
circle: { width: 44, height: 44, borderRadius: 22, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
|
||||
count: { color: colors.pureWhite, fontSize: 12, fontWeight: "600", textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 3 },
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMemo } from "react";
|
||||
import { ActivityIndicator, FlatList, Modal, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { PetAvatar } from "../PetAvatar";
|
||||
import { formatPetName, formatRelativeTime } from "../../lib/format";
|
||||
import { getTypography, radii, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
storyId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onPressPet: (petId: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of web's owner-only viewer Sheet — wer hat diese Story gesehen.
|
||||
* stories.getViewers prüft serverseitig, dass nur der Owner der Story fragt.
|
||||
*/
|
||||
export function StoryViewersSheet({ storyId, visible, onClose, onPressPet }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
|
||||
const trpc = useTRPC();
|
||||
|
||||
const { data: viewers = [], isLoading } = useQuery({
|
||||
...trpc.stories.getViewers.queryOptions({ storyId }),
|
||||
enabled: visible,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel={t("common.close")} />
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("storyViewer.viewersTitle", { count: viewers.length })}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={12} accessibilityLabel={t("common.close")}>
|
||||
<X size={20} color={colors.ink} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator style={styles.loading} color={colors.marigoldOrange} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={viewers}
|
||||
keyExtractor={(item) => item.viewerPet.id}
|
||||
contentContainerStyle={styles.list}
|
||||
ListEmptyComponent={<Text style={styles.muted}>{t("storyViewer.noViewers")}</Text>}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.row} onPress={() => onPressPet(item.viewerPet.id)}>
|
||||
<PetAvatar name={item.viewerPet.name} avatarKey={item.viewerPet.avatarKey} size="sm" owner={item.viewerPet.owner} />
|
||||
<Text style={styles.name} numberOfLines={1}>{formatPetName(item.viewerPet)}</Text>
|
||||
<Text style={styles.time}>{formatRelativeTime(item.viewedAt)}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens, insetBottom: number) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.35)" },
|
||||
sheet: {
|
||||
maxHeight: "60%",
|
||||
backgroundColor: colors.porchWhite,
|
||||
borderTopLeftRadius: radii["2xl"],
|
||||
borderTopRightRadius: radii["2xl"],
|
||||
paddingBottom: insetBottom + 12,
|
||||
},
|
||||
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 16, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
title: { ...typography.title, fontSize: 16 },
|
||||
loading: { padding: 24 },
|
||||
list: { padding: 16, gap: 12 },
|
||||
muted: { ...typography.body, color: colors.quietGray, textAlign: "center", paddingVertical: 16 },
|
||||
row: { flexDirection: "row", alignItems: "center", gap: 10 },
|
||||
name: { ...typography.body, flex: 1, fontWeight: "600" },
|
||||
time: { ...typography.body, fontSize: 11, color: colors.quietGray },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { PanResponder, View, type GestureResponderEvent, type PanResponderGestureState, type PanResponderInstance } from "react-native";
|
||||
import { OverlayContent } from "./OverlayContent";
|
||||
import { PositionedOverlay } from "./StoryOverlayLayer";
|
||||
import { angleDelta, applyGesture, touchGeometry, type Rect, type StoryOverlay } from "../../lib/story-overlays";
|
||||
|
||||
const TAP_MAX_MOVE = 6;
|
||||
const TAP_MAX_MS = 250;
|
||||
|
||||
type Callbacks = {
|
||||
overlay: StoryOverlay;
|
||||
rect: Rect;
|
||||
onChange: (next: StoryOverlay) => void;
|
||||
onTap: () => void;
|
||||
/** Ziehen beginnt/läuft/endet — der Editor zeigt dann den Papierkorb. */
|
||||
onDragStateChange: (dragging: boolean, pageY: number) => void;
|
||||
/** Loslassen über dem Papierkorb? (pageY des Fingers) */
|
||||
isOverTrash: (pageY: number) => boolean;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
type Props = Callbacks & { mentionName?: string };
|
||||
|
||||
/**
|
||||
* Ein verschiebbares Overlay im Story-Editor. Gesten mit React-Natives
|
||||
* eingebautem PanResponder statt gesture-handler/reanimated: Die sind zwar
|
||||
* über expo-router installiert, aber ob sie im Store-Build 1.0.6 nativ stecken,
|
||||
* ist nicht sicher belegt — ein OTA-Update mit ihrem Import würde dann beim
|
||||
* Start abstürzen. PanResponder ist RN-Core und damit OTA-sicher.
|
||||
*
|
||||
* 1 Finger: verschieben · 2 Finger: zoomen + drehen (+ verschieben) ·
|
||||
* kurz tippen: bearbeiten · auf den Papierkorb ziehen: löschen.
|
||||
*/
|
||||
export function EditableOverlay({ mentionName, ...callbacks }: Props) {
|
||||
// Die Gesten-Handler leben über Renders hinweg und lesen den aktuellen Stand
|
||||
// erst beim Berühren aus diesem Ref (klassisches PanResponder-Muster).
|
||||
const latest = useRef<Callbacks>(callbacks);
|
||||
useLayoutEffect(() => {
|
||||
latest.current = callbacks;
|
||||
});
|
||||
const getLatest = useCallback(() => latest.current, []);
|
||||
// Die Fabrik legt nur Handler an; gelesen wird der Ref erst bei Berührung, nie beim Rendern.
|
||||
// eslint-disable-next-line react-hooks/refs -- PanResponder-Muster, siehe createOverlayResponder
|
||||
const [responder] = useState(() => createOverlayResponder(getLatest));
|
||||
|
||||
return (
|
||||
<PositionedOverlay overlay={callbacks.overlay} rect={callbacks.rect} interactive>
|
||||
<View {...responder.panHandlers} hitSlop={12}>
|
||||
<OverlayContent overlay={callbacks.overlay} baseWidth={callbacks.rect.width} mentionName={mentionName} />
|
||||
</View>
|
||||
</PositionedOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestenlogik außerhalb der Komponente: hält ihren Zustand in der Closure und
|
||||
* liest Props nur in Event-Handlern (nie während des Renderns).
|
||||
*/
|
||||
function createOverlayResponder(getLatest: () => Callbacks): PanResponderInstance {
|
||||
const g = {
|
||||
base: null as StoryOverlay | null,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
touches: 0,
|
||||
startDistance: 1,
|
||||
startAngle: 0,
|
||||
startCentroid: { x: 0, y: 0 },
|
||||
startedAt: 0,
|
||||
moved: 0,
|
||||
};
|
||||
|
||||
// Fingerzahl geändert → neue Ausgangslage, sonst springt das Overlay.
|
||||
function rebase(evt: GestureResponderEvent, state: Pick<PanResponderGestureState, "dx" | "dy">) {
|
||||
const touches = evt.nativeEvent.touches;
|
||||
g.base = getLatest().overlay;
|
||||
g.offsetX = state.dx;
|
||||
g.offsetY = state.dy;
|
||||
g.touches = touches.length;
|
||||
if (touches.length >= 2) {
|
||||
const geo = touchGeometry(touches[0], touches[1]);
|
||||
g.startDistance = geo.distance;
|
||||
g.startAngle = geo.angle;
|
||||
g.startCentroid = { x: (touches[0].pageX + touches[1].pageX) / 2, y: (touches[0].pageY + touches[1].pageY) / 2 };
|
||||
}
|
||||
}
|
||||
|
||||
function finish(evt: GestureResponderEvent) {
|
||||
const { pageY } = evt.nativeEvent;
|
||||
getLatest().onDragStateChange(false, pageY);
|
||||
if (g.moved <= TAP_MAX_MOVE && Date.now() - g.startedAt <= TAP_MAX_MS) {
|
||||
getLatest().onTap();
|
||||
} else if (getLatest().isOverTrash(pageY)) {
|
||||
getLatest().onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
return PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderTerminationRequest: () => false,
|
||||
onPanResponderGrant: (evt) => {
|
||||
g.startedAt = Date.now();
|
||||
g.moved = 0;
|
||||
rebase(evt, { dx: 0, dy: 0 });
|
||||
getLatest().onDragStateChange(true, evt.nativeEvent.pageY);
|
||||
},
|
||||
onPanResponderMove: (evt, state) => {
|
||||
const touches = evt.nativeEvent.touches;
|
||||
if (touches.length !== g.touches) rebase(evt, state);
|
||||
g.moved = Math.max(g.moved, Math.hypot(state.dx, state.dy));
|
||||
|
||||
let pinch = 1;
|
||||
let rotationDelta = 0;
|
||||
let dx = state.dx - g.offsetX;
|
||||
let dy = state.dy - g.offsetY;
|
||||
if (touches.length >= 2 && g.startDistance > 0) {
|
||||
const now = touchGeometry(touches[0], touches[1]);
|
||||
pinch = now.distance / g.startDistance;
|
||||
rotationDelta = angleDelta(g.startAngle, now.angle);
|
||||
dx = (touches[0].pageX + touches[1].pageX) / 2 - g.startCentroid.x;
|
||||
dy = (touches[0].pageY + touches[1].pageY) / 2 - g.startCentroid.y;
|
||||
g.moved = Math.max(g.moved, TAP_MAX_MOVE + 1);
|
||||
}
|
||||
const base = g.base ?? getLatest().overlay;
|
||||
getLatest().onChange(applyGesture(base, getLatest().rect, { dx, dy, pinch, rotationDelta }));
|
||||
getLatest().onDragStateChange(true, evt.nativeEvent.pageY);
|
||||
},
|
||||
onPanResponderRelease: (evt) => finish(evt),
|
||||
onPanResponderTerminate: (evt) => finish(evt),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, Pressable, Image as RNImage, ActivityIndicator, Alert, StyleSheet, type LayoutChangeEvent } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useVideoPlayer, VideoView } from "expo-video";
|
||||
import { AtSign, Smile, Trash2, Type, X } from "lucide-react-native";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import {
|
||||
MAX_GIFS,
|
||||
MAX_MENTIONS,
|
||||
MAX_OVERLAYS,
|
||||
STORY_BACKGROUND_KEYS,
|
||||
STORY_BACKGROUNDS,
|
||||
TEXT_STORY_ASPECT,
|
||||
containRect,
|
||||
type StoryOverlay,
|
||||
type TextOverlay,
|
||||
} from "../../lib/story-overlays";
|
||||
import { EditableOverlay } from "./EditableOverlay";
|
||||
import { StoryBackground } from "./StoryBackground";
|
||||
import { TextEditorModal, type TextDraft } from "./TextEditorModal";
|
||||
import { MentionPicker } from "./MentionPicker";
|
||||
import { GifPicker } from "./GifPicker";
|
||||
import { StickerPicker } from "./StickerPicker";
|
||||
|
||||
export type EditorMedia =
|
||||
| { kind: "image"; uri: string; width: number; height: number }
|
||||
| { kind: "video"; uri: string; width: number; height: number; mimeType?: string }
|
||||
| { kind: "text" };
|
||||
|
||||
export type EditorResult = { overlays: StoryOverlay[]; background?: string; mentionNames: Record<string, string> };
|
||||
|
||||
type Picker = "text" | "mention" | "gif" | "sticker" | null;
|
||||
|
||||
const TRASH_ZONE_HEIGHT = 120;
|
||||
|
||||
/**
|
||||
* Story-Editor (feature/story-editor): Foto, Video oder Farbverlauf als Fläche,
|
||||
* darauf Text, @-Markierungen, GIFs und Sticker. Positionen werden relativ zur
|
||||
* Medienfläche gespeichert (lib/story-overlays.ts), damit sie im Viewer — App
|
||||
* und Web — an derselben Stelle sitzen.
|
||||
*/
|
||||
export function StoryEditor({
|
||||
media,
|
||||
petId,
|
||||
isSubmitting,
|
||||
submitLabel,
|
||||
progress,
|
||||
toolsEnabled = true,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
media: EditorMedia;
|
||||
/** Notschalter aus → nur Vorschau + Teilen, keine Werkzeuge (wie vor dem Editor). */
|
||||
toolsEnabled?: boolean;
|
||||
petId: string;
|
||||
isSubmitting: boolean;
|
||||
submitLabel: string;
|
||||
/** Upload-Fortschritt/Status unter dem Teilen-Knopf (Video). */
|
||||
progress?: React.ReactNode;
|
||||
onSubmit: (result: EditorResult) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [canvas, setCanvas] = useState({ width: 0, height: 0 });
|
||||
const [overlays, setOverlays] = useState<StoryOverlay[]>([]);
|
||||
const [background, setBackground] = useState<string>(STORY_BACKGROUND_KEYS[0]);
|
||||
const [mentionNames, setMentionNames] = useState<Record<string, string>>({});
|
||||
const [picker, setPicker] = useState<Picker>(media.kind === "text" ? "text" : null);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [overTrash, setOverTrash] = useState(false);
|
||||
const canvasTopRef = useRef(0);
|
||||
const canvasRef = useRef<View>(null);
|
||||
|
||||
const mediaSize = media.kind === "text" ? TEXT_STORY_ASPECT : { width: media.width, height: media.height };
|
||||
const rect = useMemo(
|
||||
() => containRect(canvas.width, canvas.height, mediaSize.width, mediaSize.height),
|
||||
[canvas.width, canvas.height, mediaSize.width, mediaSize.height]
|
||||
);
|
||||
|
||||
const onCanvasLayout = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setCanvas({ width, height });
|
||||
canvasRef.current?.measureInWindow((_x, y) => {
|
||||
canvasTopRef.current = y;
|
||||
});
|
||||
};
|
||||
|
||||
const trashTop = () => canvasTopRef.current + canvas.height - TRASH_ZONE_HEIGHT;
|
||||
const isOverTrash = useCallback((pageY: number) => pageY >= trashTop(), [canvas.height]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const gifCount = overlays.filter((o) => o.kind === "gif").length;
|
||||
const mentionCount = new Set(overlays.flatMap((o) => (o.kind === "mention" ? [o.petId] : []))).size;
|
||||
const isFull = overlays.length >= MAX_OVERLAYS;
|
||||
|
||||
const add = (overlay: StoryOverlay) => {
|
||||
if (isFull) {
|
||||
Alert.alert(t("storyEditor.limitOverlays", { max: MAX_OVERLAYS }));
|
||||
return;
|
||||
}
|
||||
setOverlays((list) => [...list, overlay]);
|
||||
};
|
||||
|
||||
// Neue Overlays erscheinen leicht versetzt in der Mitte, damit sie sich nicht genau überdecken.
|
||||
const spawnPosition = () => {
|
||||
const offset = (overlays.length % 5) * 0.04;
|
||||
return { x: 0.5, y: 0.42 + offset, scale: 1, rotation: 0 };
|
||||
};
|
||||
|
||||
const handleTextDone = (draft: TextDraft) => {
|
||||
const text = draft.text.trim();
|
||||
if (editingIndex !== null) {
|
||||
setOverlays((list) =>
|
||||
text
|
||||
? list.map((o, i) => (i === editingIndex && o.kind === "text" ? { ...o, ...draft, text } : o))
|
||||
: list.filter((_, i) => i !== editingIndex)
|
||||
);
|
||||
} else if (text) {
|
||||
add({ kind: "text", ...spawnPosition(), ...draft, text });
|
||||
}
|
||||
setEditingIndex(null);
|
||||
setPicker(null);
|
||||
};
|
||||
|
||||
const editingText = editingIndex !== null && overlays[editingIndex]?.kind === "text" ? (overlays[editingIndex] as TextOverlay) : undefined;
|
||||
const hasText = overlays.some((o) => o.kind === "text");
|
||||
const canSubmit = !isSubmitting && rect.width > 0 && (media.kind !== "text" || hasText);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View ref={canvasRef} style={styles.canvas} onLayout={onCanvasLayout}>
|
||||
{rect.width > 0 && (
|
||||
<View style={{ position: "absolute", left: rect.x, top: rect.y, width: rect.width, height: rect.height }}>
|
||||
{media.kind === "text" ? (
|
||||
<StoryBackground backgroundKey={background} width={rect.width} height={rect.height} />
|
||||
) : media.kind === "video" ? (
|
||||
<EditorVideo uri={media.uri} />
|
||||
) : (
|
||||
<RNImage source={{ uri: media.uri }} style={StyleSheet.absoluteFill} resizeMode="cover" />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Tippen auf eine freie Stelle der Story öffnet den Texteditor (wie Instagram). */}
|
||||
{toolsEnabled && rect.width > 0 && (
|
||||
<Pressable
|
||||
style={{ position: "absolute", left: rect.x, top: rect.y, width: rect.width, height: rect.height }}
|
||||
onPress={() => setPicker("text")}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyEditor.addText")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{overlays.map((overlay, index) => (
|
||||
<EditableOverlay
|
||||
key={index}
|
||||
overlay={overlay}
|
||||
rect={rect}
|
||||
mentionName={overlay.kind === "mention" ? mentionNames[overlay.petId] : undefined}
|
||||
onChange={(next) => setOverlays((list) => list.map((o, i) => (i === index ? next : o)))}
|
||||
onTap={() => {
|
||||
if (overlay.kind === "text") {
|
||||
setEditingIndex(index);
|
||||
setPicker("text");
|
||||
}
|
||||
}}
|
||||
onDragStateChange={(isDragging, pageY) => {
|
||||
setDragging(isDragging);
|
||||
setOverTrash(isDragging && isOverTrash(pageY));
|
||||
}}
|
||||
isOverTrash={isOverTrash}
|
||||
onDelete={() => setOverlays((list) => list.filter((_, i) => i !== index))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{toolsEnabled && overlays.length === 0 && media.kind !== "text" && rect.width > 0 && (
|
||||
<View pointerEvents="none" style={[styles.hintWrap, { top: rect.y + rect.height / 2 - 20 }]}>
|
||||
<Text style={styles.hint}>{t("storyEditor.emptyHint")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{dragging && (
|
||||
<View pointerEvents="none" style={[styles.trashZone, { height: TRASH_ZONE_HEIGHT }]}>
|
||||
<View style={[styles.trashCircle, overTrash && styles.trashCircleActive]}>
|
||||
<Trash2 color="#ffffff" size={24} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.topBar, { top: insets.top + 8 }]}>
|
||||
<ToolButton onPress={onCancel} label={t("common.cancel")}>
|
||||
<X color="#ffffff" size={22} />
|
||||
</ToolButton>
|
||||
{toolsEnabled && <View style={styles.tools}>
|
||||
{media.kind === "text" && (
|
||||
<ToolButton
|
||||
label={t("storyEditor.background")}
|
||||
onPress={() => setBackground((bg) => STORY_BACKGROUND_KEYS[(STORY_BACKGROUND_KEYS.indexOf(bg) + 1) % STORY_BACKGROUND_KEYS.length])}
|
||||
>
|
||||
<View style={[styles.swatch, { backgroundColor: STORY_BACKGROUNDS[background][0], borderColor: STORY_BACKGROUNDS[background][1] }]} />
|
||||
</ToolButton>
|
||||
)}
|
||||
<ToolButton label={t("storyEditor.addText")} onPress={() => setPicker("text")}>
|
||||
<Type color="#ffffff" size={22} />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label={t("storyEditor.addMention")}
|
||||
onPress={() => (mentionCount >= MAX_MENTIONS ? Alert.alert(t("storyEditor.limitMentions", { max: MAX_MENTIONS })) : setPicker("mention"))}
|
||||
>
|
||||
<AtSign color="#ffffff" size={22} />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label={t("storyEditor.addGif")}
|
||||
onPress={() => (gifCount >= MAX_GIFS ? Alert.alert(t("storyEditor.limitGifs", { max: MAX_GIFS })) : setPicker("gif"))}
|
||||
>
|
||||
<Text style={styles.gifLabel}>GIF</Text>
|
||||
</ToolButton>
|
||||
<ToolButton label={t("storyEditor.addSticker")} onPress={() => setPicker("sticker")}>
|
||||
<Smile color="#ffffff" size={22} />
|
||||
</ToolButton>
|
||||
</View>}
|
||||
</View>
|
||||
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + 12 }]}>
|
||||
<Pressable
|
||||
style={[styles.shareButton, !canSubmit && styles.shareButtonDisabled]}
|
||||
disabled={!canSubmit}
|
||||
onPress={() => onSubmit({ overlays, background: media.kind === "text" ? background : undefined, mentionNames })}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{isSubmitting ? <ActivityIndicator color="#ffffff" /> : <Text style={styles.shareText}>{submitLabel}</Text>}
|
||||
</Pressable>
|
||||
{progress}
|
||||
</View>
|
||||
|
||||
<TextEditorModal
|
||||
visible={picker === "text"}
|
||||
initial={editingText ? { text: editingText.text, color: editingText.color, style: editingText.style, font: editingText.font } : undefined}
|
||||
onDone={handleTextDone}
|
||||
onCancel={() => {
|
||||
setEditingIndex(null);
|
||||
setPicker(null);
|
||||
if (media.kind === "text" && !hasText && editingIndex === null && overlays.length === 0) onCancel();
|
||||
}}
|
||||
/>
|
||||
<MentionPicker
|
||||
visible={picker === "mention"}
|
||||
petId={petId}
|
||||
onClose={() => setPicker(null)}
|
||||
onSelect={(pet) => {
|
||||
setMentionNames((names) => ({ ...names, [pet.id]: pet.name }));
|
||||
if (!overlays.some((o) => o.kind === "mention" && o.petId === pet.id)) add({ kind: "mention", ...spawnPosition(), petId: pet.id });
|
||||
setPicker(null);
|
||||
}}
|
||||
/>
|
||||
<GifPicker
|
||||
visible={picker === "gif"}
|
||||
onClose={() => setPicker(null)}
|
||||
onSelect={(gif) => {
|
||||
add({ kind: "gif", ...spawnPosition(), gifId: gif.id, url: gif.url, width: gif.width, height: gif.height });
|
||||
setPicker(null);
|
||||
}}
|
||||
/>
|
||||
<StickerPicker
|
||||
visible={picker === "sticker"}
|
||||
onClose={() => setPicker(null)}
|
||||
onSelect={(stickerId) => {
|
||||
add({ kind: "sticker", ...spawnPosition(), stickerId });
|
||||
setPicker(null);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolButton({ label, onPress, children }: { label: string; onPress: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<Pressable style={styles.toolButton} onPress={onPress} hitSlop={6} accessibilityRole="button" accessibilityLabel={label}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stumm in Schleife — eigene Komponente, damit useVideoPlayer nicht bedingt aufgerufen wird. */
|
||||
function EditorVideo({ uri }: { uri: string }) {
|
||||
const player = useVideoPlayer(uri, (p) => {
|
||||
p.loop = true;
|
||||
p.muted = true;
|
||||
p.play();
|
||||
});
|
||||
return <VideoView player={player} style={StyleSheet.absoluteFill} contentFit="cover" nativeControls={false} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: "#111111" },
|
||||
canvas: { flex: 1 },
|
||||
topBar: { position: "absolute", left: 12, right: 12, flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
|
||||
tools: { flexDirection: "row", gap: 8 },
|
||||
toolButton: { width: 42, height: 42, borderRadius: 21, backgroundColor: "rgba(0,0,0,0.45)", alignItems: "center", justifyContent: "center" },
|
||||
gifLabel: { color: "#ffffff", fontFamily: fontFamily.extraBold, fontSize: 13, letterSpacing: 0.5 },
|
||||
swatch: { width: 22, height: 22, borderRadius: 11, borderWidth: 3 },
|
||||
bottomBar: { paddingHorizontal: 16, paddingTop: 12, gap: 8, backgroundColor: "#111111" },
|
||||
shareButton: { height: 48, borderRadius: 24, backgroundColor: "#f97316", alignItems: "center", justifyContent: "center" },
|
||||
shareButtonDisabled: { opacity: 0.5 },
|
||||
shareText: { color: "#ffffff", fontFamily: fontFamily.bold, fontSize: 16 },
|
||||
hintWrap: { position: "absolute", left: 24, right: 24, alignItems: "center" },
|
||||
hint: {
|
||||
color: "#ffffff",
|
||||
fontFamily: fontFamily.semiBold,
|
||||
fontSize: 15,
|
||||
textAlign: "center",
|
||||
textShadowColor: "rgba(0,0,0,0.6)",
|
||||
textShadowRadius: 6,
|
||||
},
|
||||
trashZone: { position: "absolute", left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center" },
|
||||
trashCircle: { width: 56, height: 56, borderRadius: 28, backgroundColor: "rgba(0,0,0,0.55)", alignItems: "center", justifyContent: "center", borderWidth: 2, borderColor: "rgba(255,255,255,0.6)" },
|
||||
trashCircleActive: { backgroundColor: "#dc2626", transform: [{ scale: 1.2 }] },
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { View, Pressable, StyleSheet } from "react-native";
|
||||
import { OverlayContent } from "./OverlayContent";
|
||||
import { overlayCenter, type Rect, type StoryOverlay } from "../../lib/story-overlays";
|
||||
|
||||
type MentionedPet = { id: string; name: string };
|
||||
|
||||
/**
|
||||
* Anzeige-Ebene im Story-Viewer: legt die Overlays auf die Medienfläche `rect`
|
||||
* (gleiches Koordinatensystem wie im Editor). Nur Markierungen sind antippbar
|
||||
* (→ Tierprofil), alles andere lässt Berührungen zu den Weiter/Zurück-Zonen durch.
|
||||
*/
|
||||
export function StoryOverlayLayer({
|
||||
overlays,
|
||||
rect,
|
||||
mentionedPets,
|
||||
onPressMention,
|
||||
}: {
|
||||
overlays: readonly StoryOverlay[];
|
||||
rect: Rect;
|
||||
mentionedPets: readonly MentionedPet[];
|
||||
onPressMention?: (petId: string) => void;
|
||||
}) {
|
||||
if (!overlays.length || rect.width <= 0) return null;
|
||||
const names = new Map(mentionedPets.map((p) => [p.id, p.name]));
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
|
||||
{overlays.map((overlay, i) => {
|
||||
const content = (
|
||||
<OverlayContent overlay={overlay} baseWidth={rect.width} mentionName={overlay.kind === "mention" ? names.get(overlay.petId) : undefined} />
|
||||
);
|
||||
return (
|
||||
<PositionedOverlay key={i} overlay={overlay} rect={rect} interactive={overlay.kind === "mention" && !!onPressMention}>
|
||||
{overlay.kind === "mention" && onPressMention ? (
|
||||
<Pressable onPress={() => onPressMention(overlay.petId)} hitSlop={8} accessibilityRole="link">
|
||||
{content}
|
||||
</Pressable>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</PositionedOverlay>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zentriert den Inhalt auf (x, y) der Medienfläche, ohne ihn messen zu müssen:
|
||||
* ein großes, unsichtbares Quadrat um den Mittelpunkt mit zentriertem Inhalt.
|
||||
* Drehung und Skalierung wirken um die Mitte — wie im Web (transform-origin center).
|
||||
*/
|
||||
export function PositionedOverlay({
|
||||
overlay,
|
||||
rect,
|
||||
interactive,
|
||||
children,
|
||||
}: {
|
||||
overlay: StoryOverlay;
|
||||
rect: Rect;
|
||||
interactive: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const box = rect.width * 2;
|
||||
const { left, top } = overlayCenter(rect, overlay);
|
||||
return (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={{ position: "absolute", left: left - box / 2, top: top - box / 2, width: box, height: box, alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<View
|
||||
pointerEvents={interactive ? "auto" : "none"}
|
||||
style={{ transform: [{ rotate: `${overlay.rotation}deg` }, { scale: overlay.scale }] }}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, TextInput, Pressable, StyleSheet } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import { MAX_TEXT_LENGTH, TEXT_COLORS, TEXT_STYLES, type TextColor, type TextFont, type TextStyle } from "../../lib/story-overlays";
|
||||
import { textAppearance } from "./OverlayContent";
|
||||
import { EditorLayer } from "./EditorLayer";
|
||||
|
||||
export type TextDraft = { text: string; color: TextColor; style: TextStyle; font: TextFont };
|
||||
|
||||
const DEFAULT_DRAFT: TextDraft = { text: "", color: "#ffffff", style: "plain", font: "bold" };
|
||||
|
||||
/**
|
||||
* Vollbild-Texteingabe über dem Editor (wie Instagram): Text tippen, Farbe
|
||||
* (8 Farben), Stil (schlicht → Box → weich) und Schrift (fett/normal) wählen.
|
||||
* Leerer Text beim Fertig-Drücken = Overlay entfernen bzw. nicht anlegen.
|
||||
*/
|
||||
export function TextEditorModal({
|
||||
visible,
|
||||
initial,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
visible: boolean;
|
||||
initial?: TextDraft;
|
||||
onDone: (draft: TextDraft) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [draft, setDraft] = useState<TextDraft>(initial ?? DEFAULT_DRAFT);
|
||||
|
||||
// Beim Öffnen mit dem bearbeiteten Overlay bzw. leer starten.
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Zurücksetzen beim Öffnen ist hier gewollt
|
||||
if (visible) setDraft(initial ?? DEFAULT_DRAFT);
|
||||
}, [visible, initial]);
|
||||
|
||||
const { textColor, background } = textAppearance(draft);
|
||||
const nextStyle = TEXT_STYLES[(TEXT_STYLES.indexOf(draft.style) + 1) % TEXT_STYLES.length];
|
||||
|
||||
return (
|
||||
<EditorLayer visible={visible} onRequestClose={onCancel} style={[styles.backdrop, { paddingTop: insets.top + 8, paddingBottom: insets.bottom + 12 }]}>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable onPress={onCancel} hitSlop={12} accessibilityRole="button">
|
||||
<Text style={styles.topButton}>{t("common.cancel")}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.topTools}>
|
||||
<Pressable
|
||||
style={styles.toolButton}
|
||||
onPress={() => setDraft((d) => ({ ...d, style: nextStyle }))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyEditor.textStyle")}
|
||||
>
|
||||
<Text style={styles.toolButtonText}>{t(`storyEditor.style.${draft.style}`)}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.toolButton}
|
||||
onPress={() => setDraft((d) => ({ ...d, font: d.font === "bold" ? "regular" : "bold" }))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyEditor.textFont")}
|
||||
>
|
||||
<Text style={[styles.toolButtonText, { fontFamily: draft.font === "bold" ? fontFamily.bold : fontFamily.regular }]}>Aa</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable onPress={() => onDone(draft)} hitSlop={12} accessibilityRole="button">
|
||||
<Text style={[styles.topButton, styles.done]}>{t("common.done")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.center}>
|
||||
<View style={[styles.preview, background ? { backgroundColor: background } : null]}>
|
||||
<TextInput
|
||||
value={draft.text}
|
||||
onChangeText={(text) => setDraft((d) => ({ ...d, text }))}
|
||||
autoFocus
|
||||
multiline
|
||||
maxLength={MAX_TEXT_LENGTH}
|
||||
placeholder={t("storyEditor.textPlaceholder")}
|
||||
placeholderTextColor="rgba(255,255,255,0.6)"
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: textColor, fontFamily: draft.font === "bold" ? fontFamily.bold : fontFamily.regular },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.colors}>
|
||||
{TEXT_COLORS.map((color) => (
|
||||
<Pressable
|
||||
key={color}
|
||||
onPress={() => setDraft((d) => ({ ...d, color }))}
|
||||
style={[styles.colorDot, { backgroundColor: color }, draft.color === color && styles.colorDotActive]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={color}
|
||||
accessibilityState={{ selected: draft.color === color }}
|
||||
hitSlop={4}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</EditorLayer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { backgroundColor: "rgba(0,0,0,0.75)" },
|
||||
topBar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16 },
|
||||
topButton: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 16 },
|
||||
done: { color: "#f97316" },
|
||||
topTools: { flexDirection: "row", gap: 10 },
|
||||
toolButton: { borderWidth: 1.5, borderColor: "#ffffff", borderRadius: 999, paddingHorizontal: 12, paddingVertical: 4 },
|
||||
toolButtonText: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 14 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 24 },
|
||||
preview: { borderRadius: 10, paddingHorizontal: 12, paddingVertical: 6, maxWidth: "100%" },
|
||||
input: { fontSize: 28, lineHeight: 34, textAlign: "center", minWidth: 120, textShadowColor: "rgba(0,0,0,0.35)", textShadowRadius: 4 },
|
||||
colors: { flexDirection: "row", justifyContent: "center", gap: 12, paddingVertical: 8 },
|
||||
colorDot: { width: 28, height: 28, borderRadius: 14, borderWidth: 2, borderColor: "rgba(255,255,255,0.6)" },
|
||||
colorDotActive: { borderColor: "#ffffff", borderWidth: 3, transform: [{ scale: 1.15 }] },
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Alert } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../api/trpc";
|
||||
|
||||
type BlockTarget = { id: string; name: string };
|
||||
|
||||
/**
|
||||
* Port of web's src/components/safety/BlockDialog.tsx (SAFE-01) as a native
|
||||
* confirm Alert instead of a dialog component. Required for App Store
|
||||
* Guideline 1.2 (UGC apps must let users block abusive accounts).
|
||||
*
|
||||
* The block itself is enforced server-side: feed.getFeed filters blocked
|
||||
* pets, comments/DMs reject blocked pairs. Explore/search don't filter blocks
|
||||
* yet (server change) — invalidating them here is harmless groundwork.
|
||||
*
|
||||
* `onBlocked` lets a caller leave a screen that shows the blocked pet (the
|
||||
* pet profile navigates back).
|
||||
*/
|
||||
export function useBlockPet(onBlocked?: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const blockMutation = useMutation(
|
||||
trpc.blocks.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(trpc.feed.pathFilter());
|
||||
queryClient.invalidateQueries(trpc.explore.pathFilter());
|
||||
queryClient.invalidateQueries(trpc.search.pathFilter());
|
||||
queryClient.invalidateQueries(trpc.posts.pathFilter());
|
||||
queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
onBlocked?.();
|
||||
},
|
||||
onError: () => Alert.alert(t("block.errorToast")),
|
||||
})
|
||||
);
|
||||
|
||||
const confirmBlock = (blockerPetId: string, target: BlockTarget) => {
|
||||
Alert.alert(t("block.title", { petName: target.name }), t("block.description", { petName: target.name }), [
|
||||
{ text: t("block.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("block.confirm"),
|
||||
style: "destructive",
|
||||
onPress: () =>
|
||||
blockMutation.mutate(
|
||||
{ blockerPetId, blockedPetId: target.id },
|
||||
{ onSuccess: () => Alert.alert(t("block.successToast", { petName: target.name })) }
|
||||
),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return { confirmBlock, isBlocking: blockMutation.isPending };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const BASE_PADDING = 16;
|
||||
|
||||
/**
|
||||
* Bottom padding for scroll containers on stack screens (no tab bar below).
|
||||
* Android is edge-to-edge, so without this the last item scrolls under the
|
||||
* gesture/navigation bar — found.md: the first post on a pet profile sat in
|
||||
* the gesture area and couldn't be tapped. Same idea as comments.tsx /
|
||||
* messages/[conversationId].tsx, which pass insets.bottom into makeStyles.
|
||||
*
|
||||
* Usage: `contentContainerStyle={[styles.list, bottomPadding]}`.
|
||||
*/
|
||||
export function useBottomSafePadding(base: number = BASE_PADDING) {
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
return useMemo(() => ({ paddingBottom: base + bottom }), [base, bottom]);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useActivePet } from "../context/ActivePetContext";
|
||||
import { notificationRoute, targetFromPushData } from "../lib/notification-route";
|
||||
|
||||
/**
|
||||
* Antippen einer Push-Mitteilung führt direkt zum Ziel (Gespräch, Beitrag,
|
||||
* Story, Profil, Gesundheit) — vorher öffnete ein Tipp nur die App.
|
||||
* useLastNotificationResponse deckt laufende App und Kaltstart ab.
|
||||
*
|
||||
* Gilt die Mitteilung für ein anderes eigenes Tier, wird vorher dorthin
|
||||
* gewechselt (Nachrichten und Gesundheit hängen am aktiven Tier).
|
||||
* Läuft im (app)-Layout, also erst nach Login und mit geladenen Tieren.
|
||||
*/
|
||||
export function useNotificationTapRouting(): void {
|
||||
const response = Notifications.useLastNotificationResponse();
|
||||
const router = useRouter();
|
||||
const { activePetId, setActivePetId, pets, isPending } = useActivePet();
|
||||
const handledId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!response || isPending) return;
|
||||
if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return;
|
||||
const id = response.notification.request.identifier;
|
||||
if (handledId.current === id) return;
|
||||
handledId.current = id;
|
||||
|
||||
const target = targetFromPushData(response.notification.request.content.data as Record<string, unknown> | undefined);
|
||||
Notifications.clearLastNotificationResponse();
|
||||
if (!target) return;
|
||||
|
||||
if (target.recipientPetId && target.recipientPetId !== activePetId && pets.some((p) => p.id === target.recipientPetId)) {
|
||||
setActivePetId(target.recipientPetId);
|
||||
}
|
||||
const route = notificationRoute(target, Date.now());
|
||||
if (route) router.push(route);
|
||||
}, [response, isPending, activePetId, pets, setActivePetId, router]);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTRPC } from "../api/trpc";
|
||||
|
||||
const RANKS_STALE_MS = 30 * 60 * 1000; // Ränge ändern sich selten — einmal laden, lange halten
|
||||
|
||||
export type ReferralRankView = {
|
||||
id: string;
|
||||
threshold: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
perks: string[];
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type ReferralOwnerFields = {
|
||||
referralRankId?: string | null;
|
||||
referralPerks?: readonly string[] | null;
|
||||
};
|
||||
|
||||
/** Port of web's src/hooks/useReferralRanks.ts — Rangliste in der aktuellen App-Sprache. */
|
||||
export function useReferralRanks(): { ranks: ReferralRankView[]; byId: Map<string, ReferralRankView> } {
|
||||
const trpc = useTRPC();
|
||||
const { i18n } = useTranslation();
|
||||
const isGerman = i18n.language.startsWith("de");
|
||||
const { data = [] } = useQuery({ ...trpc.referrals.ranks.queryOptions(), staleTime: RANKS_STALE_MS });
|
||||
|
||||
const ranks = data.map((rank) => ({
|
||||
id: rank.id,
|
||||
threshold: rank.threshold,
|
||||
name: isGerman ? rank.nameDe : rank.nameEn,
|
||||
description: isGerman ? rank.descriptionDe : rank.descriptionEn,
|
||||
icon: rank.icon,
|
||||
color: rank.color,
|
||||
perks: rank.perks,
|
||||
isActive: rank.isActive,
|
||||
}));
|
||||
return { ranks, byId: new Map(ranks.map((rank) => [rank.id, rank])) };
|
||||
}
|
||||
|
||||
/** Rang mit dem Vorteil `perk` für diesen Owner, sonst null (Vorteile sind kumulativ, siehe Owner.referralPerks). */
|
||||
export function useOwnerRankWithPerk(owner: ReferralOwnerFields | null | undefined, perk: string): ReferralRankView | null {
|
||||
const { byId } = useReferralRanks();
|
||||
if (!owner?.referralRankId || !owner.referralPerks?.includes(perk)) return null;
|
||||
return byId.get(owner.referralRankId) ?? null;
|
||||
}
|
||||
+210
-12
@@ -20,6 +20,7 @@
|
||||
"done": "Fertig",
|
||||
"optional": "optional",
|
||||
"noResultsFound": "Keine Einträge gefunden.",
|
||||
"searchPlaceholder": "Suchen …",
|
||||
"edit": "Bearbeiten",
|
||||
"authLoadTimeout": "Das dauert gerade länger als erwartet. Bitte erneut versuchen."
|
||||
},
|
||||
@@ -48,7 +49,8 @@
|
||||
"feed": {
|
||||
"empty": "Noch keine Beiträge im Feed.",
|
||||
"storyLabel": "Story",
|
||||
"trendingBadge": "Trending"
|
||||
"trendingBadge": "Trending",
|
||||
"yourStory": "Deine Story"
|
||||
},
|
||||
"explore": {
|
||||
"allTab": "Alle",
|
||||
@@ -65,7 +67,7 @@
|
||||
"breedLabel": "Rasse",
|
||||
"birthdayLabel": "Geburtstag (optional)",
|
||||
"adoptedAtLabel": "Adoptionstag (optional)",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT-MM-JJJJ eingeben oder leer lassen.",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben oder leer lassen.",
|
||||
"deleteConfirmTitle": "Tier wirklich löschen?",
|
||||
"deleteConfirmMessage": "{{name}} und alle zugehörigen Beiträge, Kommentare und Follower-Beziehungen werden unwiderruflich gelöscht.",
|
||||
"deleteConfirmMessageFallback": "Dieses Tier",
|
||||
@@ -84,11 +86,16 @@
|
||||
"shareButton": "Teilen",
|
||||
"deleteConfirmTitle": "Beitrag wirklich löschen?",
|
||||
"deleteConfirmMessage": "Dieser Beitrag wird unwiderruflich gelöscht.",
|
||||
"deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuch es erneut."
|
||||
"deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuch es erneut.",
|
||||
"unsupportedFormat": "Einige Fotos haben ein Format, das PawFeed nicht verarbeiten kann, und wurden übersprungen."
|
||||
},
|
||||
"story": {
|
||||
"changePhotoHint": "Antippen, um ein anderes Foto zu wählen",
|
||||
"shareButton": "Story teilen"
|
||||
"changeVideoHint": "Antippen, um ein anderes Video zu wählen",
|
||||
"shareButton": "Story teilen",
|
||||
"videoTooLong": "Videos für Stories dürfen höchstens {{max}} Sekunden lang sein.",
|
||||
"preparingUpload": "Upload wird vorbereitet …",
|
||||
"uploadingVideo": "Video wird hochgeladen … {{percent}} %"
|
||||
},
|
||||
"milestoneTypes": {
|
||||
"BIRTHDAY": "Geburtstag",
|
||||
@@ -133,19 +140,25 @@
|
||||
"manageDesc": "Ein Widerruf löscht sofort und unwiderruflich alle Gesundheitsdaten dieses Haustiers.",
|
||||
"revokeLink": "Widerrufen",
|
||||
"weightTitle": "Gewicht",
|
||||
"weightDatePlaceholder": "TT-MM-JJJJ",
|
||||
"weightDatePlaceholder": "TT.MM.JJJJ",
|
||||
"weightKgPlaceholder": "Gewicht (kg), z.B. 4,2",
|
||||
"notesPlaceholder": "Notizen (optional)",
|
||||
"weightEmpty": "Noch keine Gewichtseinträge",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT-MM-JJJJ eingeben.",
|
||||
"weightChartTitle": "Verlauf",
|
||||
"weightChartSinceFirst": "seit erster Messung",
|
||||
"weightChartA11y": "Gewichtsverlauf von {{from}} am {{fromDate}} auf {{to}} am {{toDate}}. Tippen, um eine Messung auszuwählen.",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.",
|
||||
"vetTitle": "Tierarzt",
|
||||
"vetReasonPlaceholder": "Grund, z.B. Jahresuntersuchung",
|
||||
"vetNamePlaceholder": "Tierarzt / Klinik (optional)",
|
||||
"vetEmpty": "Noch keine Tierarztbesuche",
|
||||
"vaccineTitle": "Impfungen",
|
||||
"vaccineNamePlaceholder": "Impfstoff, z.B. Tollwut, DHPP",
|
||||
"vaccineGivenPlaceholder": "Gegeben am (TT-MM-JJJJ)",
|
||||
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, TT-MM-JJJJ)",
|
||||
"vaccineGivenPlaceholder": "Gegeben am (TT.MM.JJJJ)",
|
||||
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, TT.MM.JJJJ)",
|
||||
"vaccineNextDuePreset_one": "+{{count}} Jahr",
|
||||
"vaccineNextDuePreset_other": "+{{count}} Jahre",
|
||||
"vaccineReminderHint": "Mit Folgetermin erinnern wir dich 30 Tage vorher.",
|
||||
"vaccineEmpty": "Noch keine Impfungen eingetragen",
|
||||
"vaccineGiven": "Gegeben {{date}}",
|
||||
"vaccineDue": "Fällig {{date}}",
|
||||
@@ -179,9 +192,13 @@
|
||||
"messageButton": "Nachricht",
|
||||
"adoptionStoryTitle": "Adoptionsgeschichte",
|
||||
"noFollowers": "Noch keine Follower.",
|
||||
"noFollowing": "Folgt noch niemandem."
|
||||
"noFollowing": "Folgt noch niemandem.",
|
||||
"reportPet": "Tier melden",
|
||||
"moreActions": "Weitere Aktionen"
|
||||
},
|
||||
"notifications": {
|
||||
"vaccineDue": "💉 Die Impfung „{{name}}“ ist am {{date}} fällig – Zeit, einen Termin zu vereinbaren.",
|
||||
"vaccineDueGeneric": "💉 Eine Impfung wird bald fällig.",
|
||||
"follow": "folgt dir jetzt.",
|
||||
"reaction": "hat deinen Beitrag mit einer Pfote versehen.",
|
||||
"comment": "hat deinen Beitrag kommentiert.",
|
||||
@@ -191,7 +208,14 @@
|
||||
"birthday": "🎂 Alles Gute zum Geburtstag!",
|
||||
"adoptionDay": "🏠 Heute ist der Adoptionstag!",
|
||||
"petSuggestion": "könnte dir gefallen!",
|
||||
"empty": "Keine Benachrichtigungen."
|
||||
"empty": "Keine Benachrichtigungen.",
|
||||
"referralJoined": "ist über deine Einladung zu PawFeed gekommen.",
|
||||
"referralRankUp": "Du hast einen neuen Empfehlungsrang erreicht!",
|
||||
"storyMention": "hat dich in einer Story markiert.",
|
||||
"storyComment": "hat deine Story kommentiert.",
|
||||
"storyReaction": "hat deiner Story eine Pfote gegeben.",
|
||||
"unknown": "Neue Mitteilung.",
|
||||
"petDeceased": "ist leider verstorben. In Erinnerung 🕯️"
|
||||
},
|
||||
"pets": {
|
||||
"empty": "Noch keine Tiere angelegt.",
|
||||
@@ -240,7 +264,9 @@
|
||||
"googleButton": "Mit Google anmelden",
|
||||
"orDivider": "oder",
|
||||
"googleSignInError": "Anmeldung mit Google fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"googleNeedsSignUpError": "Für dieses Google-Konto existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen."
|
||||
"googleNeedsSignUpError": "Für dieses Google-Konto existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen.",
|
||||
"appleSignInError": "Anmeldung mit Apple fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"appleNeedsSignUpError": "Für diese Apple-ID existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen."
|
||||
},
|
||||
"onboarding": {
|
||||
"addPetTitle": "Tier hinzufügen",
|
||||
@@ -307,6 +333,178 @@
|
||||
"settings": "Einstellungen",
|
||||
"newPost": "Neuer Beitrag",
|
||||
"newStory": "Neue Story",
|
||||
"newMilestone": "Neuer Meilenstein"
|
||||
"newMilestone": "Neuer Meilenstein",
|
||||
"deleteAccount": "Konto löschen",
|
||||
"invite": "Freunde einladen"
|
||||
},
|
||||
"block": {
|
||||
"menuItem": "{{petName}} blockieren",
|
||||
"title": "{{petName}} blockieren?",
|
||||
"description": "Posts von {{petName}} werden in deinem Feed ausgeblendet, und ihr könnt euch nicht mehr kommentieren oder schreiben. Niemand erfährt, dass du blockiert hast.",
|
||||
"cancel": "Nicht blockieren",
|
||||
"confirm": "Blockieren",
|
||||
"successToast": "Du hast {{petName}} blockiert.",
|
||||
"errorToast": "Tier konnte nicht blockiert werden. Bitte versuch es erneut."
|
||||
},
|
||||
"deleteAccount": {
|
||||
"trigger": "Konto löschen",
|
||||
"warningTitle": "Konto wirklich löschen?",
|
||||
"warningIntro": "Diese Aktion ist unwiderruflich. Folgendes wird dauerhaft gelöscht:",
|
||||
"itemPets": "Alle Tierprofile",
|
||||
"itemPosts": "Alle Posts, Kommentare und Reaktionen",
|
||||
"itemMedia": "Alle Bilder und Videos",
|
||||
"itemMessages": "Alle Chats und Nachrichten",
|
||||
"itemHealth": "Alle Gesundheitsdaten",
|
||||
"itemAccount": "Dein PawFeed-Konto",
|
||||
"confirmIntro": "Gib den Namen eines deiner Haustiere ein, um die Löschung zu bestätigen:",
|
||||
"inputPlaceholder": "Tiername eingeben…",
|
||||
"confirmDelete": "Konto endgültig löschen",
|
||||
"finalConfirmTitle": "Letzte Bestätigung",
|
||||
"finalConfirmMessage": "Dein Konto und alle Daten werden sofort und endgültig gelöscht. Das kann nicht rückgängig gemacht werden.",
|
||||
"deleting": "Konto wird gelöscht…",
|
||||
"doneTitle": "Dein Konto wurde gelöscht.",
|
||||
"genericError": "Etwas ist schiefgelaufen. Bitte versuch es erneut."
|
||||
},
|
||||
"referrals": {
|
||||
"title": "Freunde einladen",
|
||||
"settingsLabel": "Einladungslink, Ränge und eingeladene Tiere",
|
||||
"subtitle": "Teile deinen Link. Jedes Tier, das über deine Einladung kommt und seinen ersten Beitrag postet, bringt dich einen Rang weiter.",
|
||||
"programPaused": "Das Empfehlungsprogramm ist gerade pausiert. Dein Link funktioniert, neue Einladungen werden aber im Moment nicht gezählt.",
|
||||
"shareHeading": "Dein Einladungslink",
|
||||
"shareSubtitle": "Unbegrenzt oft nutzbar. Wer sich ohne Link registriert, kann deinen Code auch im Onboarding eingeben.",
|
||||
"yourCode": "Dein Code",
|
||||
"share": "Link teilen",
|
||||
"shareText": "Mein Tier ist auf PawFeed, dem sozialen Netzwerk für Haustiere. Mit meinem Code {{code}} sind wir direkt verbunden:",
|
||||
"ranksHeading": "Deine Ränge",
|
||||
"countedInvites_zero": "Noch keine gezählte Einladung",
|
||||
"countedInvites_one": "{{count}} gezählte Einladung",
|
||||
"countedInvites_other": "{{count}} gezählte Einladungen",
|
||||
"nextRank_one": "Noch {{count}} Einladung bis {{name}}",
|
||||
"nextRank_other": "Noch {{count}} Einladungen bis {{name}}",
|
||||
"allRanksReached": "Du hast alle Ränge erreicht. Danke!",
|
||||
"achieved": "Erreicht",
|
||||
"threshold_one": "{{count}} Einladung",
|
||||
"threshold_other": "{{count}} Einladungen",
|
||||
"inviteesHeading": "Über dich gekommen",
|
||||
"inviteesEmpty": "Noch niemand. Teile deinen Link mit Freunden, die selbst ein Tier haben.",
|
||||
"inviteeNoPet": "Noch kein Tier angelegt",
|
||||
"statusCounted": "Zählt",
|
||||
"statusWaiting": "Wartet auf ersten Beitrag",
|
||||
"statusInReview": "Wird geprüft",
|
||||
"statusRevoked": "Aberkannt",
|
||||
"redeemHeading": "Wurdest du eingeladen?",
|
||||
"redeemSubtitle": "Gib den Code der Person ein, die dich eingeladen hat. Das geht nur in den ersten Tagen nach der Registrierung.",
|
||||
"redeemLabel": "Einladungscode",
|
||||
"redeemCta": "Einlösen",
|
||||
"redeemed": "Eingelöst. Du und {{name}} folgt euch jetzt.",
|
||||
"redeemedGeneric": "Code eingelöst.",
|
||||
"errorPaused": "Das Empfehlungsprogramm ist gerade pausiert.",
|
||||
"errorInvalid": "Diesen Code gibt es nicht. Prüf die Schreibweise, z. B. BUBI-7K3M.",
|
||||
"errorOwnCode": "Das ist dein eigener Code. Gib den Code der Person ein, die dich eingeladen hat.",
|
||||
"errorAlreadyRedeemed": "Du hast schon einen Einladungscode eingelöst.",
|
||||
"errorWindowExpired": "Einladungscodes können nur in den ersten Tagen nach der Registrierung eingelöst werden.",
|
||||
"errorNoPet": "Leg zuerst ein Tier an, dann kannst du den Code einlösen.",
|
||||
"errorTooMany": "Zu viele Versuche. Bitte warte ein paar Minuten.",
|
||||
"errorGeneric": "Das hat nicht geklappt. Bitte versuch es noch einmal.",
|
||||
"rulesNote": "Eine Einladung zählt, sobald das eingeladene Tier angelegt ist und seinen ersten Beitrag veröffentlicht hat. Ränge sind digitale Auszeichnungen ohne Geldwert. Einmal erreichte Ränge bleiben erhalten, außer bei Missbrauch.",
|
||||
"invitedBy": "Eingeladen von",
|
||||
"onboardingTitle": "Wie hast du von PawFeed erfahren?",
|
||||
"onboardingSubtitle": "Freiwillig. Es hilft uns zu verstehen, wo wir neue Tierfreunde erreichen.",
|
||||
"source_FRIENDS": "Freunde oder Familie",
|
||||
"source_TIKTOK": "TikTok",
|
||||
"source_INSTAGRAM": "Instagram",
|
||||
"source_YOUTUBE": "YouTube",
|
||||
"source_FACEBOOK": "Facebook",
|
||||
"source_SHELTER_OR_PARTNER": "Tierheim, Verein oder Laden",
|
||||
"source_STORE_SEARCH": "App Store oder Google Play",
|
||||
"source_PRESS": "Zeitung, Radio oder Blog",
|
||||
"source_OTHER": "Anders"
|
||||
},
|
||||
"storyViewer": {
|
||||
"paw": "Pfote geben",
|
||||
"removePaw": "Pfote zurücknehmen",
|
||||
"openComments": "Kommentare öffnen",
|
||||
"commentsTitle": "Kommentare",
|
||||
"deleteComment": "Kommentar löschen",
|
||||
"commentError": "Kommentar konnte nicht gesendet werden. Bitte versuch es noch einmal.",
|
||||
"viewersTitle_one": "{{count}} Aufruf",
|
||||
"viewersTitle_other": "{{count}} Aufrufe",
|
||||
"noViewers": "Noch hat niemand deine Story gesehen.",
|
||||
"deleteStory": "Story löschen",
|
||||
"deleteTitle": "Story löschen?",
|
||||
"deleteDescription": "Die Story wird mit allen Aufrufen, Pfoten und Kommentaren endgültig gelöscht.",
|
||||
"deleteConfirm": "Löschen",
|
||||
"deleteError": "Die Story konnte nicht gelöscht werden. Bitte versuch es noch einmal.",
|
||||
"expiredOwnerOnly": "Abgelaufen – nur für dich sichtbar",
|
||||
"readOnlyExpired": "Die Story ist abgelaufen. Kommentare kannst du noch lesen, aber nicht mehr schreiben.",
|
||||
"pawCount_one": "{{count}} Pfote",
|
||||
"pawCount_other": "{{count}} Pfoten"
|
||||
},
|
||||
"foundingMember": {
|
||||
"label": "Gründungsmitglied",
|
||||
"tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed"
|
||||
},
|
||||
"privacy": {
|
||||
"heading": "Privatsphäre",
|
||||
"whoCanMessage": "Wer kann {{name}} Nachrichten schreiben?",
|
||||
"everyone": "Alle",
|
||||
"onlyFollowedPets": "Nur Tiere, denen ich folge",
|
||||
"dmFollowersOnlyHint": "Nur Tiere, denen du folgst, können dir Direktnachrichten schreiben.",
|
||||
"dmEveryoneHint": "Jedes Tier auf PawFeed kann dir Direktnachrichten schreiben.",
|
||||
"whoCanSeeFollowers": "Wer darf die Follower von {{name}} sehen?",
|
||||
"onlyMyFollowers": "Nur meine Follower",
|
||||
"onlyMe": "Nur ich",
|
||||
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {{name}} sehen und {{name}} in Storys markieren.",
|
||||
"followerFollowersOnlyHint": "Nur Tiere, die {{name}} folgen, können die Follower- und Folgt-Listen sehen und {{name}} in Storys markieren.",
|
||||
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {{name}} sehen — markieren in Storys kann {{name}} niemand.",
|
||||
"saveFailed": "Privatsphäre-Einstellung konnte nicht gespeichert werden"
|
||||
},
|
||||
"storyEditor": {
|
||||
"textStory": "Text-Story",
|
||||
"addText": "Text hinzufügen",
|
||||
"addMention": "Tier markieren",
|
||||
"addGif": "GIF hinzufügen",
|
||||
"addSticker": "Sticker hinzufügen",
|
||||
"background": "Hintergrund wechseln",
|
||||
"emptyHint": "Füge Text, @Markierungen, GIFs oder Sticker hinzu — oder teile direkt.",
|
||||
"textPlaceholder": "Schreib etwas …",
|
||||
"textStyle": "Textstil",
|
||||
"textFont": "Schrift",
|
||||
"style": {
|
||||
"plain": "Schlicht",
|
||||
"box": "Box",
|
||||
"soft": "Weich"
|
||||
},
|
||||
"mentionTitle": "Tier markieren",
|
||||
"mentionSearch": "Tiername suchen",
|
||||
"mentionNoResults": "Kein Tier gefunden, das du markieren darfst.",
|
||||
"mentionPrivacyHint": "Tiere, deren Follower nur für ihre Follower oder niemanden sichtbar sind, kannst du nur markieren, wenn du ihnen folgst bzw. gar nicht.",
|
||||
"gifTitle": "GIF auswählen",
|
||||
"gifSearch": "GIPHY durchsuchen",
|
||||
"gifError": "Die GIF-Suche ist gerade nicht erreichbar.",
|
||||
"stickerTitle": "Sticker",
|
||||
"limitOverlays": "Höchstens {{max}} Elemente pro Story.",
|
||||
"limitMentions": "Höchstens {{max}} Tiere pro Story markieren.",
|
||||
"limitGifs": "Höchstens {{max}} GIFs pro Story."
|
||||
},
|
||||
"inMemory": {
|
||||
"label": "In Erinnerung",
|
||||
"tooltip": "In Erinnerung – dieses Tier ist über die Regenbogenbrücke gegangen",
|
||||
"banner": "In Erinnerung an {{name}} · {{date}}",
|
||||
"settingsHeading": "Gedenkmodus",
|
||||
"settingsHint": "Wenn dein Tier verstorben ist, kannst du sein Profil als Erinnerung erhalten. Es bekommt das Abzeichen „In Erinnerung“, Beiträge bleiben sichtbar und du kannst weiter Erinnerungen teilen. Geburtstags-Mitteilungen, Impf-Erinnerungen und Vorschläge enden.",
|
||||
"activeHint": "{{name}} ist im Gedenkmodus. Du kannst das Datum korrigieren oder den Modus beenden.",
|
||||
"dateLabel": "Verstorben am",
|
||||
"activate": "Gedenkmodus aktivieren",
|
||||
"saveDate": "Datum speichern",
|
||||
"remove": "Gedenkmodus beenden",
|
||||
"confirmTitle": "{{name}} in Erinnerung halten?",
|
||||
"confirmDescription": "Das Profil bekommt das Abzeichen „In Erinnerung“. Wer deinem Tier folgt, erhält einmalig eine stille Mitteilung (ohne Push). Du kannst das jederzeit zurücknehmen.",
|
||||
"confirm": "Ja, aktivieren",
|
||||
"cancel": "Abbrechen",
|
||||
"saved": "Gedenkmodus gespeichert",
|
||||
"removed": "Gedenkmodus beendet",
|
||||
"saveFailed": "Konnte nicht gespeichert werden",
|
||||
"invalidDate": "Bitte ein gültiges Datum eingeben, das nicht in der Zukunft liegt."
|
||||
}
|
||||
}
|
||||
+210
-12
@@ -20,6 +20,7 @@
|
||||
"done": "Done",
|
||||
"optional": "optional",
|
||||
"noResultsFound": "No entries found.",
|
||||
"searchPlaceholder": "Search …",
|
||||
"edit": "Edit",
|
||||
"authLoadTimeout": "This is taking longer than expected. Please try again."
|
||||
},
|
||||
@@ -48,7 +49,8 @@
|
||||
"feed": {
|
||||
"empty": "No posts in your feed yet.",
|
||||
"storyLabel": "Story",
|
||||
"trendingBadge": "Trending"
|
||||
"trendingBadge": "Trending",
|
||||
"yourStory": "Your story"
|
||||
},
|
||||
"explore": {
|
||||
"allTab": "All",
|
||||
@@ -65,7 +67,7 @@
|
||||
"breedLabel": "Breed",
|
||||
"birthdayLabel": "Birthday (optional)",
|
||||
"adoptedAtLabel": "Adoption date (optional)",
|
||||
"invalidDate": "Please enter a valid date as DD-MM-YYYY, or leave it empty.",
|
||||
"invalidDate": "Please enter a valid date as DD.MM.YYYY, or leave it empty.",
|
||||
"deleteConfirmTitle": "Delete this pet?",
|
||||
"deleteConfirmMessage": "{{name}} and all associated posts, comments, and follower relationships will be permanently deleted.",
|
||||
"deleteConfirmMessageFallback": "This pet",
|
||||
@@ -84,11 +86,16 @@
|
||||
"shareButton": "Share",
|
||||
"deleteConfirmTitle": "Delete this post?",
|
||||
"deleteConfirmMessage": "This post will be permanently deleted.",
|
||||
"deleteError": "Couldn't delete this post. Please try again."
|
||||
"deleteError": "Couldn't delete this post. Please try again.",
|
||||
"unsupportedFormat": "Some photos use a format PawFeed cannot process and were skipped."
|
||||
},
|
||||
"story": {
|
||||
"changePhotoHint": "Tap to choose a different photo",
|
||||
"shareButton": "Share story"
|
||||
"changeVideoHint": "Tap to choose a different video",
|
||||
"shareButton": "Share story",
|
||||
"videoTooLong": "Story videos can be at most {{max}} seconds long.",
|
||||
"preparingUpload": "Preparing upload …",
|
||||
"uploadingVideo": "Uploading video … {{percent}}%"
|
||||
},
|
||||
"milestoneTypes": {
|
||||
"BIRTHDAY": "Birthday",
|
||||
@@ -133,19 +140,25 @@
|
||||
"manageDesc": "Revoking immediately and permanently deletes all health data for this pet.",
|
||||
"revokeLink": "Revoke",
|
||||
"weightTitle": "Weight",
|
||||
"weightDatePlaceholder": "DD-MM-YYYY",
|
||||
"weightDatePlaceholder": "DD.MM.YYYY",
|
||||
"weightKgPlaceholder": "Weight (kg), e.g. 4.2",
|
||||
"notesPlaceholder": "Notes (optional)",
|
||||
"weightEmpty": "No weight entries yet",
|
||||
"invalidDate": "Please enter a valid date in DD-MM-YYYY format.",
|
||||
"weightChartTitle": "History",
|
||||
"weightChartSinceFirst": "since first entry",
|
||||
"weightChartA11y": "Weight history from {{from}} on {{fromDate}} to {{to}} on {{toDate}}. Tap to select an entry.",
|
||||
"invalidDate": "Please enter a valid date in DD.MM.YYYY format.",
|
||||
"vetTitle": "Vet",
|
||||
"vetReasonPlaceholder": "Reason, e.g. annual checkup",
|
||||
"vetNamePlaceholder": "Vet / clinic (optional)",
|
||||
"vetEmpty": "No vet visits yet",
|
||||
"vaccineTitle": "Vaccinations",
|
||||
"vaccineNamePlaceholder": "Vaccine, e.g. rabies, DHPP",
|
||||
"vaccineGivenPlaceholder": "Given on (DD-MM-YYYY)",
|
||||
"vaccineNextDuePlaceholder": "Next due (optional, DD-MM-YYYY)",
|
||||
"vaccineGivenPlaceholder": "Given on (DD.MM.YYYY)",
|
||||
"vaccineNextDuePlaceholder": "Next due (optional, DD.MM.YYYY)",
|
||||
"vaccineNextDuePreset_one": "+{{count}} year",
|
||||
"vaccineNextDuePreset_other": "+{{count}} years",
|
||||
"vaccineReminderHint": "With a next due date, we'll remind you 30 days ahead.",
|
||||
"vaccineEmpty": "No vaccinations recorded yet",
|
||||
"vaccineGiven": "Given {{date}}",
|
||||
"vaccineDue": "Due {{date}}",
|
||||
@@ -179,9 +192,13 @@
|
||||
"messageButton": "Message",
|
||||
"adoptionStoryTitle": "Adoption story",
|
||||
"noFollowers": "No followers yet.",
|
||||
"noFollowing": "Not following anyone yet."
|
||||
"noFollowing": "Not following anyone yet.",
|
||||
"reportPet": "Report pet",
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"notifications": {
|
||||
"vaccineDue": "💉 The \"{{name}}\" vaccination is due on {{date}} – time to book an appointment.",
|
||||
"vaccineDueGeneric": "💉 A vaccination is due soon.",
|
||||
"follow": "is now following you.",
|
||||
"reaction": "pawed your post.",
|
||||
"comment": "commented on your post.",
|
||||
@@ -191,7 +208,14 @@
|
||||
"birthday": "🎂 Happy birthday!",
|
||||
"adoptionDay": "🏠 Today is adoption day!",
|
||||
"petSuggestion": "might be a good match!",
|
||||
"empty": "No notifications."
|
||||
"empty": "No notifications.",
|
||||
"referralJoined": "joined PawFeed through your invite.",
|
||||
"referralRankUp": "You reached a new referral rank!",
|
||||
"storyMention": "tagged you in a story.",
|
||||
"storyComment": "commented on your story.",
|
||||
"storyReaction": "gave your story a paw.",
|
||||
"unknown": "New notification.",
|
||||
"petDeceased": "has sadly passed away. In memory 🕯️"
|
||||
},
|
||||
"pets": {
|
||||
"empty": "No pets added yet.",
|
||||
@@ -240,7 +264,9 @@
|
||||
"googleButton": "Continue with Google",
|
||||
"orDivider": "or",
|
||||
"googleSignInError": "Google sign-in failed. Please try again.",
|
||||
"googleNeedsSignUpError": "There's no PawFeed account for this Google account yet. Please continue via \"Sign up\" to accept the Terms of Service."
|
||||
"googleNeedsSignUpError": "There's no PawFeed account for this Google account yet. Please continue via \"Sign up\" to accept the Terms of Service.",
|
||||
"appleSignInError": "Apple sign-in failed. Please try again.",
|
||||
"appleNeedsSignUpError": "There's no PawFeed account for this Apple ID yet. Please continue via \"Sign up\" to accept the Terms of Service."
|
||||
},
|
||||
"onboarding": {
|
||||
"addPetTitle": "Add pet",
|
||||
@@ -307,6 +333,178 @@
|
||||
"settings": "Settings",
|
||||
"newPost": "New post",
|
||||
"newStory": "New story",
|
||||
"newMilestone": "New milestone"
|
||||
"newMilestone": "New milestone",
|
||||
"deleteAccount": "Delete account",
|
||||
"invite": "Invite friends"
|
||||
},
|
||||
"block": {
|
||||
"menuItem": "Block {{petName}}",
|
||||
"title": "Block {{petName}}?",
|
||||
"description": "{{petName}}'s posts will be hidden from your feed, and you won't be able to comment on or message each other. They won't know you blocked them.",
|
||||
"cancel": "Don't block",
|
||||
"confirm": "Block",
|
||||
"successToast": "You've blocked {{petName}}.",
|
||||
"errorToast": "Couldn't block this pet. Please try again."
|
||||
},
|
||||
"deleteAccount": {
|
||||
"trigger": "Delete account",
|
||||
"warningTitle": "Really delete your account?",
|
||||
"warningIntro": "This can't be undone. The following will be permanently deleted:",
|
||||
"itemPets": "All pet profiles",
|
||||
"itemPosts": "All posts, comments and reactions",
|
||||
"itemMedia": "All photos and videos",
|
||||
"itemMessages": "All chats and messages",
|
||||
"itemHealth": "All health records",
|
||||
"itemAccount": "Your PawFeed account",
|
||||
"confirmIntro": "Type the name of one of your pets to confirm the deletion:",
|
||||
"inputPlaceholder": "Enter pet name…",
|
||||
"confirmDelete": "Permanently delete account",
|
||||
"finalConfirmTitle": "Final confirmation",
|
||||
"finalConfirmMessage": "Your account and all data will be deleted immediately and permanently. This can't be undone.",
|
||||
"deleting": "Deleting account…",
|
||||
"doneTitle": "Your account has been deleted.",
|
||||
"genericError": "Something went wrong. Please try again."
|
||||
},
|
||||
"referrals": {
|
||||
"title": "Invite friends",
|
||||
"settingsLabel": "Invite link, ranks and invited pets",
|
||||
"subtitle": "Share your link. Every pet that joins through your invite and publishes its first post moves you up a rank.",
|
||||
"programPaused": "The referral program is paused right now. Your link still works, but new invites are not counted at the moment.",
|
||||
"shareHeading": "Your invite link",
|
||||
"shareSubtitle": "Use it as often as you like. People who sign up without the link can still enter your code during onboarding.",
|
||||
"yourCode": "Your code",
|
||||
"share": "Share link",
|
||||
"shareText": "My pet is on PawFeed, the social network for pets. Use my code {{code}} and we are connected right away:",
|
||||
"ranksHeading": "Your ranks",
|
||||
"countedInvites_zero": "No counted invites yet",
|
||||
"countedInvites_one": "{{count}} counted invite",
|
||||
"countedInvites_other": "{{count}} counted invites",
|
||||
"nextRank_one": "{{count}} more invite until {{name}}",
|
||||
"nextRank_other": "{{count}} more invites until {{name}}",
|
||||
"allRanksReached": "You reached every rank. Thank you!",
|
||||
"achieved": "Reached",
|
||||
"threshold_one": "{{count}} invite",
|
||||
"threshold_other": "{{count}} invites",
|
||||
"inviteesHeading": "Joined through you",
|
||||
"inviteesEmpty": "Nobody yet. Share your link with friends who have a pet.",
|
||||
"inviteeNoPet": "No pet created yet",
|
||||
"statusCounted": "Counts",
|
||||
"statusWaiting": "Waiting for first post",
|
||||
"statusInReview": "Under review",
|
||||
"statusRevoked": "Revoked",
|
||||
"redeemHeading": "Were you invited?",
|
||||
"redeemSubtitle": "Enter the code of the person who invited you. This only works during the first days after signing up.",
|
||||
"redeemLabel": "Invite code",
|
||||
"redeemCta": "Redeem",
|
||||
"redeemed": "Redeemed. You and {{name}} now follow each other.",
|
||||
"redeemedGeneric": "Code redeemed.",
|
||||
"errorPaused": "The referral program is paused right now.",
|
||||
"errorInvalid": "That code does not exist. Check the spelling, e.g. BUBI-7K3M.",
|
||||
"errorOwnCode": "That is your own code. Enter the code of the person who invited you.",
|
||||
"errorAlreadyRedeemed": "You already redeemed an invite code.",
|
||||
"errorWindowExpired": "Invite codes can only be redeemed during the first days after signing up.",
|
||||
"errorNoPet": "Create a pet first, then you can redeem the code.",
|
||||
"errorTooMany": "Too many attempts. Please wait a few minutes.",
|
||||
"errorGeneric": "That did not work. Please try again.",
|
||||
"rulesNote": "An invite counts once the invited pet is created and has published its first post. Ranks are digital awards without monetary value. Ranks you reach stay with you unless the program is abused.",
|
||||
"invitedBy": "Invited by",
|
||||
"onboardingTitle": "How did you hear about PawFeed?",
|
||||
"onboardingSubtitle": "Optional. It helps us understand where we reach new pet lovers.",
|
||||
"source_FRIENDS": "Friends or family",
|
||||
"source_TIKTOK": "TikTok",
|
||||
"source_INSTAGRAM": "Instagram",
|
||||
"source_YOUTUBE": "YouTube",
|
||||
"source_FACEBOOK": "Facebook",
|
||||
"source_SHELTER_OR_PARTNER": "Shelter, club or shop",
|
||||
"source_STORE_SEARCH": "App Store or Google Play",
|
||||
"source_PRESS": "Newspaper, radio or blog",
|
||||
"source_OTHER": "Other"
|
||||
},
|
||||
"storyViewer": {
|
||||
"paw": "Give a paw",
|
||||
"removePaw": "Remove paw",
|
||||
"openComments": "Open comments",
|
||||
"commentsTitle": "Comments",
|
||||
"deleteComment": "Delete comment",
|
||||
"commentError": "Your comment could not be sent. Please try again.",
|
||||
"viewersTitle_one": "{{count}} view",
|
||||
"viewersTitle_other": "{{count}} views",
|
||||
"noViewers": "Nobody has seen your story yet.",
|
||||
"deleteStory": "Delete story",
|
||||
"deleteTitle": "Delete story?",
|
||||
"deleteDescription": "The story and all its views, paws and comments will be deleted permanently.",
|
||||
"deleteConfirm": "Delete",
|
||||
"deleteError": "The story could not be deleted. Please try again.",
|
||||
"expiredOwnerOnly": "Expired – only visible to you",
|
||||
"readOnlyExpired": "This story has expired. You can still read comments but not add new ones.",
|
||||
"pawCount_one": "{{count}} paw",
|
||||
"pawCount_other": "{{count}} paws"
|
||||
},
|
||||
"foundingMember": {
|
||||
"label": "Founding member",
|
||||
"tooltip": "Founding member – one of the first 250 pets on PawFeed"
|
||||
},
|
||||
"privacy": {
|
||||
"heading": "Privacy",
|
||||
"whoCanMessage": "Who can message {{name}}?",
|
||||
"everyone": "Everyone",
|
||||
"onlyFollowedPets": "Only pets I follow",
|
||||
"dmFollowersOnlyHint": "Only pets that you follow can send you direct messages.",
|
||||
"dmEveryoneHint": "Any pet on PawFeed can send you direct messages.",
|
||||
"whoCanSeeFollowers": "Who can see {{name}}'s followers?",
|
||||
"onlyMyFollowers": "Only my followers",
|
||||
"onlyMe": "Only me",
|
||||
"followerEveryoneHint": "Anyone can see {{name}}'s followers and following lists and tag {{name}} in stories.",
|
||||
"followerFollowersOnlyHint": "Only pets that follow {{name}} can see the followers and following lists and tag {{name}} in stories.",
|
||||
"followerNobodyHint": "Only you can see {{name}}'s followers and following lists — nobody can tag {{name}} in stories.",
|
||||
"saveFailed": "Failed to save privacy setting"
|
||||
},
|
||||
"storyEditor": {
|
||||
"textStory": "Text story",
|
||||
"addText": "Add text",
|
||||
"addMention": "Tag a pet",
|
||||
"addGif": "Add GIF",
|
||||
"addSticker": "Add sticker",
|
||||
"background": "Change background",
|
||||
"emptyHint": "Add text, @tags, GIFs or stickers — or share right away.",
|
||||
"textPlaceholder": "Write something …",
|
||||
"textStyle": "Text style",
|
||||
"textFont": "Font",
|
||||
"style": {
|
||||
"plain": "Plain",
|
||||
"box": "Box",
|
||||
"soft": "Soft"
|
||||
},
|
||||
"mentionTitle": "Tag a pet",
|
||||
"mentionSearch": "Search pet name",
|
||||
"mentionNoResults": "No pet found that you're allowed to tag.",
|
||||
"mentionPrivacyHint": "Pets whose followers are visible only to their followers or nobody can only be tagged if you follow them, or not at all.",
|
||||
"gifTitle": "Choose a GIF",
|
||||
"gifSearch": "Search GIPHY",
|
||||
"gifError": "GIF search is unavailable right now.",
|
||||
"stickerTitle": "Stickers",
|
||||
"limitOverlays": "At most {{max}} items per story.",
|
||||
"limitMentions": "Tag at most {{max}} pets per story.",
|
||||
"limitGifs": "At most {{max}} GIFs per story."
|
||||
},
|
||||
"inMemory": {
|
||||
"label": "In memory",
|
||||
"tooltip": "In memory – this pet has crossed the rainbow bridge",
|
||||
"banner": "In memory of {{name}} · {{date}}",
|
||||
"settingsHeading": "Memorial mode",
|
||||
"settingsHint": "If your pet has passed away, you can keep their profile as a memorial. It gets the “In memory” badge, posts stay visible and you can keep sharing memories. Birthday notifications, vaccine reminders and suggestions stop.",
|
||||
"activeHint": "{{name}} is in memorial mode. You can correct the date or end the mode.",
|
||||
"dateLabel": "Passed away on",
|
||||
"activate": "Turn on memorial mode",
|
||||
"saveDate": "Save date",
|
||||
"remove": "End memorial mode",
|
||||
"confirmTitle": "Keep {{name}} in memory?",
|
||||
"confirmDescription": "The profile gets the “In memory” badge. Everyone following your pet receives a single quiet notification (no push). You can undo this at any time.",
|
||||
"confirm": "Yes, turn on",
|
||||
"cancel": "Cancel",
|
||||
"saved": "Memorial mode saved",
|
||||
"removed": "Memorial mode ended",
|
||||
"saveFailed": "Could not be saved",
|
||||
"invalidDate": "Please enter a valid date that is not in the future."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { addYearsToDisplayDate, displayDateToIso, formatDateInput, isoToDisplayDate } from "./date-format";
|
||||
|
||||
describe("mobile date-format", () => {
|
||||
it("displays ISO dates with dots", () => {
|
||||
expect(isoToDisplayDate("2026-10-04")).toBe("04.10.2026");
|
||||
});
|
||||
|
||||
it("parses dotted, dashed and slashed input to ISO", () => {
|
||||
expect(displayDateToIso("04.10.2026")).toBe("2026-10-04");
|
||||
expect(displayDateToIso("04-10-2026")).toBe("2026-10-04");
|
||||
expect(displayDateToIso("04/10/2026")).toBe("2026-10-04");
|
||||
});
|
||||
|
||||
it("pads one-digit day and month", () => {
|
||||
expect(displayDateToIso("4.1.2026")).toBe("2026-01-04");
|
||||
});
|
||||
|
||||
it("rejects impossible calendar dates and garbage", () => {
|
||||
expect(displayDateToIso("31.02.2026")).toBeNull();
|
||||
expect(displayDateToIso("2026-10-04")).toBeNull();
|
||||
expect(displayDateToIso("abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("masks typed digits as DD.MM.YYYY", () => {
|
||||
expect(formatDateInput("04")).toBe("04");
|
||||
expect(formatDateInput("041")).toBe("04.1");
|
||||
expect(formatDateInput("0410")).toBe("04.10");
|
||||
expect(formatDateInput("04102026")).toBe("04.10.2026");
|
||||
expect(formatDateInput("041020269")).toBe("04.10.2026");
|
||||
});
|
||||
|
||||
it("lets backspace remove a trailing separator instead of re-adding it", () => {
|
||||
expect(formatDateInput("04.")).toBe("04");
|
||||
});
|
||||
|
||||
it("suggests a next due date some years ahead", () => {
|
||||
expect(addYearsToDisplayDate("04.10.2026", 1)).toBe("04.10.2027");
|
||||
expect(addYearsToDisplayDate("04.10.2026", 3)).toBe("04.10.2029");
|
||||
expect(addYearsToDisplayDate("29.02.2028", 1)).toBe("01.03.2029");
|
||||
expect(addYearsToDisplayDate("", 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,30 @@
|
||||
/**
|
||||
* The health forms take a free-text date field validated against the
|
||||
* backend's strict `YYYY-MM-DD` regex (src/trpc/routers/health.ts). Typing
|
||||
* dates in that order isn't the German convention (DD-MM-YYYY) — found.md:
|
||||
* users typing the natural order got a validation error the mutations never
|
||||
* surfaced (no onError handler), which looked exactly like "data isn't being
|
||||
* saved". Both problems share one fix: accept DD-MM-YYYY in the UI, convert
|
||||
* to ISO right before the mutation call, and reject anything that doesn't
|
||||
* parse as a real calendar date instead of silently sending garbage.
|
||||
* dates in that order isn't the German convention — found.md: users typing
|
||||
* the natural order got a validation error the mutations never surfaced (no
|
||||
* onError handler), which looked exactly like "data isn't being saved". Fix:
|
||||
* accept DD.MM.YYYY in the UI, convert to ISO right before the mutation call,
|
||||
* and reject anything that doesn't parse as a real calendar date instead of
|
||||
* silently sending garbage.
|
||||
*
|
||||
* Display uses "." (found.md Neu7: "-" is unusual in German). Parsing still
|
||||
* accepts "-" and "/" and one-digit day/month so older habits keep working.
|
||||
*/
|
||||
|
||||
const DISPLAY_PATTERN = /^(\d{2})-(\d{2})-(\d{4})$/;
|
||||
const DISPLAY_SEPARATOR = ".";
|
||||
const DISPLAY_PATTERN = /^(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})$/;
|
||||
const ISO_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
const MAX_DATE_DIGITS = 8;
|
||||
|
||||
export function isoToDisplayDate(iso: string): string {
|
||||
const match = ISO_PATTERN.exec(iso);
|
||||
if (!match) return iso;
|
||||
const [, year, month, day] = match;
|
||||
return `${day}-${month}-${year}`;
|
||||
return [day, month, year].join(DISPLAY_SEPARATOR);
|
||||
}
|
||||
|
||||
/** Returns the equivalent `YYYY-MM-DD` string, or null if not a valid DD-MM-YYYY calendar date. */
|
||||
/** Returns the equivalent `YYYY-MM-DD` string, or null if not a valid DD.MM.YYYY calendar date. */
|
||||
export function displayDateToIso(display: string): string | null {
|
||||
const match = DISPLAY_PATTERN.exec(display.trim());
|
||||
if (!match) return null;
|
||||
@@ -33,7 +38,32 @@ export function displayDateToIso(display: string): string | null {
|
||||
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||
if (!isRealDate) return null;
|
||||
|
||||
return `${yearStr}-${monthStr}-${dayStr}`;
|
||||
return `${yearStr}-${monthStr.padStart(2, "0")}-${dayStr.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live mask for typing: keeps digits only and inserts the separators itself
|
||||
* (so the number pad, which has no "." on iOS, is enough). A separator is only
|
||||
* added once a following digit exists — otherwise backspace over "04." would
|
||||
* immediately re-add the dot and the field would get stuck.
|
||||
*/
|
||||
export function formatDateInput(text: string): string {
|
||||
const digits = text.replace(/\D/g, "").slice(0, MAX_DATE_DIGITS);
|
||||
if (digits.length <= 2) return digits;
|
||||
if (digits.length <= 4) return `${digits.slice(0, 2)}${DISPLAY_SEPARATOR}${digits.slice(2)}`;
|
||||
return `${digits.slice(0, 2)}${DISPLAY_SEPARATOR}${digits.slice(2, 4)}${DISPLAY_SEPARATOR}${digits.slice(4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folgetermin-Vorschlag „+1 / +3 Jahre“ für Impfungen. Null bei ungültigem
|
||||
* Ausgangsdatum. 29.02. + 1 Jahr landet (wie Date selbst) auf dem 01.03.
|
||||
*/
|
||||
export function addYearsToDisplayDate(display: string, years: number): string | null {
|
||||
const iso = displayDateToIso(display);
|
||||
if (!iso) return null;
|
||||
const [year, month, day] = iso.split("-").map(Number);
|
||||
const shifted = new Date(Date.UTC(year + years, month - 1, day));
|
||||
return isoToDisplayDate(shifted.toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
export function todayAsDisplayDate(): string {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterOptionsByName, normalizeForSearch } from "./filter-options";
|
||||
|
||||
const breeds = [
|
||||
{ id: "1", name: "Bichon Frisé" },
|
||||
{ id: "2", name: "Shiba Inu" },
|
||||
{ id: "3", name: "Europäisch Kurzhaar" },
|
||||
{ id: "4", name: "Bengalkatze" },
|
||||
];
|
||||
|
||||
describe("filterOptionsByName", () => {
|
||||
it("returns every option for an empty or blank query", () => {
|
||||
expect(filterOptionsByName(breeds, "")).toEqual(breeds);
|
||||
expect(filterOptionsByName(breeds, " ")).toEqual(breeds);
|
||||
});
|
||||
|
||||
it("matches case-insensitively anywhere in the name", () => {
|
||||
expect(filterOptionsByName(breeds, "INU").map((b) => b.id)).toEqual(["2"]);
|
||||
expect(filterOptionsByName(breeds, "be").map((b) => b.id)).toEqual(["4"]);
|
||||
});
|
||||
|
||||
it("ignores accents and umlauts", () => {
|
||||
expect(filterOptionsByName(breeds, "frise").map((b) => b.id)).toEqual(["1"]);
|
||||
expect(filterOptionsByName(breeds, "europaisch").map((b) => b.id)).toEqual(["3"]);
|
||||
});
|
||||
|
||||
it("returns nothing when no name matches", () => {
|
||||
expect(filterOptionsByName(breeds, "pudel")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeForSearch", () => {
|
||||
it("lowercases, trims and strips diacritics", () => {
|
||||
expect(normalizeForSearch(" Bichon Frisé ")).toBe("bichon frise");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Lowercase + strip diacritics, so "eu" finds "Européen" and "BICHON" finds "Bichon Frisé". */
|
||||
export function normalizeForSearch(text: string): string {
|
||||
return text
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Keeps options whose name contains the query; an empty query keeps all of them. */
|
||||
export function filterOptionsByName<T extends { name: string }>(options: T[], query: string): T[] {
|
||||
const needle = normalizeForSearch(query);
|
||||
if (!needle) return options;
|
||||
return options.filter((option) => normalizeForSearch(option.name).includes(needle));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { notificationRoute, targetFromPushData } from "./notification-route";
|
||||
|
||||
const NOW = new Date("2026-10-04T12:00:00Z").getTime();
|
||||
|
||||
describe("notificationRoute", () => {
|
||||
it("opens the conversation for messages", () => {
|
||||
expect(notificationRoute({ type: "MESSAGE", conversationId: "c1" }, NOW)).toBe("/messages/c1");
|
||||
expect(notificationRoute({ type: "MESSAGE" }, NOW)).toBe("/messages");
|
||||
});
|
||||
|
||||
it("opens the post for reactions, comments, mentions and paw-backs", () => {
|
||||
for (const type of ["REACTION", "COMMENT", "MENTION", "PAW_BACK"]) {
|
||||
expect(notificationRoute({ type, postId: "p1" }, NOW)).toBe("/posts/p1/comments");
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the actor's profile for follows, suggestions, referrals and memorials", () => {
|
||||
for (const type of ["FOLLOW", "PET_SUGGESTION", "REFERRAL_JOINED", "PET_DECEASED"]) {
|
||||
expect(notificationRoute({ type, actorPetId: "a1" }, NOW)).toBe("/pets/a1");
|
||||
}
|
||||
expect(notificationRoute({ type: "REFERRAL_RANK_UP" }, NOW)).toBe("/invite");
|
||||
});
|
||||
|
||||
it("opens the own pet's health page for birthdays, adoption days and vaccines", () => {
|
||||
for (const type of ["BIRTHDAY", "ADOPTION_DAY", "VACCINE_DUE"]) {
|
||||
expect(notificationRoute({ type, recipientPetId: "me" }, NOW)).toBe("/pets/me/health");
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the tagged story directly, at the right story", () => {
|
||||
const route = notificationRoute({ type: "STORY_MENTION", recipientPetId: "me", actorPetId: "tagger", storyId: "s1" }, NOW)!;
|
||||
expect(route.startsWith("/story-viewer?")).toBe(true);
|
||||
const params = new URLSearchParams(route.split("?")[1]);
|
||||
expect(params.get("petId")).toBe("tagger");
|
||||
expect(params.get("storyId")).toBe("s1");
|
||||
});
|
||||
|
||||
it("comments and paws on the own story open the own story", () => {
|
||||
const route = notificationRoute({ type: "STORY_COMMENT", recipientPetId: "me", actorPetId: "fan", storyId: "s2" }, NOW)!;
|
||||
expect(new URLSearchParams(route.split("?")[1]).get("petId")).toBe("me");
|
||||
});
|
||||
|
||||
it("falls back to the profile when someone else's story has expired", () => {
|
||||
expect(
|
||||
notificationRoute(
|
||||
{ type: "STORY_MENTION", recipientPetId: "me", storyId: "s1", storyPetId: "tagger", storyExpiresAt: "2026-10-03T00:00:00Z" },
|
||||
NOW
|
||||
)
|
||||
).toBe("/pets/tagger");
|
||||
});
|
||||
|
||||
it("returns null for unknown types", () => {
|
||||
expect(notificationRoute({ type: "SOMETHING_NEW" }, NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("targetFromPushData", () => {
|
||||
it("reads the server's push payload and treats empty strings as missing", () => {
|
||||
expect(targetFromPushData({ type: "MESSAGE", petId: "me", actorPetId: "a", postId: "", storyId: "", conversationId: "c1" })).toEqual({
|
||||
type: "MESSAGE",
|
||||
recipientPetId: "me",
|
||||
actorPetId: "a",
|
||||
postId: null,
|
||||
storyId: null,
|
||||
conversationId: "c1",
|
||||
});
|
||||
expect(targetFromPushData(undefined)).toBeNull();
|
||||
expect(targetFromPushData({ foo: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Wohin führt eine Mitteilung? Eine Regel für die Mitteilungsliste und für das
|
||||
* Antippen einer Push-Mitteilung (Tester-Wunsch 04.10.: „Benachrichtigung bringt
|
||||
* dich auf kürzestem Weg dorthin, wo es wichtig ist“).
|
||||
*
|
||||
* Push-Daten kommen von src/lib/notify.ts (Server): type, petId (Empfänger),
|
||||
* actorPetId, postId, storyId, conversationId — fehlende Felder als "".
|
||||
*/
|
||||
export type NotificationTarget = {
|
||||
type: string;
|
||||
/** Empfänger-Tier (eigenes Tier) */
|
||||
recipientPetId?: string | null;
|
||||
actorPetId?: string | null;
|
||||
postId?: string | null;
|
||||
conversationId?: string | null;
|
||||
storyId?: string | null;
|
||||
/** Tier, dem die Story gehört — aus der Liste bekannt, beim Push hergeleitet */
|
||||
storyPetId?: string | null;
|
||||
storyPetName?: string | null;
|
||||
storyPetAvatarKey?: string | null;
|
||||
/** Ablauf der Story, falls bekannt (Liste); beim Push unbekannt → Viewer prüft selbst */
|
||||
storyExpiresAt?: string | Date | null;
|
||||
};
|
||||
|
||||
const STORY_TYPES = new Set(["STORY_MENTION", "STORY_COMMENT", "STORY_REACTION"]);
|
||||
|
||||
export function notificationRoute(target: NotificationTarget, now: number): string | null {
|
||||
const { type, recipientPetId, actorPetId, postId, conversationId } = target;
|
||||
|
||||
if (STORY_TYPES.has(type) && target.storyId) {
|
||||
// Markierung: Story gehört dem markierenden Tier; Kommentar/Pfote: der eigenen.
|
||||
const storyPetId = target.storyPetId || (type === "STORY_MENTION" ? actorPetId : recipientPetId);
|
||||
if (!storyPetId) return null;
|
||||
const isOwnStory = storyPetId === recipientPetId;
|
||||
const expired = target.storyExpiresAt ? new Date(target.storyExpiresAt).getTime() <= now : false;
|
||||
if (expired && !isOwnStory) return `/pets/${storyPetId}`;
|
||||
const params = new URLSearchParams({ petId: storyPetId, storyId: target.storyId });
|
||||
if (target.storyPetName) params.set("petName", target.storyPetName);
|
||||
if (target.storyPetAvatarKey) params.set("petAvatarKey", target.storyPetAvatarKey);
|
||||
return `/story-viewer?${params.toString()}`;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "MESSAGE":
|
||||
return conversationId ? `/messages/${conversationId}` : "/messages";
|
||||
case "REACTION":
|
||||
case "COMMENT":
|
||||
case "MENTION":
|
||||
case "PAW_BACK":
|
||||
return postId ? `/posts/${postId}/comments` : null;
|
||||
case "FOLLOW":
|
||||
case "PET_SUGGESTION":
|
||||
case "REFERRAL_JOINED":
|
||||
case "PET_DECEASED":
|
||||
return actorPetId ? `/pets/${actorPetId}` : null;
|
||||
case "REFERRAL_RANK_UP":
|
||||
return "/invite";
|
||||
case "BIRTHDAY":
|
||||
case "ADOPTION_DAY":
|
||||
case "VACCINE_DUE":
|
||||
return recipientPetId ? `/pets/${recipientPetId}/health` : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Push-Daten (alles Strings, leer = fehlt) → Ziel. */
|
||||
export function targetFromPushData(data: Record<string, unknown> | null | undefined): NotificationTarget | null {
|
||||
if (!data || typeof data.type !== "string") return null;
|
||||
const str = (key: string) => (typeof data[key] === "string" && data[key] ? (data[key] as string) : null);
|
||||
return {
|
||||
type: data.type,
|
||||
recipientPetId: str("petId"),
|
||||
actorPetId: str("actorPetId"),
|
||||
postId: str("postId"),
|
||||
storyId: str("storyId"),
|
||||
conversationId: str("conversationId"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createElement } from "react";
|
||||
import {
|
||||
Award,
|
||||
Crown,
|
||||
Flame,
|
||||
Gem,
|
||||
Heart,
|
||||
Leaf,
|
||||
Medal,
|
||||
PawPrint,
|
||||
Rocket,
|
||||
Shield,
|
||||
Star,
|
||||
Trophy,
|
||||
type LucideIcon,
|
||||
type LucideProps,
|
||||
} from "lucide-react-native";
|
||||
|
||||
/**
|
||||
* Gegenstück zu REFERRAL_ICON_KEYS in src/lib/referral-ranks.ts (Web). Die
|
||||
* Schlüssel müssen dort und hier identisch bleiben — das Admin-Panel bietet
|
||||
* nur Symbole an, die es in beiden lucide-Paketen gibt.
|
||||
*/
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
"paw-print": PawPrint,
|
||||
heart: Heart,
|
||||
shield: Shield,
|
||||
star: Star,
|
||||
award: Award,
|
||||
crown: Crown,
|
||||
gem: Gem,
|
||||
medal: Medal,
|
||||
trophy: Trophy,
|
||||
flame: Flame,
|
||||
rocket: Rocket,
|
||||
leaf: Leaf,
|
||||
};
|
||||
|
||||
/** Rang-Symbol per Schlüssel; unbekannte Schlüssel fallen auf die Pfote zurück. */
|
||||
export function ReferralIcon({ icon, ...props }: { icon: string } & LucideProps) {
|
||||
return createElement(ICONS[icon] ?? PawPrint, props);
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
* TS type — always coerce with `new Date(...)` before comparing.
|
||||
*/
|
||||
export function isActiveSponsor(owner: {
|
||||
sponsorSince: Date | string | null;
|
||||
sponsorPeriodEnd: Date | string | null;
|
||||
sponsorSince?: Date | string | null;
|
||||
sponsorPeriodEnd?: Date | string | null;
|
||||
}): boolean {
|
||||
if (!owner.sponsorSince) return false;
|
||||
if (!owner.sponsorPeriodEnd) return true;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Story-Editor: Web/Server und App haben je eine Kopie der Overlay-Konstanten,
|
||||
* Sticker und Geometrie (die App kann nichts aus src/ importieren, der
|
||||
* Docker-Build nichts aus mobile/). Dieser Test hält beide identisch. Er liegt
|
||||
* bewusst in mobile/ (vom Docker-Build und der Web-tsconfig ausgeschlossen) und
|
||||
* läuft über das Root-Vitest mit.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as web from "@/lib/story-overlays";
|
||||
import * as webStickers from "@/lib/story-stickers";
|
||||
import * as app from "./story-overlays";
|
||||
import * as appStickers from "./story-stickers";
|
||||
|
||||
describe("Story-Overlay-Spiegel Web ↔ App", () => {
|
||||
it("has identical constants", () => {
|
||||
for (const key of [
|
||||
"MAX_OVERLAYS",
|
||||
"MAX_GIFS",
|
||||
"MAX_MENTIONS",
|
||||
"MAX_TEXT_LENGTH",
|
||||
"TEXT_COLORS",
|
||||
"TEXT_STYLES",
|
||||
"TEXT_FONTS",
|
||||
"STORY_BACKGROUNDS",
|
||||
"STORY_BACKGROUND_KEYS",
|
||||
"TEXT_STORY_ASPECT",
|
||||
"OVERLAY_SIZES",
|
||||
"MIN_SCALE",
|
||||
"MAX_SCALE",
|
||||
] as const) {
|
||||
expect(app[key], key).toEqual(web[key]);
|
||||
}
|
||||
});
|
||||
|
||||
it("has identical stickers", () => {
|
||||
expect(appStickers.STORY_STICKERS).toEqual(webStickers.STORY_STICKERS);
|
||||
expect(appStickers.STICKER_OUTLINE).toEqual(webStickers.STICKER_OUTLINE);
|
||||
});
|
||||
|
||||
it("computes identical geometry", () => {
|
||||
const cases: [number, number, number, number][] = [
|
||||
[400, 800, 1080, 1350],
|
||||
[390, 844, 1920, 1080],
|
||||
[1280, 720, 9, 16],
|
||||
[0, 100, 10, 10],
|
||||
];
|
||||
for (const c of cases) expect(app.containRect(...c)).toEqual(web.containRect(...c));
|
||||
const rect = { x: 10, y: 20, width: 300, height: 500 };
|
||||
expect(app.overlayCenter(rect, { x: 0.25, y: 0.75 })).toEqual(web.overlayCenter(rect, { x: 0.25, y: 0.75 }));
|
||||
for (const color of web.TEXT_COLORS) expect(app.contrastTextColor(color)).toBe(web.contrastTextColor(color));
|
||||
for (const story of [{ mediaType: "TEXT" }, { mediaType: "VIDEO", aspectRatio: "16:9" }, { mediaType: "IMAGE", mediaWidth: 3, mediaHeight: 4 }]) {
|
||||
expect(app.storyMediaSize(story)).toEqual(web.storyMediaSize(story));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Story-Editor — App-Spiegel von src/lib/story-overlays.ts (Web/Server).
|
||||
* Konstanten und Geometrie müssen identisch bleiben, damit Overlays in App und
|
||||
* Web an derselben Stelle sitzen (src/__tests__/story-overlays-parity.test.ts
|
||||
* prüft das). Validiert wird nur auf dem Server; hier gibt es deshalb kein Zod.
|
||||
*
|
||||
* Positionen sind relativ zur Medienfläche (0–1), Größen relativ zu deren Breite.
|
||||
*/
|
||||
|
||||
export const MAX_OVERLAYS = 15;
|
||||
export const MAX_GIFS = 3;
|
||||
export const MAX_MENTIONS = 10;
|
||||
export const MAX_TEXT_LENGTH = 200;
|
||||
|
||||
export const TEXT_COLORS = ["#ffffff", "#171717", "#f97316", "#facc15", "#22c55e", "#3b82f6", "#ec4899", "#a855f7"] as const;
|
||||
export const TEXT_STYLES = ["plain", "box", "soft"] as const;
|
||||
export const TEXT_FONTS = ["regular", "bold"] as const;
|
||||
|
||||
export const STORY_BACKGROUNDS: Record<string, readonly [string, string]> = {
|
||||
sunset: ["#f97316", "#ec4899"],
|
||||
ocean: ["#0ea5e9", "#6366f1"],
|
||||
forest: ["#22c55e", "#0f766e"],
|
||||
night: ["#1e1b4b", "#7c3aed"],
|
||||
peach: ["#fdba74", "#fde68a"],
|
||||
ink: ["#262626", "#525252"],
|
||||
};
|
||||
export const STORY_BACKGROUND_KEYS = Object.keys(STORY_BACKGROUNDS);
|
||||
|
||||
export const TEXT_STORY_ASPECT = { width: 9, height: 16 } as const;
|
||||
|
||||
export const OVERLAY_SIZES = {
|
||||
textFont: 0.07,
|
||||
textMaxWidth: 0.82,
|
||||
mentionFont: 0.055,
|
||||
sticker: 0.26,
|
||||
gifWidth: 0.42,
|
||||
} as const;
|
||||
export const MIN_SCALE = 0.3;
|
||||
export const MAX_SCALE = 4;
|
||||
|
||||
export type TextColor = (typeof TEXT_COLORS)[number];
|
||||
export type TextStyle = (typeof TEXT_STYLES)[number];
|
||||
export type TextFont = (typeof TEXT_FONTS)[number];
|
||||
|
||||
type Base = { x: number; y: number; scale: number; rotation: number };
|
||||
export type TextOverlay = Base & { kind: "text"; text: string; color: TextColor; style: TextStyle; font: TextFont };
|
||||
export type MentionOverlay = Base & { kind: "mention"; petId: string };
|
||||
export type GifOverlay = Base & { kind: "gif"; gifId: string; url: string; width: number; height: number };
|
||||
export type StickerOverlay = Base & { kind: "sticker"; stickerId: string };
|
||||
export type StoryOverlay = TextOverlay | MentionOverlay | GifOverlay | StickerOverlay;
|
||||
|
||||
export type Rect = { x: number; y: number; width: number; height: number };
|
||||
|
||||
export function containRect(containerWidth: number, containerHeight: number, mediaWidth: number, mediaHeight: number): Rect {
|
||||
if (containerWidth <= 0 || containerHeight <= 0 || mediaWidth <= 0 || mediaHeight <= 0) {
|
||||
return { x: 0, y: 0, width: Math.max(containerWidth, 0), height: Math.max(containerHeight, 0) };
|
||||
}
|
||||
const scale = Math.min(containerWidth / mediaWidth, containerHeight / mediaHeight);
|
||||
const width = mediaWidth * scale;
|
||||
const height = mediaHeight * scale;
|
||||
return { x: (containerWidth - width) / 2, y: (containerHeight - height) / 2, width, height };
|
||||
}
|
||||
|
||||
export function overlayCenter(rect: Rect, overlay: { x: number; y: number }): { left: number; top: number } {
|
||||
return { left: rect.x + overlay.x * rect.width, top: rect.y + overlay.y * rect.height };
|
||||
}
|
||||
|
||||
export function contrastTextColor(background: string): "#171717" | "#ffffff" {
|
||||
const hex = background.replace("#", "");
|
||||
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
return luminance > 0.6 ? "#171717" : "#ffffff";
|
||||
}
|
||||
|
||||
export function storyMediaSize(story: {
|
||||
mediaType: string;
|
||||
mediaWidth?: number | null;
|
||||
mediaHeight?: number | null;
|
||||
aspectRatio?: string | null;
|
||||
}): { width: number; height: number } | null {
|
||||
if (story.mediaType === "TEXT") return { ...TEXT_STORY_ASPECT };
|
||||
if (story.mediaWidth && story.mediaHeight) return { width: story.mediaWidth, height: story.mediaHeight };
|
||||
const match = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(story.aspectRatio ?? "");
|
||||
if (match) return { width: Number(match[1]), height: Number(match[2]) };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Nur App (Editor) ─────────────────────────────────────────────────────────
|
||||
|
||||
export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
/**
|
||||
* Neue Lage eines Overlays aus einer Geste: Verschiebung in Pixeln (dx/dy),
|
||||
* Zwei-Finger-Zoom (pinch = aktueller / Start-Fingerabstand) und Drehung
|
||||
* (Grad). Bleibt innerhalb der Medienfläche und der erlaubten Größe.
|
||||
*/
|
||||
export function applyGesture<T extends Base>(
|
||||
start: T,
|
||||
rect: Rect,
|
||||
gesture: { dx: number; dy: number; pinch: number; rotationDelta: number }
|
||||
): T {
|
||||
if (rect.width <= 0 || rect.height <= 0) return start;
|
||||
return {
|
||||
...start,
|
||||
x: clamp(start.x + gesture.dx / rect.width, 0, 1),
|
||||
y: clamp(start.y + gesture.dy / rect.height, 0, 1),
|
||||
scale: clamp(start.scale * gesture.pinch, MIN_SCALE, MAX_SCALE),
|
||||
rotation: normalizeRotation(start.rotation + gesture.rotationDelta),
|
||||
};
|
||||
}
|
||||
|
||||
/** Auf -180…180 Grad bringen (Server erlaubt -360…360). */
|
||||
export function normalizeRotation(deg: number): number {
|
||||
let r = deg % 360;
|
||||
if (r > 180) r -= 360;
|
||||
if (r < -180) r += 360;
|
||||
return Math.round(r * 10) / 10;
|
||||
}
|
||||
|
||||
/** Fingerabstand und -winkel zweier Berührungen (für Zoom/Drehen). */
|
||||
export function touchGeometry(a: { pageX: number; pageY: number }, b: { pageX: number; pageY: number }) {
|
||||
const dx = b.pageX - a.pageX;
|
||||
const dy = b.pageY - a.pageY;
|
||||
return { distance: Math.hypot(dx, dy), angle: (Math.atan2(dy, dx) * 180) / Math.PI };
|
||||
}
|
||||
|
||||
/** Winkeldifferenz ohne Sprung bei ±180°. */
|
||||
export function angleDelta(from: number, to: number): number {
|
||||
let d = to - from;
|
||||
if (d > 180) d -= 360;
|
||||
if (d < -180) d += 360;
|
||||
return d;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Story-Sticker (Story-Editor). Reine Formdaten im 100×100-Raster, damit Web
|
||||
* (DOM-SVG) und App (react-native-svg) exakt dasselbe zeichnen. Jeder Sticker
|
||||
* wird zweimal gezeichnet: erst alle Teile als weißer Umriss (STICKER_OUTLINE),
|
||||
* dann in Farbe — das gibt den typischen Aufkleber-Look auf jedem Hintergrund.
|
||||
*
|
||||
* ⚠️ Spiegel von src/lib/story-stickers.ts (Web/Server) — identisch halten
|
||||
* (src/__tests__/story-overlays-parity.test.ts prüft das).
|
||||
*/
|
||||
|
||||
export type StickerPart =
|
||||
| { type: "circle"; cx: number; cy: number; r: number; fill: string }
|
||||
| { type: "rect"; x: number; y: number; width: number; height: number; rx: number; fill: string }
|
||||
| { type: "path"; d: string; fill: string };
|
||||
|
||||
export type StickerDef = { id: string; label: string; parts: StickerPart[] };
|
||||
|
||||
export const STICKER_OUTLINE = { color: "#ffffff", width: 8 } as const;
|
||||
|
||||
export const STORY_STICKERS: StickerDef[] = [
|
||||
{
|
||||
id: "paw",
|
||||
label: "Pfote",
|
||||
parts: [
|
||||
{ type: "circle", cx: 50, cy: 64, r: 20, fill: "#f97316" },
|
||||
{ type: "circle", cx: 22, cy: 42, r: 9, fill: "#f97316" },
|
||||
{ type: "circle", cx: 38, cy: 26, r: 10, fill: "#f97316" },
|
||||
{ type: "circle", cx: 62, cy: 26, r: 10, fill: "#f97316" },
|
||||
{ type: "circle", cx: 78, cy: 42, r: 9, fill: "#f97316" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "heart",
|
||||
label: "Herz",
|
||||
parts: [{ type: "path", d: "M50 86 C22 66 8 50 13 32 C18 15 40 12 50 29 C60 12 82 15 87 32 C92 50 78 66 50 86 Z", fill: "#ec4899" }],
|
||||
},
|
||||
{
|
||||
id: "bone",
|
||||
label: "Knochen",
|
||||
parts: [
|
||||
{ type: "rect", x: 24, y: 41, width: 52, height: 18, rx: 4, fill: "#fde68a" },
|
||||
{ type: "circle", cx: 22, cy: 40, r: 11, fill: "#fde68a" },
|
||||
{ type: "circle", cx: 22, cy: 60, r: 11, fill: "#fde68a" },
|
||||
{ type: "circle", cx: 78, cy: 40, r: 11, fill: "#fde68a" },
|
||||
{ type: "circle", cx: 78, cy: 60, r: 11, fill: "#fde68a" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "star",
|
||||
label: "Stern",
|
||||
parts: [{ type: "path", d: "M50 8 L61 37 L93 38 L68 58 L77 90 L50 72 L23 90 L32 58 L7 38 L39 37 Z", fill: "#facc15" }],
|
||||
},
|
||||
{
|
||||
id: "sparkle",
|
||||
label: "Funkeln",
|
||||
parts: [
|
||||
{ type: "path", d: "M44 10 C47 34 54 41 78 44 C54 47 47 54 44 78 C41 54 34 47 10 44 C34 41 41 34 44 10 Z", fill: "#a855f7" },
|
||||
{ type: "path", d: "M78 62 C79 72 82 75 92 76 C82 77 79 80 78 90 C77 80 74 77 64 76 C74 75 77 72 78 62 Z", fill: "#a855f7" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fish",
|
||||
label: "Fisch",
|
||||
parts: [
|
||||
{ type: "path", d: "M74 50 L92 34 L92 66 Z", fill: "#3b82f6" },
|
||||
{ type: "path", d: "M12 50 C24 28 58 26 76 50 C58 74 24 72 12 50 Z", fill: "#3b82f6" },
|
||||
{ type: "circle", cx: 30, cy: 46, r: 5, fill: "#171717" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "crown",
|
||||
label: "Krone",
|
||||
parts: [
|
||||
{ type: "path", d: "M14 76 L18 30 L36 50 L50 22 L64 50 L82 30 L86 76 Z", fill: "#facc15" },
|
||||
{ type: "circle", cx: 50, cy: 62, r: 6, fill: "#ec4899" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sun",
|
||||
label: "Sonne",
|
||||
parts: [
|
||||
{ type: "path", d: "M50 4 L57 22 L43 22 Z M50 96 L43 78 L57 78 Z M4 50 L22 43 L22 57 Z M96 50 L78 57 L78 43 Z M17 17 L33 26 L26 33 Z M83 83 L67 74 L74 67 Z M83 17 L74 33 L67 26 Z M17 83 L26 67 L33 74 Z", fill: "#fb923c" },
|
||||
{ type: "circle", cx: 50, cy: 50, r: 22, fill: "#facc15" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const STICKER_IDS = STORY_STICKERS.map((s) => s.id);
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Mirrors src/lib/story-video-limits.ts on web — mobile and web don't share
|
||||
* a package, so this value is duplicated. Keep both in sync manually if the
|
||||
* limit ever changes.
|
||||
*/
|
||||
export const MAX_STORY_VIDEO_DURATION_SECS = 30;
|
||||
@@ -20,3 +20,33 @@ export async function uploadImageToSupabase(uri: string, presignedUrl: string, m
|
||||
throw new Error(`Upload fehlgeschlagen (Status ${result.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUTs a local video file directly to a Mux direct-upload URL (from
|
||||
* `stories.createVideoUpload`). Separate function rather than reusing
|
||||
* `uploadImageToSupabase` above — same `File.upload` primitive, but a
|
||||
* different destination (Mux, not Supabase) and no signature-matching
|
||||
* requirement on Content-Type, so keeping the working photo path untouched.
|
||||
*/
|
||||
export async function uploadFileToUrl(
|
||||
uri: string,
|
||||
uploadUrl: string,
|
||||
mimeType: string,
|
||||
onProgress?: (fraction: number) => void
|
||||
): Promise<void> {
|
||||
const file = new File(uri);
|
||||
const result = await file.upload(uploadUrl, {
|
||||
httpMethod: "PUT",
|
||||
mimeType,
|
||||
headers: { "Content-Type": mimeType },
|
||||
onProgress: onProgress
|
||||
? ({ bytesSent, totalBytes }) => {
|
||||
if (totalBytes > 0) onProgress(Math.min(bytesSent / totalBytes, 1));
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
throw new Error(`Video-Upload fehlgeschlagen (Status ${result.status})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildWeightChart, nearestPointIndex } from "./weight-chart";
|
||||
|
||||
const box = { width: 300, height: 180, left: 40, right: 10, top: 10, bottom: 30 };
|
||||
|
||||
describe("buildWeightChart", () => {
|
||||
it("needs at least two entries", () => {
|
||||
expect(buildWeightChart([], box)).toBeNull();
|
||||
expect(buildWeightChart([{ date: "2026-01-01", weightGrams: 4000 }], box)).toBeNull();
|
||||
});
|
||||
|
||||
it("sorts by date and spaces x by time, not by index", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-31", weightGrams: 4200 },
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-01-04", weightGrams: 4100 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.points.map((p) => p.kg)).toEqual([4, 4.1, 4.2]);
|
||||
expect(chart.points[0].x).toBe(40);
|
||||
expect(chart.points[2].x).toBe(290);
|
||||
// 3 of 30 days → 10 % of the 250px plot width, not the middle.
|
||||
expect(chart.points[1].x).toBeCloseTo(65, 5);
|
||||
});
|
||||
|
||||
it("puts heavier weights higher and reports the change since the first entry", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-02-01", weightGrams: 4350 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.points[1].y).toBeLessThan(chart.points[0].y);
|
||||
expect(chart.deltaKg).toBeCloseTo(0.35, 5);
|
||||
});
|
||||
|
||||
it("keeps a flat series inside the plot and never goes below 0 kg", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 300 },
|
||||
{ date: "2026-01-02", weightGrams: 300 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
for (const p of chart.points) {
|
||||
expect(p.y).toBeGreaterThan(box.top);
|
||||
expect(p.y).toBeLessThan(chart.baselineY);
|
||||
}
|
||||
expect(chart.yTicks[0].kg).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to index spacing when all entries share a date", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-01-01", weightGrams: 4100 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.points.map((p) => p.x)).toEqual([40, 290]);
|
||||
});
|
||||
|
||||
it("labels at most a handful of dates and always the newest", () => {
|
||||
const entries = Array.from({ length: 12 }, (_, i) => ({ date: new Date(Date.UTC(2026, 0, 1 + i)), weightGrams: 4000 + i }));
|
||||
const chart = buildWeightChart(entries, box)!;
|
||||
expect(chart.xLabelIndices.length).toBeLessThanOrEqual(4);
|
||||
expect(chart.xLabelIndices).toContain(11);
|
||||
});
|
||||
|
||||
it("never places two date labels closer than a quarter of the plot", () => {
|
||||
// Two weigh-ins a day apart, then one a year later: the first two must not both get a label.
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2025-01-01", weightGrams: 4000 },
|
||||
{ date: "2025-01-02", weightGrams: 4010 },
|
||||
{ date: "2026-01-01", weightGrams: 4500 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
const xs = chart.xLabelIndices.map((i) => chart.points[i].x);
|
||||
for (let i = 1; i < xs.length; i++) expect(xs[i] - xs[i - 1]).toBeGreaterThanOrEqual(250 / 4);
|
||||
expect(chart.xLabelIndices.at(-1)).toBe(2);
|
||||
});
|
||||
|
||||
it("builds closed area and open line paths", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-01-11", weightGrams: 4100 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.linePath.startsWith("M 40 ")).toBe(true);
|
||||
expect(chart.areaPath.endsWith("Z")).toBe(true);
|
||||
expect(chart.areaPath).toContain(` ${chart.baselineY} `);
|
||||
});
|
||||
});
|
||||
|
||||
describe("y axis ticks", () => {
|
||||
it("uses round, distinct tick values even for a tiny range", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-09-01", weightGrams: 4200 },
|
||||
{ date: "2026-10-01", weightGrams: 4150 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
const labels = chart.yTicks.map((t) => t.kg.toFixed(chart.yTickDecimals));
|
||||
expect(new Set(labels).size).toBe(labels.length);
|
||||
expect(chart.yTicks[0].kg).toBeLessThanOrEqual(4.15);
|
||||
expect(chart.yTicks.at(-1)!.kg).toBeGreaterThanOrEqual(4.2);
|
||||
expect(chart.yTickDecimals).toBe(2);
|
||||
});
|
||||
|
||||
it("uses whole kilos for a big dog over a wide range", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2025-01-01", weightGrams: 25000 },
|
||||
{ date: "2026-01-01", weightGrams: 33000 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.yTickDecimals).toBe(0);
|
||||
for (const t of chart.yTicks) expect(Number.isInteger(t.kg)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nearestPointIndex", () => {
|
||||
it("picks the point closest to the tap", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-01-11", weightGrams: 4100 },
|
||||
{ date: "2026-01-21", weightGrams: 4200 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(nearestPointIndex(chart.points, 0)).toBe(0);
|
||||
expect(nearestPointIndex(chart.points, 170)).toBe(1);
|
||||
expect(nearestPointIndex(chart.points, 999)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Pure geometry for the mobile weight-history chart (port of web's
|
||||
* src/components/health/WeightChart.tsx). Kept free of React/SVG so it can
|
||||
* be unit-tested.
|
||||
*
|
||||
* One deliberate difference from web: x is proportional to the date, not to
|
||||
* the entry's index — weigh-ins are irregular, and index spacing would make a
|
||||
* jump over three months look as steep as one over three days.
|
||||
*/
|
||||
|
||||
export type WeightEntry = { date: Date | string; weightGrams: number };
|
||||
|
||||
export type ChartBox = { width: number; height: number; left: number; right: number; top: number; bottom: number };
|
||||
|
||||
export type ChartPoint = { x: number; y: number; kg: number; time: number; entry: WeightEntry };
|
||||
|
||||
export type WeightChartGeometry = {
|
||||
points: ChartPoint[];
|
||||
linePath: string;
|
||||
areaPath: string;
|
||||
yTicks: { y: number; kg: number }[];
|
||||
/** Decimal places that make every y tick label distinct (derived from the tick step). */
|
||||
yTickDecimals: number;
|
||||
xLabelIndices: number[];
|
||||
baselineY: number;
|
||||
deltaKg: number;
|
||||
};
|
||||
|
||||
const TARGET_Y_TICKS = 4;
|
||||
const MAX_X_LABELS = 4;
|
||||
const RANGE_PADDING = 0.18;
|
||||
const FLAT_RANGE_PADDING_KG = 0.5;
|
||||
|
||||
/** Rounds a raw step up to 1, 2 or 5 × 10^n, so tick labels are round numbers (4,0 / 4,2 / 4,4 — not 4,13 / 4,27). */
|
||||
function niceStep(rawStep: number): number {
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
|
||||
const normalized = rawStep / magnitude;
|
||||
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
return nice * magnitude;
|
||||
}
|
||||
|
||||
function smoothPath(points: ChartPoint[]): string {
|
||||
return points
|
||||
.map((p, i) => {
|
||||
if (i === 0) return `M ${p.x} ${p.y}`;
|
||||
const prev = points[i - 1];
|
||||
const cpX = (prev.x + p.x) / 2;
|
||||
return `C ${cpX} ${prev.y} ${cpX} ${p.y} ${p.x} ${p.y}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Date labels chosen by on-screen distance, not by index (x is time-based, so
|
||||
* index spacing could stack labels on top of each other): walk back from the
|
||||
* newest point and keep one whenever it is far enough from the last kept one.
|
||||
*/
|
||||
function pickLabelIndices(points: { x: number }[], plotWidth: number): number[] {
|
||||
const minGap = plotWidth / MAX_X_LABELS;
|
||||
const kept = [points.length - 1];
|
||||
for (let i = points.length - 2; i >= 0 && kept.length < MAX_X_LABELS; i--) {
|
||||
if (points[kept[kept.length - 1]].x - points[i].x >= minGap) kept.push(i);
|
||||
}
|
||||
return kept.reverse();
|
||||
}
|
||||
|
||||
/** Returns null for fewer than two entries — a single weigh-in is not a trend. */
|
||||
export function buildWeightChart(entries: WeightEntry[], box: ChartBox): WeightChartGeometry | null {
|
||||
if (entries.length < 2) return null;
|
||||
|
||||
const sorted = [...entries]
|
||||
.map((entry) => ({ entry, time: new Date(entry.date).getTime(), kg: entry.weightGrams / 1000 }))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
|
||||
const kgs = sorted.map((e) => e.kg);
|
||||
const minKg = Math.min(...kgs);
|
||||
const maxKg = Math.max(...kgs);
|
||||
const rawRange = maxKg - minKg;
|
||||
const pad = rawRange === 0 ? FLAT_RANGE_PADDING_KG : rawRange * RANGE_PADDING;
|
||||
const step = niceStep((maxKg + pad - Math.max(0, minKg - pad)) / TARGET_Y_TICKS);
|
||||
// Snap the padded range outward to whole steps so every gridline sits on a round value.
|
||||
const yMin = Math.max(0, Math.floor((minKg - pad) / step) * step);
|
||||
const yMax = Math.ceil((maxKg + pad) / step) * step;
|
||||
const tickCount = Math.round((yMax - yMin) / step);
|
||||
|
||||
const plotW = box.width - box.left - box.right;
|
||||
const plotH = box.height - box.top - box.bottom;
|
||||
const firstTime = sorted[0].time;
|
||||
const timeSpan = sorted[sorted.length - 1].time - firstTime;
|
||||
|
||||
const xOf = (time: number, index: number) =>
|
||||
box.left +
|
||||
(timeSpan > 0 ? (time - firstTime) / timeSpan : index / (sorted.length - 1)) * plotW;
|
||||
const yOf = (kg: number) => box.top + plotH - ((kg - yMin) / (yMax - yMin)) * plotH;
|
||||
|
||||
const points = sorted.map((e, i) => ({ x: xOf(e.time, i), y: yOf(e.kg), kg: e.kg, time: e.time, entry: e.entry }));
|
||||
const baselineY = box.top + plotH;
|
||||
const linePath = smoothPath(points);
|
||||
const areaPath =
|
||||
`M ${points[0].x} ${baselineY} L ${points[0].x} ${points[0].y} ` +
|
||||
linePath.replace(/^M [^ ]+ [^ ]+ ?/, "") +
|
||||
` L ${points[points.length - 1].x} ${baselineY} Z`;
|
||||
|
||||
const yTicks = Array.from({ length: tickCount + 1 }, (_, i) => {
|
||||
// Rounded to kill float noise (0.1 + 0.2), the step decides the precision.
|
||||
const kg = Number((yMin + step * i).toFixed(6));
|
||||
return { kg, y: yOf(kg) };
|
||||
});
|
||||
|
||||
return {
|
||||
points,
|
||||
linePath,
|
||||
areaPath,
|
||||
yTicks,
|
||||
yTickDecimals: Math.max(0, -Math.floor(Math.log10(step) + 1e-9)),
|
||||
xLabelIndices: pickLabelIndices(points, plotW),
|
||||
baselineY,
|
||||
deltaKg: kgs[kgs.length - 1] - kgs[0],
|
||||
};
|
||||
}
|
||||
|
||||
/** Index of the point closest to a tapped x position. */
|
||||
export function nearestPointIndex(points: ChartPoint[], x: number): number {
|
||||
let best = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (Math.abs(points[i].x - x) < Math.abs(points[best].x - x)) best = i;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -26,6 +26,12 @@ export interface ColorTokens {
|
||||
quietGray: string;
|
||||
alertRed: string;
|
||||
alertRedTint: string;
|
||||
/**
|
||||
* Data line in charts (weight history). Checked with the dataviz palette
|
||||
* validator: >= 3:1 against both surfaces and inside the lightness band in
|
||||
* both modes — the brighter marigoldOrange fails one of the two each way.
|
||||
*/
|
||||
chartLine: string;
|
||||
}
|
||||
|
||||
export const colors: ColorTokens = {
|
||||
@@ -41,6 +47,7 @@ export const colors: ColorTokens = {
|
||||
quietGray: "#737373",
|
||||
alertRed: "#dc2626",
|
||||
alertRedTint: "#fee2e2",
|
||||
chartLine: "#ea580c",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -61,6 +68,7 @@ export const darkColors: ColorTokens = {
|
||||
quietGray: "#a3a3a3",
|
||||
alertRed: "#f87171",
|
||||
alertRedTint: "#450a0a",
|
||||
chartLine: "#ea580c",
|
||||
};
|
||||
|
||||
export const radii = {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# App Store Eintrag — PawFeed (iOS, Deutsch)
|
||||
|
||||
Basis: `beschreibung.md` (Play Store), angepasst an die Felder von App Store Connect.
|
||||
|
||||
## Untertitel (30/30 Zeichen)
|
||||
|
||||
```
|
||||
Das soziale Netz für Haustiere
|
||||
```
|
||||
|
||||
## Werbetext (153/170 Zeichen, jederzeit ohne neue Prüfung änderbar)
|
||||
|
||||
```
|
||||
Gib deinem Haustier die Bühne! Eigenes Profil, Fotos, Videos & Storys, Meilensteine und Gesundheits-Tracking – das Netzwerk, in dem Tiere die Stars sind.
|
||||
```
|
||||
|
||||
## Keywords (99/100 Zeichen, nur Kommas, keine Leerzeichen)
|
||||
|
||||
```
|
||||
haustier,hund,katze,tiere,tierprofil,social,netzwerk,fotos,kaninchen,vogel,reptilien,aquarium,pferd
|
||||
```
|
||||
|
||||
## Beschreibung (2.011/4.000 Zeichen, keine Emojis — App Store Connect lehnt sie ab)
|
||||
|
||||
```
|
||||
PawFeed – das soziale Netzwerk für Haustiere
|
||||
|
||||
Bei PawFeed steht nicht der Mensch im Mittelpunkt, sondern dein Haustier: Jedes Tier bekommt ein eigenes Profil, eigene Follower und eine eigene Geschichte – vom Hund über die Katze bis zur Vogelspinne.
|
||||
|
||||
WAS DU MIT PAWFEED MACHEN KANNST
|
||||
|
||||
Beiträge & Storys
|
||||
Teile Fotos und kurze Videos aus dem Alltag deines Haustiers. Storys verschwinden nach 24 Stunden, Beiträge bleiben dauerhaft im Profil.
|
||||
|
||||
Reaktionen & Paw-Backs
|
||||
Statt „Gefällt mir" gibt's die Pfote – und mit „Paw-Back" teilst du Lieblingsbeiträge anderer Tiere in deinem eigenen Feed.
|
||||
|
||||
Meilensteine
|
||||
Halte besondere Momente fest: Geburtstage, Adoptionstag, den ersten Ausflug oder den ersten Tierarztbesuch.
|
||||
|
||||
Gesundheits-Tracking
|
||||
Behalte Gewicht, Tierarztbesuche und Impfungen im Blick – freiwillig, mit ausdrücklicher Einwilligung, jederzeit widerrufbar und dann vollständig löschbar.
|
||||
|
||||
Nachrichten
|
||||
Schreib anderen Tierprofilen direkt – ideal für Verabredungen zum Spielen oder einfach zum Plaudern.
|
||||
|
||||
Entdecken
|
||||
Finde neue Tiere nach Tierart und Rasse – von Hund und Katze über Kaninchen, Vögel und Reptilien bis zum Aquarium.
|
||||
|
||||
Arachnophobie-Hilfe
|
||||
Kein Fan von Spinnen? Blende Spinnen-Inhalte in Feed, Entdecken und Suche komplett aus.
|
||||
|
||||
Hell oder dunkel
|
||||
PawFeed passt sich deinem iPhone an – mit hellem und dunklem Design, auf Deutsch und Englisch.
|
||||
|
||||
FÜR WELCHE TIERE?
|
||||
Hunde, Katzen, Vögel, Pferde, Kaninchen, Meerschweinchen, Hamster, Mäuse, Ratten, Chinchillas, Schlangen, Echsen, Schildkröten, Geckos, Vogelspinnen und Aquarien.
|
||||
|
||||
SICHER & RESPEKTVOLL
|
||||
Unangemessene Beiträge und Profile kannst du mit einem Tipp melden, andere Tiere blockieren – und dein Konto samt aller Daten jederzeit direkt in der App löschen.
|
||||
|
||||
DATENSCHUTZ ERNST GENOMMEN
|
||||
Gesundheitsdaten werden nur nach ausdrücklicher Einwilligung verarbeitet und lassen sich jederzeit vollständig löschen. Details in unserer Datenschutzerklärung: https://pawfeed.org/datenschutz
|
||||
|
||||
Lade PawFeed herunter und gib deinem Haustier die Bühne, die es verdient.
|
||||
```
|
||||
Loaded 100 of 297 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user