From 1ee2b53a571bdd964ef98272dae9f73226dd0c18 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 15 Aug 2026 11:57:24 +0200 Subject: [PATCH] feat(species): expand taxonomy to 14 species, add Arachnophobie-Helfer Add Horse, Rabbit, Guinea Pig, Hamster, Mouse, Rat, Snake, Lizard, Turtle, Gecko, and Spider as Species (with breed taxonomies + DE translations), alongside the existing Dog/Cat/Bird. The species/breed system was already fully data-driven (pet creation, onboarding, ad targeting all read pets.listSpecies/listBreeds dynamically), so this is mostly seed data. Spiders get dedicated handling: an opt-in Owner.hideSpiders preference (offered once during onboarding via ArachnophobiaOptIn, always re-toggleable on /pets via HideSpidersToggle) fully excludes Species=Spider pets/posts from feed, explore, and search when enabled. --- CLAUDE.md | 6 +- messages/de.json | 11 + messages/en.json | 11 + prisma/schema.prisma | 1 + prisma/seed.ts | 233 +++++++++++++++++++ src/__tests__/explore.test.ts | 15 ++ src/__tests__/feed.test.ts | 25 ++ src/__tests__/owner.test.ts | 36 +++ src/__tests__/search.test.ts | 15 ++ src/app/(app)/pets/page.tsx | 7 +- src/app/onboarding/pet/page.tsx | 12 +- src/components/account/HideSpidersToggle.tsx | 44 ++++ src/components/legal/ArachnophobiaOptIn.tsx | 68 ++++++ src/lib/spider-exclusion.ts | 50 ++++ src/trpc/routers/explore.ts | 9 +- src/trpc/routers/feed.ts | 18 +- src/trpc/routers/owner.ts | 22 ++ src/trpc/routers/search.ts | 11 +- 18 files changed, 583 insertions(+), 11 deletions(-) create mode 100644 src/components/account/HideSpidersToggle.tsx create mode 100644 src/components/legal/ArachnophobiaOptIn.tsx create mode 100644 src/lib/spider-exclusion.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4096da1..23d3412 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -**PawFeed** — a social platform where pets are the stars. Dogs, cats, and birds get their own profiles; owners manage them, but the pet is the identity (profile URL, posts, follows — all pet-scoped, not owner-scoped). Photos, short-form video, stories, milestones, DMs, and health tracking, all within a single pet-first social graph. +**PawFeed** — a social platform where pets are the stars. Domestic/companion animals get their own profiles; owners manage them, but the pet is the identity (profile URL, posts, follows — all pet-scoped, not owner-scoped). Photos, short-form video, stories, milestones, DMs, and health tracking, all within a single pet-first social graph. **Core value:** Pets are first-class social identities, not content on a human's feed. Every feature is built around the pet's life. -**Constraints:** Web-first (mobile-responsive); dogs/cats/birds only in v1; short-form video only (no long-form). +**Constraints:** Web-first (mobile-responsive); short-form video only (no long-form). + +**Species scope:** Dogs, cats, birds, horses, rabbits, guinea pigs, hamsters, mice, rats, snakes, lizards, turtles, geckos, and spiders — see `Species`/`Breed` in `prisma/schema.prisma` and the taxonomy in `prisma/seed.ts`. The species/breed system is fully data-driven (i18n translation tables, dynamic `pets.listSpecies`/`pets.listBreeds` queries) — every consumer (pet creation, onboarding, ad targeting) already reads from these tables, so adding a new species is a seed-data change, not a code change. The one exception: **spiders** are treated specially — an owner-level "Arachnophobie-Helfer" preference (`Owner.hideSpiders`, toggled during onboarding via `ArachnophobiaOptIn` or later on `/pets` via `HideSpidersToggle`) fully excludes Species="Spider" pets/posts from feed/explore/search when enabled (`src/lib/spider-exclusion.ts`). This exclusion is applied per-query at each read site (feed.ts, explore.ts, search.ts) — there is no central chokepoint, so a new pet/post-listing query must add the same `getSpiderExclusionId()` check to stay consistent. ## Commands diff --git a/messages/de.json b/messages/de.json index 267d4cd..110a13f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -532,6 +532,17 @@ "privacyLink": "Datenschutzerklärung", "continueCta": "Bestätigen und weiter" }, + "ArachnophobiaOptIn": { + "title": "Arachnophobie-Helfer", + "subtitle": "PawFeed ist jetzt auch für Spinnen-Halter da. Wenn dir vor Spinnenbildern graut, blenden wir sie für dich komplett aus — im Feed, bei Explore und in der Suche.", + "checkboxLabel": "Ja, bitte Spinnen-Inhalte für mich ausblenden.", + "laterHint": "Du kannst das jederzeit unter \"Meine Tiere\" wieder ändern.", + "continueCta": "Weiter" + }, + "HideSpidersToggle": { + "label": "Spinnen-Inhalte ausblenden", + "description": "Blendet Pets und Beiträge der Tierart Spinne im Feed, bei Explore und in der Suche aus." + }, "PetsPage": { "title": "Meine Tiere", "addPet": "Tier anlegen", diff --git a/messages/en.json b/messages/en.json index ec40596..06c21bd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -532,6 +532,17 @@ "privacyLink": "Privacy Policy", "continueCta": "Confirm and continue" }, + "ArachnophobiaOptIn": { + "title": "Arachnophobia helper", + "subtitle": "PawFeed now welcomes spider owners too. If spider photos aren't your thing, we can hide them completely — in the feed, Explore, and search.", + "checkboxLabel": "Yes, please hide spider content for me.", + "laterHint": "You can change this anytime under \"My Pets\".", + "continueCta": "Continue" + }, + "HideSpidersToggle": { + "label": "Hide spider content", + "description": "Hides pets and posts of the species Spider from the feed, Explore, and search." + }, "PetsPage": { "title": "My Pets", "addPet": "Add Pet", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index be9fc4a..5be9fe2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,7 @@ model Owner { dataExportTokens DataExportToken[] warnings Warning[] themePreference ThemePreference? // null = follows system preference, never explicitly toggled + hideSpiders Boolean @default(false) // Arachnophobie-Helfer: hides Species="Spider" pets/posts from feed/explore/search when true // Sponsoring (Gitea #22) — freiwilliger Beitrag/Abo gegen ein "Sponsor"-Badge. stripeCustomerId String? @unique diff --git a/prisma/seed.ts b/prisma/seed.ts index c4c0c3b..f25dca4 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -75,10 +75,121 @@ const BIRD_BREEDS = [ "Amazon Parrot", ]; +const HORSE_BREEDS = [ + "Arabian Horse", + "Icelandic Horse", + "Shetland Pony", + "Haflinger", + "Friesian", + "Andalusian", + "Quarter Horse", + "Thoroughbred", + "Warmblood", + "Welsh Pony", + "Trakehner", + "Hanoverian", +]; + +const RABBIT_BREEDS = [ + "Netherland Dwarf", + "Holland Lop", + "Lionhead Rabbit", + "Rex Rabbit", + "Angora Rabbit", + "Flemish Giant", + "Mini Rex", + "Dutch Rabbit", +]; + +const GUINEA_PIG_BREEDS = [ + "American Guinea Pig", + "Abyssinian Guinea Pig", + "Peruvian Guinea Pig", + "Silkie Guinea Pig", + "Teddy Guinea Pig", + "Skinny Pig", +]; + +const HAMSTER_BREEDS = [ + "Syrian Hamster", + "Roborovski Hamster", + "Campbell's Dwarf Hamster", + "Winter White Hamster", + "Chinese Hamster", +]; + +const MOUSE_BREEDS = [ + "Fancy Mouse", + "Satin Mouse", + "Long-Haired Mouse", + "Hairless Mouse", +]; + +const RAT_BREEDS = [ + "Standard Rat", + "Dumbo Rat", + "Hairless Rat", + "Rex Rat", + "Satin Rat", +]; + +const SNAKE_BREEDS = [ + "Corn Snake", + "Ball Python", + "Boa Constrictor", + "King Snake", + "Milk Snake", + "Garter Snake", +]; + +const LIZARD_BREEDS = [ + "Bearded Dragon", + "Green Iguana", + "Blue-Tongued Skink", + "Chameleon", + "Monitor Lizard", +]; + +const TURTLE_BREEDS = [ + "Red-Eared Slider", + "Greek Tortoise", + "Hermann's Tortoise", + "Box Turtle", + "African Sulcata Tortoise", +]; + +const GECKO_BREEDS = [ + "Leopard Gecko", + "Crested Gecko", + "Tokay Gecko", + "Day Gecko", + "Gargoyle Gecko", +]; + +const SPIDER_BREEDS = [ + "Chilean Rose Tarantula", + "Mexican Red Knee Tarantula", + "Curly Hair Tarantula", + "Pink Toe Tarantula", + "Goliath Birdeater", + "Brazilian Black Tarantula", +]; + const SPECIES_DE: Record = { "Dog": "Hund", "Cat": "Katze", "Bird": "Vogel", + "Horse": "Pferd", + "Rabbit": "Kaninchen", + "Guinea Pig": "Meerschweinchen", + "Hamster": "Hamster", + "Mouse": "Maus", + "Rat": "Ratte", + "Snake": "Schlange", + "Lizard": "Echse", + "Turtle": "Schildkröte", + "Gecko": "Gecko", + "Spider": "Spinne", }; const DOG_BREEDS_DE: Record = { @@ -135,10 +246,121 @@ const BIRD_BREEDS_DE: Record = { "Amazon Parrot": "Amazone", }; +const HORSE_BREEDS_DE: Record = { + "Arabian Horse": "Araber", + "Icelandic Horse": "Islandpferd", + "Shetland Pony": "Shetlandpony", + "Haflinger": "Haflinger", + "Friesian": "Friese", + "Andalusian": "Andalusier", + "Quarter Horse": "Quarter Horse", + "Thoroughbred": "Englisches Vollblut", + "Warmblood": "Warmblut", + "Welsh Pony": "Welsh Pony", + "Trakehner": "Trakehner", + "Hanoverian": "Hannoveraner", +}; + +const RABBIT_BREEDS_DE: Record = { + "Netherland Dwarf": "Hollandzwerg", + "Holland Lop": "Widderkaninchen", + "Lionhead Rabbit": "Löwenkopfkaninchen", + "Rex Rabbit": "Rex-Kaninchen", + "Angora Rabbit": "Angorakaninchen", + "Flemish Giant": "Deutscher Riese", + "Mini Rex": "Mini Rex", + "Dutch Rabbit": "Holländer", +}; + +const GUINEA_PIG_BREEDS_DE: Record = { + "American Guinea Pig": "Glatthaarmeerschweinchen", + "Abyssinian Guinea Pig": "Rosettenmeerschweinchen", + "Peruvian Guinea Pig": "Perumeerschweinchen", + "Silkie Guinea Pig": "Silkie", + "Teddy Guinea Pig": "Teddymeerschweinchen", + "Skinny Pig": "Skinny Pig", +}; + +const HAMSTER_BREEDS_DE: Record = { + "Syrian Hamster": "Goldhamster", + "Roborovski Hamster": "Roborowski-Zwerghamster", + "Campbell's Dwarf Hamster": "Campbell-Zwerghamster", + "Winter White Hamster": "Dsungarischer Zwerghamster", + "Chinese Hamster": "Chinesischer Streifenhamster", +}; + +const MOUSE_BREEDS_DE: Record = { + "Fancy Mouse": "Farbmaus", + "Satin Mouse": "Satinmaus", + "Long-Haired Mouse": "Langhaarmaus", + "Hairless Mouse": "Nacktmaus", +}; + +const RAT_BREEDS_DE: Record = { + "Standard Rat": "Standardratte", + "Dumbo Rat": "Dumbo-Ratte", + "Hairless Rat": "Nacktratte", + "Rex Rat": "Rex-Ratte", + "Satin Rat": "Satin-Ratte", +}; + +const SNAKE_BREEDS_DE: Record = { + "Corn Snake": "Kornnatter", + "Ball Python": "Königspython", + "Boa Constrictor": "Abgottschlange", + "King Snake": "Königsnatter", + "Milk Snake": "Milchschlange", + "Garter Snake": "Strumpfbandnatter", +}; + +const LIZARD_BREEDS_DE: Record = { + "Bearded Dragon": "Bartagame", + "Green Iguana": "Grüner Leguan", + "Blue-Tongued Skink": "Blauzungenskink", + "Chameleon": "Chamäleon", + "Monitor Lizard": "Waran", +}; + +const TURTLE_BREEDS_DE: Record = { + "Red-Eared Slider": "Rotwangen-Schmuckschildkröte", + "Greek Tortoise": "Griechische Landschildkröte", + "Hermann's Tortoise": "Griechische Landschildkröte (Testudo hermanni)", + "Box Turtle": "Dosenschildkröte", + "African Sulcata Tortoise": "Spornschildkröte", +}; + +const GECKO_BREEDS_DE: Record = { + "Leopard Gecko": "Leopardgecko", + "Crested Gecko": "Kronengecko", + "Tokay Gecko": "Tokeh", + "Day Gecko": "Taggecko", + "Gargoyle Gecko": "Gargoyle-Gecko", +}; + +const SPIDER_BREEDS_DE: Record = { + "Chilean Rose Tarantula": "Chilenische Rosenhaarvogelspinne", + "Mexican Red Knee Tarantula": "Mexikanische Rotknievogelspinne", + "Curly Hair Tarantula": "Lockenhaarvogelspinne", + "Pink Toe Tarantula": "Antillen-Baumvogelspinne", + "Goliath Birdeater": "Goliath-Vogelspinne", + "Brazilian Black Tarantula": "Brasilianische Schwarze Vogelspinne", +}; + const BREED_DE_BY_SPECIES: Record> = { "Dog": DOG_BREEDS_DE, "Cat": CAT_BREEDS_DE, "Bird": BIRD_BREEDS_DE, + "Horse": HORSE_BREEDS_DE, + "Rabbit": RABBIT_BREEDS_DE, + "Guinea Pig": GUINEA_PIG_BREEDS_DE, + "Hamster": HAMSTER_BREEDS_DE, + "Mouse": MOUSE_BREEDS_DE, + "Rat": RAT_BREEDS_DE, + "Snake": SNAKE_BREEDS_DE, + "Lizard": LIZARD_BREEDS_DE, + "Turtle": TURTLE_BREEDS_DE, + "Gecko": GECKO_BREEDS_DE, + "Spider": SPIDER_BREEDS_DE, }; async function main() { @@ -148,6 +370,17 @@ async function main() { { name: "Dog", breeds: DOG_BREEDS }, { name: "Cat", breeds: CAT_BREEDS }, { name: "Bird", breeds: BIRD_BREEDS }, + { name: "Horse", breeds: HORSE_BREEDS }, + { name: "Rabbit", breeds: RABBIT_BREEDS }, + { name: "Guinea Pig", breeds: GUINEA_PIG_BREEDS }, + { name: "Hamster", breeds: HAMSTER_BREEDS }, + { name: "Mouse", breeds: MOUSE_BREEDS }, + { name: "Rat", breeds: RAT_BREEDS }, + { name: "Snake", breeds: SNAKE_BREEDS }, + { name: "Lizard", breeds: LIZARD_BREEDS }, + { name: "Turtle", breeds: TURTLE_BREEDS }, + { name: "Gecko", breeds: GECKO_BREEDS }, + { name: "Spider", breeds: SPIDER_BREEDS }, ]; for (const s of speciesData) { diff --git a/src/__tests__/explore.test.ts b/src/__tests__/explore.test.ts index 85ee0b5..00e8bf0 100644 --- a/src/__tests__/explore.test.ts +++ b/src/__tests__/explore.test.ts @@ -91,6 +91,21 @@ describe("explore.listPets", () => { expect(result.pets).toHaveLength(1); }); + it("Arachnophobie-Helfer: excludes Species=Spider pets when the caller opted in", async () => { + const { caller, mockPrisma } = await createExploreCaller(); + mockPrisma.owner.findUnique.mockResolvedValue({ hideSpiders: true }); + mockPrisma.species.findUnique.mockResolvedValue({ id: "species-spider" }); + mockPrisma.pet.findMany.mockResolvedValue([makePet("pet-1", "species-dog")]); + + await caller.explore.listPets({ limit: 20 }); + + expect(mockPrisma.pet.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ NOT: { speciesId: "species-spider" } }), + }) + ); + }); + it("EXPL-01 — cursor pagination: returns nextCursor when more results exist", async () => { const { caller, mockPrisma } = await createExploreCaller(); diff --git a/src/__tests__/feed.test.ts b/src/__tests__/feed.test.ts index 6325a11..d752cfe 100644 --- a/src/__tests__/feed.test.ts +++ b/src/__tests__/feed.test.ts @@ -200,6 +200,31 @@ describe("feed.getFeed", () => { ); }); + it("Arachnophobie-Helfer: excludes Species=Spider posts when the caller opted in", async () => { + const { caller, mockPrisma } = createCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" }); + mockPrisma.owner.findUnique.mockResolvedValue({ hideSpiders: true }); + mockPrisma.species.findUnique.mockResolvedValue({ id: "species-spider" }); + + const postIds = ["post-1"]; + mockGetFeedPage.mockResolvedValue({ postIds, nextCursor: null }); + mockPrisma.block.findMany.mockResolvedValue([]); + mockPrisma.post.findMany.mockResolvedValue([ + { id: "post-1", petId: "pet-2", createdAt: new Date(), pet: { id: "pet-2" }, images: [], milestone: null }, + ]); + + await caller.getFeed({ petId: "pet-1", cursor: null, limit: 20 }); + + expect(mockPrisma.post.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + pet: expect.objectContaining({ speciesId: { not: "species-spider" } }), + }), + }) + ); + }); + it("cold-start: empty follow feed falls back to species-matched discovery posts", async () => { const { caller, mockPrisma } = createCaller(); diff --git a/src/__tests__/owner.test.ts b/src/__tests__/owner.test.ts index 0350b21..1b476cd 100644 --- a/src/__tests__/owner.test.ts +++ b/src/__tests__/owner.test.ts @@ -89,3 +89,39 @@ describe("owner.setThemePreference", () => { ).rejects.toThrow(); }); }); + +describe("owner.getHideSpidersPreference", () => { + it("defaults to false when the Owner row does not exist yet", async () => { + const { caller, mockPrisma } = await createOwnerCaller(); + mockPrisma.owner.findUnique.mockResolvedValue(null); + + const result = await caller.owner.getHideSpidersPreference(); + + expect(result).toEqual({ hideSpiders: false }); + }); + + it("returns the stored preference when set", async () => { + const { caller, mockPrisma } = await createOwnerCaller(); + mockPrisma.owner.findUnique.mockResolvedValue({ hideSpiders: true }); + + const result = await caller.owner.getHideSpidersPreference(); + + expect(result).toEqual({ hideSpiders: true }); + }); +}); + +describe("owner.setHideSpidersPreference", () => { + it("upserts the preference on the calling owner (works even before the Owner row exists)", async () => { + const { caller, mockPrisma } = await createOwnerCaller(); + mockPrisma.owner.upsert.mockResolvedValue({ id: "owner-1" }); + + const result = await caller.owner.setHideSpidersPreference({ hideSpiders: true }); + + expect(mockPrisma.owner.upsert).toHaveBeenCalledWith({ + where: { id: "owner-1" }, + create: { id: "owner-1", hideSpiders: true }, + update: { hideSpiders: true }, + }); + expect(result).toEqual({ ok: true }); + }); +}); diff --git a/src/__tests__/search.test.ts b/src/__tests__/search.test.ts index 610d26d..85abd1e 100644 --- a/src/__tests__/search.test.ts +++ b/src/__tests__/search.test.ts @@ -88,6 +88,21 @@ describe("search.pets", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((result.pets[0] as any).breed?.name).toBe("Golden Retriever"); }); + + it("Arachnophobie-Helfer: excludes Species=Spider pets when the caller opted in", async () => { + const { caller, mockPrisma } = await createSearchCaller(); + mockPrisma.owner.findUnique.mockResolvedValue({ hideSpiders: true }); + mockPrisma.species.findUnique.mockResolvedValue({ id: "species-spider" }); + mockPrisma.pet.findMany.mockResolvedValue([]); + + await caller.search.pets({ query: "Buddy" }); + + expect(mockPrisma.pet.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ NOT: { speciesId: "species-spider" } }), + }) + ); + }); }); // ─── search.byHashtag ──────────────────────────────────────────────────────── diff --git a/src/app/(app)/pets/page.tsx b/src/app/(app)/pets/page.tsx index 2e0ec38..e3885a9 100644 --- a/src/app/(app)/pets/page.tsx +++ b/src/app/(app)/pets/page.tsx @@ -4,6 +4,7 @@ import { createTRPCCaller } from "@/trpc/server"; import { buttonVariants } from "@/components/ui/button"; import { PetGrid } from "./PetGrid"; import { DeleteAccountDialog } from "@/components/account/DeleteAccountDialog"; +import { HideSpidersToggle } from "@/components/account/HideSpidersToggle"; /** * My Pets page — /pets @@ -33,7 +34,11 @@ export default async function PetsPage({ -
+
+ +
+ +
Impressum Datenschutz diff --git a/src/app/onboarding/pet/page.tsx b/src/app/onboarding/pet/page.tsx index 481eda7..643f5c2 100644 --- a/src/app/onboarding/pet/page.tsx +++ b/src/app/onboarding/pet/page.tsx @@ -9,19 +9,22 @@ import { Button } from "@/components/ui/button"; import { useActivePet } from "@/context/ActivePetContext"; import { OnboardingGuidelines } from "@/components/legal/OnboardingGuidelines"; import { UploadConsent } from "@/components/legal/UploadConsent"; +import { ArachnophobiaOptIn } from "@/components/legal/ArachnophobiaOptIn"; import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher"; /** * Onboarding pet page — /onboarding/pet * - * Four-step flow: + * Five-step flow: * Step 0: Acknowledge community guidelines (skipped automatically once * acknowledged, or if nothing has been published — see * OnboardingGuidelines) * Step 1: Confirm upload-rights + data-processing consent (Gitea #3, * skipped automatically once already given — see UploadConsent) - * Step 2: Create pet profile (name, species, etc.) - * Step 3: Upload avatar photo (now that petId exists) + * Step 2: Arachnophobie-Helfer opt-in (non-blocking, shown once — see + * ArachnophobiaOptIn; always re-toggleable later on /pets) + * Step 3: Create pet profile (name, species, etc.) + * Step 4: Upload avatar photo (now that petId exists) * * Lives OUTSIDE the (app) layout group to avoid redirect loop. */ @@ -32,6 +35,7 @@ export default function OnboardingPetPage() { const [createdPetId, setCreatedPetId] = useState(null); const [guidelinesAcknowledged, setGuidelinesAcknowledged] = useState(false); const [uploadConsentGiven, setUploadConsentGiven] = useState(false); + const [arachnophobiaStepDone, setArachnophobiaStepDone] = useState(false); let content: React.ReactNode; @@ -39,6 +43,8 @@ export default function OnboardingPetPage() { content = setGuidelinesAcknowledged(true)} />; } else if (!uploadConsentGiven) { content = setUploadConsentGiven(true)} />; + } else if (!arachnophobiaStepDone) { + content = setArachnophobiaStepDone(true)} />; } else if (createdPetId) { content = (
diff --git a/src/components/account/HideSpidersToggle.tsx b/src/components/account/HideSpidersToggle.tsx new file mode 100644 index 0000000..ac60e8b --- /dev/null +++ b/src/components/account/HideSpidersToggle.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { Switch } from "@/components/ui/switch"; + +/** + * Arachnophobie-Helfer toggle — always available on /pets so an owner who + * opted in (or skipped) during onboarding can change their mind later. + * See ArachnophobiaOptIn.tsx for the onboarding-time version. + */ +export function HideSpidersToggle() { + const t = useTranslations("HideSpidersToggle"); + const trpc = useTRPC(); + const qc = useQueryClient(); + + const { data } = useQuery(trpc.owner.getHideSpidersPreference.queryOptions()); + + const setPreferenceMutation = useMutation( + trpc.owner.setHideSpidersPreference.mutationOptions({ + onSuccess: () => { + void qc.invalidateQueries({ queryKey: trpc.owner.getHideSpidersPreference.queryKey() }); + }, + }) + ); + + const hideSpiders = data?.hideSpiders ?? false; + + return ( +
+
+

{t("label")}

+

{t("description")}

+
+ setPreferenceMutation.mutate({ hideSpiders: checked })} + disabled={setPreferenceMutation.isPending} + aria-label={t("label")} + /> +
+ ); +} diff --git a/src/components/legal/ArachnophobiaOptIn.tsx b/src/components/legal/ArachnophobiaOptIn.tsx new file mode 100644 index 0000000..5fe9edd --- /dev/null +++ b/src/components/legal/ArachnophobiaOptIn.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { useMutation } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { Bug, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface ArachnophobiaOptInProps { + /** Called once the choice (or the default "no") is on file. */ + onContinue: () => void; +} + +/** + * Non-blocking onboarding step (Arachnophobie-Helfer): offers to hide + * Species="Spider" pets/posts across feed/explore/search. Unlike + * OnboardingGuidelines/UploadConsent this is never mandatory — shown once + * per onboarding pass, always re-toggleable later on /pets + * (see HideSpidersToggle.tsx). + */ +export function ArachnophobiaOptIn({ onContinue }: ArachnophobiaOptInProps) { + const t = useTranslations("ArachnophobiaOptIn"); + const trpc = useTRPC(); + + const [hideSpiders, setHideSpiders] = useState(false); + + const setPreferenceMutation = useMutation( + trpc.owner.setHideSpidersPreference.mutationOptions({ + onSuccess: onContinue, + onError: onContinue, // never block onboarding on this — worst case they set it later on /pets + }) + ); + + return ( +
+
+
+

{t("subtitle")}

+ + + +

{t("laterHint")}

+ + +
+ ); +} diff --git a/src/lib/spider-exclusion.ts b/src/lib/spider-exclusion.ts new file mode 100644 index 0000000..a525889 --- /dev/null +++ b/src/lib/spider-exclusion.ts @@ -0,0 +1,50 @@ +import "server-only"; +import type { PrismaClient } from "@prisma/client"; + +/** + * Arachnophobie-Helfer (Species="Spider" filter) support. + * + * The "Spider" Species row is static reference data seeded once + * (prisma/seed.ts) and never changes at runtime, so its id is cached + * in-process for the life of the server — avoids an extra query on every + * feed/explore/search call. Returns null if the row doesn't exist yet + * (e.g. seed hasn't run), in which case callers simply apply no filter. + */ +let cachedSpiderSpeciesId: string | null | undefined; + +export async function resolveSpiderSpeciesId(prisma: PrismaClient): Promise { + if (cachedSpiderSpeciesId !== undefined) return cachedSpiderSpeciesId; + const species = await prisma.species.findUnique({ + where: { name: "Spider" }, + select: { id: true }, + }); + cachedSpiderSpeciesId = species?.id ?? null; + return cachedSpiderSpeciesId; +} + +/** + * True only for an authenticated owner who has explicitly opted in. + * userId is nullable because explore.listPets/getTrending are publicProcedure — + * anonymous visitors never get spiders hidden (they haven't opted in to anything). + */ +export async function shouldExcludeSpiders( + prisma: PrismaClient, + userId: string | null +): Promise { + if (!userId) return false; + const owner = await prisma.owner.findUnique({ + where: { id: userId }, + select: { hideSpiders: true }, + }); + return owner?.hideSpiders ?? false; +} + +/** Resolves both pieces at once — the id to exclude, or null if no filter should apply. */ +export async function getSpiderExclusionId( + prisma: PrismaClient, + userId: string | null +): Promise { + const excluding = await shouldExcludeSpiders(prisma, userId); + if (!excluding) return null; + return resolveSpiderSpeciesId(prisma); +} diff --git a/src/trpc/routers/explore.ts b/src/trpc/routers/explore.ts index 2ea6820..adc83b5 100644 --- a/src/trpc/routers/explore.ts +++ b/src/trpc/routers/explore.ts @@ -2,6 +2,7 @@ import "server-only"; import { z } from "zod"; import { router, publicProcedure, rateLimited } from "@/trpc/init"; import { pickLocalizedName } from "@/lib/localized-name"; +import { getSpiderExclusionId } from "@/lib/spider-exclusion"; const TRENDING_POSTS_WINDOW_MS = 48 * 60 * 60 * 1000; // 48 h const TRENDING_HASHTAGS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days @@ -18,10 +19,12 @@ export const exploreRouter = router({ }) ) .query(async ({ ctx, input }) => { + const spiderExclusionId = await getSpiderExclusionId(ctx.prisma, ctx.userId); const pets = await ctx.prisma.pet.findMany({ where: { ...(input.speciesId ? { speciesId: input.speciesId } : {}), ...(input.breedId ? { breedId: input.breedId } : {}), + ...(spiderExclusionId ? { NOT: { speciesId: spiderExclusionId } } : {}), }, orderBy: { createdAt: "desc" }, take: input.limit + 1, @@ -57,13 +60,17 @@ export const exploreRouter = router({ const now = Date.now(); const since48h = new Date(now - TRENDING_POSTS_WINDOW_MS); const since7d = new Date(now - TRENDING_HASHTAGS_WINDOW_MS); + const spiderExclusionId = await getSpiderExclusionId(ctx.prisma, ctx.userId); const [posts, hashtags] = await Promise.all([ ctx.prisma.post.findMany({ where: { hiddenAt: null, type: "PHOTO", - pet: { owner: { shadowban: null } }, + pet: { + owner: { shadowban: null }, + ...(spiderExclusionId ? { NOT: { speciesId: spiderExclusionId } } : {}), + }, OR: [{ createdAt: { gte: since48h } }, { pinnedAt: { not: null } }], }, include: { diff --git a/src/trpc/routers/feed.ts b/src/trpc/routers/feed.ts index 1407d6f..63bb957 100644 --- a/src/trpc/routers/feed.ts +++ b/src/trpc/routers/feed.ts @@ -5,6 +5,7 @@ import { router, protectedProcedure } from "@/trpc/init"; import { redis } from "@/lib/redis"; import { getFeedPage } from "@/lib/feed-helpers"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; +import { getSpiderExclusionId } from "@/lib/spider-exclusion"; // Engagement-based ranking score for the algorithmic feed. // Recency decays logarithmically; reaction/comment/repost weights are tuned @@ -69,7 +70,8 @@ const postInclude = { async function getDiscoveryPosts( prisma: PrismaClient, viewerPet: { id: string; speciesId: string }, - limit: number + limit: number, + spiderExclusionId: string | null ) { const blockedEntries = await prisma.block.findMany({ where: { blockerPetId: viewerPet.id }, @@ -77,6 +79,11 @@ async function getDiscoveryPosts( }); const blockedPetIds = blockedEntries.map((b) => b.blockedPetId); + // Discovery surfaces posts from other pets of the SAME species — if the + // viewer's own pet is a spider and they've opted to hide spiders, there's + // nothing to discover here at all (the whole discovery pool is spiders). + if (spiderExclusionId && viewerPet.speciesId === spiderExclusionId) return []; + const candidates = await prisma.post.findMany({ where: { hiddenAt: null, @@ -109,6 +116,7 @@ export const feedRouter = router({ ) .query(async ({ ctx, input }) => { const viewerPet = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); + const spiderExclusionId = await getSpiderExclusionId(ctx.prisma, ctx.userId); let postIds: string[]; let nextCursor: number | null; @@ -142,7 +150,10 @@ export const feedRouter = router({ where: { hiddenAt: null, petId: { in: petIds, notIn: blockedPetIds }, - pet: { OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }] }, + pet: { + OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }], + ...(spiderExclusionId ? { speciesId: { not: spiderExclusionId } } : {}), + }, ...(cursorDate ? { createdAt: { lt: cursorDate } } : {}), }, orderBy: { createdAt: "desc" }, @@ -168,7 +179,7 @@ export const feedRouter = router({ // Only on the first page — paginating an already-empty follow feed // shouldn't keep serving discovery pages indefinitely. if (!input.cursor) { - const discoveryPosts = await getDiscoveryPosts(ctx.prisma, viewerPet, input.limit); + const discoveryPosts = await getDiscoveryPosts(ctx.prisma, viewerPet, input.limit, spiderExclusionId); if (discoveryPosts.length > 0) { return { posts: discoveryPosts, nextCursor: null, source: "discovery" as const }; } @@ -189,6 +200,7 @@ export const feedRouter = router({ pet: { id: { notIn: blockedPetIds }, OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }], + ...(spiderExclusionId ? { speciesId: { not: spiderExclusionId } } : {}), }, }, include: postInclude, diff --git a/src/trpc/routers/owner.ts b/src/trpc/routers/owner.ts index d1c7f63..5fdae24 100644 --- a/src/trpc/routers/owner.ts +++ b/src/trpc/routers/owner.ts @@ -23,4 +23,26 @@ export const ownerRouter = router({ }); return { ok: true }; }), + + // Arachnophobie-Helfer: hides Species="Spider" pets/posts across + // feed/explore/search when enabled (see src/lib/spider-exclusion.ts). + // Offered once during onboarding, always re-toggleable on the /pets page. + getHideSpidersPreference: protectedProcedure.query(async ({ ctx }) => { + const owner = await ctx.prisma.owner.findUnique({ + where: { id: ctx.userId }, + select: { hideSpiders: true }, + }); + return { hideSpiders: owner?.hideSpiders ?? false }; + }), + + setHideSpidersPreference: protectedProcedure + .input(z.object({ hideSpiders: z.boolean() })) + .mutation(async ({ ctx, input }) => { + await ctx.prisma.owner.upsert({ + where: { id: ctx.userId }, + create: { id: ctx.userId, hideSpiders: input.hideSpiders, registrationIp: ctx.ip }, + update: { hideSpiders: input.hideSpiders }, + }); + return { ok: true }; + }), }); diff --git a/src/trpc/routers/search.ts b/src/trpc/routers/search.ts index afbef79..cd9ece2 100644 --- a/src/trpc/routers/search.ts +++ b/src/trpc/routers/search.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { router, protectedProcedure } from "@/trpc/init"; import { clerkClient } from "@clerk/nextjs/server"; import { pickLocalizedName } from "@/lib/localized-name"; +import { getSpiderExclusionId } from "@/lib/spider-exclusion"; export const searchRouter = router({ pets: protectedProcedure @@ -14,6 +15,7 @@ export const searchRouter = router({ }) ) .query(async ({ ctx, input }) => { + const spiderExclusionId = await getSpiderExclusionId(ctx.prisma, ctx.userId); const pets = await ctx.prisma.pet.findMany({ where: { OR: [ @@ -21,6 +23,7 @@ export const searchRouter = router({ { breed: { name: { contains: input.query, mode: "insensitive" } } }, ], ...(input.cursor ? { id: { gt: input.cursor } } : {}), + ...(spiderExclusionId ? { NOT: { speciesId: spiderExclusionId } } : {}), }, orderBy: { name: "asc" }, take: input.limit + 1, @@ -63,12 +66,16 @@ export const searchRouter = router({ }); if (!hashtag) return { posts: [], nextCursor: null }; + const spiderExclusionId = await getSpiderExclusionId(ctx.prisma, ctx.userId); const rows = await ctx.prisma.postHashtag.findMany({ where: { hashtagId: hashtag.id, post: { hiddenAt: null, - pet: { OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }] }, + pet: { + OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }], + ...(spiderExclusionId ? { NOT: { speciesId: spiderExclusionId } } : {}), + }, }, ...(input.cursor ? { postId: { lt: input.cursor } } : {}), }, @@ -108,11 +115,13 @@ export const searchRouter = router({ if (clerkUsers.length === 0) return { owners: [] }; + const spiderExclusionId = await getSpiderExclusionId(ctx.prisma, ctx.userId); const userIds = clerkUsers.map((u) => u.id); const owners = await ctx.prisma.owner.findMany({ where: { id: { in: userIds } }, include: { pets: { + where: spiderExclusionId ? { NOT: { speciesId: spiderExclusionId } } : undefined, include: { species: { include: { translations: { where: { locale: ctx.locale } } } }, breed: { include: { translations: { where: { locale: ctx.locale } } } },