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.
This commit is contained in:
admin committed 2026-08-15 11:57:24 +02:00
1 parent 5660a3a09d
commit 1ee2b53a57
18 files changed
+583 -11

No files matched your search

+4 -2
View File
@@ -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
+11
View File
@@ -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",
+11
View File
@@ -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",
+1
View File
@@ -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
+233
View File
@@ -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<string, string> = {
"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<string, string> = {
@@ -135,10 +246,121 @@ const BIRD_BREEDS_DE: Record<string, string> = {
"Amazon Parrot": "Amazone",
};
const HORSE_BREEDS_DE: Record<string, string> = {
"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<string, string> = {
"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<string, string> = {
"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<string, string> = {
"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<string, string> = {
"Fancy Mouse": "Farbmaus",
"Satin Mouse": "Satinmaus",
"Long-Haired Mouse": "Langhaarmaus",
"Hairless Mouse": "Nacktmaus",
};
const RAT_BREEDS_DE: Record<string, string> = {
"Standard Rat": "Standardratte",
"Dumbo Rat": "Dumbo-Ratte",
"Hairless Rat": "Nacktratte",
"Rex Rat": "Rex-Ratte",
"Satin Rat": "Satin-Ratte",
};
const SNAKE_BREEDS_DE: Record<string, string> = {
"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<string, string> = {
"Bearded Dragon": "Bartagame",
"Green Iguana": "Grüner Leguan",
"Blue-Tongued Skink": "Blauzungenskink",
"Chameleon": "Chamäleon",
"Monitor Lizard": "Waran",
};
const TURTLE_BREEDS_DE: Record<string, string> = {
"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<string, string> = {
"Leopard Gecko": "Leopardgecko",
"Crested Gecko": "Kronengecko",
"Tokay Gecko": "Tokeh",
"Day Gecko": "Taggecko",
"Gargoyle Gecko": "Gargoyle-Gecko",
};
const SPIDER_BREEDS_DE: Record<string, string> = {
"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<string, Record<string, string>> = {
"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) {
+15
View File
@@ -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();
+25
View File
@@ -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();
+36
View File
@@ -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 });
});
});
+15
View File
@@ -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 ────────────────────────────────────────────────────────
+6 -1
View File
@@ -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({
<PetGrid pets={pets} />
<div className="mt-10 pt-4 border-t flex items-center justify-center gap-4 text-xs text-muted-foreground">
<div className="mt-10">
<HideSpidersToggle />
</div>
<div className="mt-6 pt-4 border-t flex items-center justify-center gap-4 text-xs text-muted-foreground">
<Link href="/impressum" className="hover:text-foreground transition-colors">Impressum</Link>
<span aria-hidden="true">·</span>
<Link href="/datenschutz" className="hover:text-foreground transition-colors">Datenschutz</Link>
+9 -3
View File
@@ -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<string | null>(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 = <OnboardingGuidelines onContinue={() => setGuidelinesAcknowledged(true)} />;
} else if (!uploadConsentGiven) {
content = <UploadConsent onContinue={() => setUploadConsentGiven(true)} />;
} else if (!arachnophobiaStepDone) {
content = <ArachnophobiaOptIn onContinue={() => setArachnophobiaStepDone(true)} />;
} else if (createdPetId) {
content = (
<div className="w-full max-w-[512px] p-8 flex flex-col items-center gap-6">
@@ -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 (
<div className="flex items-center justify-between gap-4 rounded-xl border bg-card p-4">
<div>
<p className="text-sm font-medium text-foreground">{t("label")}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t("description")}</p>
</div>
<Switch
checked={hideSpiders}
onCheckedChange={(checked: boolean) => setPreferenceMutation.mutate({ hideSpiders: checked })}
disabled={setPreferenceMutation.isPending}
aria-label={t("label")}
/>
</div>
);
}
@@ -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 (
<div className="w-full max-w-[512px] p-8">
<div className="flex items-center gap-2 mb-2">
<Bug className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
<h1 className="text-xl font-semibold">{t("title")}</h1>
</div>
<p className="text-sm text-muted-foreground mb-6">{t("subtitle")}</p>
<label className="flex items-start gap-3 rounded-xl border bg-card p-4 cursor-pointer mb-6">
<input
type="checkbox"
checked={hideSpiders}
onChange={(e) => setHideSpiders(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-input accent-orange-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-500"
/>
<span className="text-sm text-foreground leading-relaxed">{t("checkboxLabel")}</span>
</label>
<p className="text-xs text-muted-foreground mb-6">{t("laterHint")}</p>
<Button
className="w-full min-h-[44px] bg-orange-500 hover:bg-orange-600 text-white"
onClick={() => setPreferenceMutation.mutate({ hideSpiders })}
disabled={setPreferenceMutation.isPending}
>
{setPreferenceMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t("continueCta")
)}
</Button>
</div>
);
}
+50
View File
@@ -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<string | null> {
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<boolean> {
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<string | null> {
const excluding = await shouldExcludeSpiders(prisma, userId);
if (!excluding) return null;
return resolveSpiderSpeciesId(prisma);
}
+8 -1
View File
@@ -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: {
+15 -3
View File
@@ -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,
+22
View File
@@ -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 };
}),
});
+10 -1
View File
@@ -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 } } } },