-
+
+
+
{formatPetName(pet)}
{pet.species.name}
+ {pet.invitedByPet && (
+
+ {t.rich("invitedBy", {
+ name: pet.invitedByPet.name,
+ link: (chunks) => (
+
+ {chunks}
+
+ ),
+ })}
+
+ )}
+
{/* Owner attribution — only shown on own pet */}
{isOwnPet && ownerUsername && (
diff --git a/src/app/api/webhooks/mux/route.ts b/src/app/api/webhooks/mux/route.ts
index 8f38b63..be03074 100644
--- a/src/app/api/webhooks/mux/route.ts
+++ b/src/app/api/webhooks/mux/route.ts
@@ -4,6 +4,7 @@ import { mux, MUX_WEBHOOK_SECRET } from "@/lib/mux";
import { redis } from "@/lib/redis";
import { fanOutPost } from "@/lib/feed-helpers";
import { MAX_STORY_VIDEO_DURATION_SECS } from "@/lib/story-video-limits";
+import { maybeQualifyReferral } from "@/lib/referrals";
/**
* POST /api/webhooks/mux
@@ -73,7 +74,7 @@ export async function POST(req: NextRequest) {
const videoPost = await prisma.videoPost.findFirst({
where: { muxAssetId: assetId },
- include: { post: { select: { id: true, petId: true, createdAt: true } } },
+ include: { post: { select: { id: true, petId: true, createdAt: true, pet: { select: { ownerId: true } } } } },
});
if (videoPost) {
@@ -95,6 +96,11 @@ export async function POST(req: NextRequest) {
videoPost.post.createdAt,
prisma
).catch((err) => console.error("[mux-webhook] fanOutPost failed", err));
+
+ // Empfehlungsprogramm: ein fertig verarbeitetes Video zählt als erster Beitrag
+ await maybeQualifyReferral(prisma, videoPost.post.pet.ownerId).catch((err) =>
+ console.error("[mux-webhook] referral qualification failed", err)
+ );
break;
}
diff --git a/src/app/onboarding/pet/page.tsx b/src/app/onboarding/pet/page.tsx
index 643f5c2..fb4d347 100644
--- a/src/app/onboarding/pet/page.tsx
+++ b/src/app/onboarding/pet/page.tsx
@@ -11,6 +11,10 @@ import { OnboardingGuidelines } from "@/components/legal/OnboardingGuidelines";
import { UploadConsent } from "@/components/legal/UploadConsent";
import { ArachnophobiaOptIn } from "@/components/legal/ArachnophobiaOptIn";
import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher";
+import { AcquisitionStep } from "@/components/referrals/AcquisitionStep";
+import { useQuery } from "@tanstack/react-query";
+import { useTRPC } from "@/trpc/client";
+import { Loader2 } from "lucide-react";
/**
* Onboarding pet page — /onboarding/pet
@@ -24,6 +28,8 @@ import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher";
* 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 3b: "Wie hast du von PawFeed erfahren?" + optional referral code
+ * (Empfehlungsprogramm — once per owner, see referrals.onboardingState)
* Step 4: Upload avatar photo (now that petId exists)
*
* Lives OUTSIDE the (app) layout group to avoid redirect loop.
@@ -36,6 +42,15 @@ export default function OnboardingPetPage() {
const [guidelinesAcknowledged, setGuidelinesAcknowledged] = useState(false);
const [uploadConsentGiven, setUploadConsentGiven] = useState(false);
const [arachnophobiaStepDone, setArachnophobiaStepDone] = useState(false);
+ 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: !!createdPetId,
+ });
+ const isReferralStateLoading = !!createdPetId && !acquisitionStepDone && referralState === undefined;
+ const showAcquisitionStep = !!createdPetId && !acquisitionStepDone && !!referralState?.showStep;
let content: React.ReactNode;
@@ -45,6 +60,12 @@ export default function OnboardingPetPage() {
content = setUploadConsentGiven(true)} />;
} else if (!arachnophobiaStepDone) {
content = setArachnophobiaStepDone(true)} />;
+ } else if (isReferralStateLoading) {
+ content = ;
+ } else if (showAcquisitionStep) {
+ content = (
+ setAcquisitionStepDone(true)} />
+ );
} else if (createdPetId) {
content = (
diff --git a/src/app/p/[secret]/layout.tsx b/src/app/p/[secret]/layout.tsx
index fc99407..1ae9806 100644
--- a/src/app/p/[secret]/layout.tsx
+++ b/src/app/p/[secret]/layout.tsx
@@ -21,6 +21,7 @@ import {
ArrowLeft,
Radio,
Music,
+ Gift,
} from "lucide-react";
interface AdminLayoutProps {
@@ -71,6 +72,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
{ href: `${base}/broadcasts`, label: "Mitteilungen", Icon: Radio },
{ href: `${base}/log`, label: "Audit Log", Icon: ScrollText },
{ href: `${base}/verification`, label: "Verification", Icon: BadgeCheck },
+ { href: `${base}/referrals`, label: "Empfehlungen", Icon: Gift },
{ href: `${base}/blacklist`, label: "Blacklist", Icon: Ban },
]
: []),
diff --git a/src/app/p/[secret]/referrals/page.tsx b/src/app/p/[secret]/referrals/page.tsx
new file mode 100644
index 0000000..f0ded24
--- /dev/null
+++ b/src/app/p/[secret]/referrals/page.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { RanksTab } from "@/components/admin/referrals/RanksTab";
+import { SettingsTab } from "@/components/admin/referrals/SettingsTab";
+import { OverviewTab } from "@/components/admin/referrals/OverviewTab";
+
+/**
+ * Empfehlungsprogramm verwalten: Ränge (Schwellen, Namen, Farben, Vorteile),
+ * Einstellungen und Übersicht. Ändern dürfen nur SUPER_ADMINs — die Prüfung
+ * passiert in referral-admin.ts, nicht hier.
+ */
+export default function AdminReferralsPage() {
+ return (
+
+
+
Empfehlungen
+
Ränge, Einstellungen und Auswertung des Empfehlungsprogramms.
+
+
+
+ Übersicht
+ Ränge
+ Einstellungen
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/p/[secret]/verification/page.tsx b/src/app/p/[secret]/verification/page.tsx
index d1a41da..6950c22 100644
--- a/src/app/p/[secret]/verification/page.tsx
+++ b/src/app/p/[secret]/verification/page.tsx
@@ -212,6 +212,9 @@ export default function AdminVerificationPage() {
Already verified
)}
+ {req.pet.owner.referralPerks.includes("VERIFICATION_PRIORITY") && (
+
Bevorzugt (Empfehlungsrang)
+ )}
{req.pet.ownerId}
diff --git a/src/app/r/[code]/page.tsx b/src/app/r/[code]/page.tsx
new file mode 100644
index 0000000..9d90613
--- /dev/null
+++ b/src/app/r/[code]/page.tsx
@@ -0,0 +1,86 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { headers } from "next/headers";
+import { getTranslations } from "next-intl/server";
+import { prisma } from "@/lib/prisma";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { normalizeReferralCode } from "@/lib/referral-code";
+import { buttonVariants } from "@/components/ui/button";
+import { PetAvatar } from "@/components/ui/pet-avatar";
+import { LandingHeader } from "@/components/landing/LandingHeader";
+import { LandingFooter } from "@/components/landing/LandingFooter";
+import { PlayStoreBadge } from "@/components/landing/PlayStoreBadge";
+import { RememberReferralCode } from "@/components/referrals/RememberReferralCode";
+
+export const metadata: Metadata = {
+ title: "Einladung zu PawFeed",
+ robots: { index: false, follow: false },
+};
+
+const LOOKUPS_PER_MINUTE = 30;
+
+/**
+ * Öffentliche Einladungsseite des Empfehlungsprogramms (/r/BUBI-7K3M).
+ * Zeigt nur Name und Bild des ersten Tiers des Einladenden — der Einladende
+ * hat den Link selbst geteilt. Unbekannte Codes bekommen dieselbe Seite ohne
+ * Tier, damit sich nicht durchprobieren lässt, welche Codes existieren.
+ * Der Code wird nur für das spätere Vorausfüllen gemerkt (Cookie pf_ref);
+ * eingelöst wird er erst im Onboarding nach der Registrierung.
+ */
+export default async function ReferralLandingPage({ params }: { params: Promise<{ code: string }> }) {
+ const { code: rawCode } = await params;
+ const t = await getTranslations("ReferralLanding");
+ const code = normalizeReferralCode(decodeURIComponent(rawCode));
+
+ const headerList = await headers();
+ const ip = headerList.get("cf-connecting-ip") ?? headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
+ const { success } = await checkRateLimit({ key: `referral-landing:${ip}`, limit: LOOKUPS_PER_MINUTE, windowSeconds: 60 });
+
+ const inviterPet =
+ code && success
+ ? await prisma.pet.findFirst({
+ where: { owner: { referralCode: code } },
+ orderBy: { createdAt: "asc" },
+ select: { name: true, avatarKey: true },
+ })
+ : null;
+
+ return (
+
+
+
+
+ {inviterPet ? (
+
+
+
+ {t("titleWithPet", { name: inviterPet.name })}
+
+
+ ) : (
+
{t("titleGeneric")}
+ )}
+
+
{t("subtitle")}
+
+ {code && inviterPet && (
+
+
{t("codeLabel")}
+
{code}
+
{t("codeHint")}
+
+
+ )}
+
+
+
+
+ {t("webSignUp")}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/referrals/OverviewTab.tsx b/src/components/admin/referrals/OverviewTab.tsx
new file mode 100644
index 0000000..edee8bc
--- /dev/null
+++ b/src/components/admin/referrals/OverviewTab.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import Link from "next/link";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { AlertTriangle, Loader2 } from "lucide-react";
+import { toast } from "sonner";
+import { useTRPC } from "@/trpc/client";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+const SOURCE_LABELS: Record = {
+ FRIENDS: "Freunde/Familie", TIKTOK: "TikTok", INSTAGRAM: "Instagram", YOUTUBE: "YouTube", FACEBOOK: "Facebook",
+ SHELTER_OR_PARTNER: "Tierheim/Partner", STORE_SEARCH: "Store-Suche", PRESS: "Presse", OTHER: "Anders",
+};
+
+type PetRef = { id: string; name: string } | null;
+
+function PetLink({ pet }: { pet: PetRef }) {
+ if (!pet) return ohne Tier ;
+ return {pet.name};
+}
+
+type MaybeDate = Date | string | null;
+
+function statusOf(o: { referralRevokedAt: MaybeDate; referralQualifiedAt: MaybeDate; referralFlaggedAt: MaybeDate }) {
+ if (o.referralRevokedAt) return aberkannt ;
+ if (o.referralQualifiedAt) return zählt ;
+ if (o.referralFlaggedAt) return Prüfung ;
+ return wartet ;
+}
+
+export function OverviewTab() {
+ const trpc = useTRPC();
+ const qc = useQueryClient();
+ const { data, isLoading } = useQuery(trpc.referralAdmin.overview.queryOptions());
+ const { data: featurePool = [] } = useQuery(trpc.referralAdmin.perkHolders.queryOptions({ perk: "FEATURE_POOL" }));
+ const { data: spotlight = [] } = useQuery(trpc.referralAdmin.perkHolders.queryOptions({ perk: "CURATOR_SPOTLIGHT" }));
+
+ const refresh = () => void qc.invalidateQueries(trpc.referralAdmin.overview.queryFilter());
+ const approve = useMutation(trpc.referralAdmin.approveFlagged.mutationOptions({ onSuccess: () => { toast.success("Freigegeben"); refresh(); }, onError: (e) => toast.error(e.message) }));
+ const revoke = useMutation(trpc.referralAdmin.revokeReferral.mutationOptions({ onSuccess: () => { toast.success("Aberkannt"); refresh(); }, onError: (e) => toast.error(e.message) }));
+
+ if (isLoading || !data) return ;
+
+ const tiles = [
+ { label: "Eingelöst (7 / 30 Tage)", value: `${data.redeemed7} / ${data.redeemed30}` },
+ { label: "Gezählt (7 / 30 Tage)", value: `${data.qualified7} / ${data.qualified30}` },
+ { label: "Einladungsfaktor (30 Tage)", value: data.inviteFactor30.toFixed(2) },
+ { label: "Owner mit Tier", value: String(data.ownersWithPet) },
+ ];
+ const answered = data.sources.filter((s) => s.source).reduce((sum, s) => sum + s.count, 0);
+
+ return (
+
+
+ {tiles.map((tile) => (
+
+
{tile.label}
+ {tile.value}
+
+ ))}
+
+
+ {data.flagged.length > 0 && (
+
+
+ {data.flagged.map((o) => (
+
+
+ eingeladen von
+ approve.mutate({ ownerId: o.id })}>Freigeben
+ revoke.mutate({ ownerId: o.id, reason: "Tageslimit-Prüfung" })}>Aberkennen
+
+ ))}
+
+
+ )}
+
+
+
+ {data.sources.filter((s) => s.source).sort((a, b) => b.count - a.count).map((s) => (
+
+ {SOURCE_LABELS[s.source ?? ""] ?? s.source}
+
+ {s.count}
+
+ ))}
+
+
+
+
+
+ {data.recent.map((o) => (
+
+ ←
+ {o.sameIpAsInviter && gleiche IP }
+ {statusOf(o)}
+ {!o.referralRevokedAt && (
+ revoke.mutate({ ownerId: o.id, reason: "Manuell aberkannt" })}>Aberkennen
+ )}
+
+ ))}
+ {data.recent.length === 0 && Noch keine Einlösungen. }
+
+
+
+
+
+ {data.topInviters.map((o, i) => (
+
+ {i + 1}
+
+ {o.qualifiedReferralCount}
+
+ ))}
+
+
+
+
+
+ );
+}
+
+type PerkOwner = { id: string; qualifiedReferralCount: number; pets: { id: string; name: string; _count: { followers: number; posts: number } }[] };
+
+function PerkList({ title, owners }: { title: string; owners: PerkOwner[] }) {
+ return (
+
+
+ {owners.flatMap((o) => o.pets.map((pet) => (
+
+
+ {pet._count.followers} Follower · {pet._count.posts} Beiträge
+
+ )))}
+ {owners.length === 0 && Noch niemand. }
+
+
+ );
+}
+
+function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
+ return (
+
+
+
{title}
+ {hint &&
{hint}
}
+
+ {children}
+
+ );
+}
diff --git a/src/components/admin/referrals/RankEditorDialog.tsx b/src/components/admin/referrals/RankEditorDialog.tsx
new file mode 100644
index 0000000..48760a5
--- /dev/null
+++ b/src/components/admin/referrals/RankEditorDialog.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+import { useState } from "react";
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import { ReferralIcon } from "@/components/referrals/ReferralRankBadge";
+import {
+ IMPLEMENTED_REFERRAL_PERKS,
+ REFERRAL_COLOR_PATTERN,
+ REFERRAL_ICON_KEYS,
+ REFERRAL_PERKS,
+ type ReferralIconKey,
+ type ReferralPerkKey,
+} from "@/lib/referral-ranks";
+import { cn } from "@/lib/utils";
+
+export type RankFormValues = {
+ key: string;
+ nameDe: string;
+ nameEn: string;
+ descriptionDe: string;
+ descriptionEn: string;
+ threshold: number;
+ icon: ReferralIconKey;
+ color: string;
+ perks: ReferralPerkKey[];
+ isActive: boolean;
+};
+
+export const EMPTY_RANK: RankFormValues = {
+ key: "", nameDe: "", nameEn: "", descriptionDe: "", descriptionEn: "",
+ threshold: 1, icon: "paw-print", color: "#e8620f", perks: ["NAME_BADGE"], isActive: true,
+};
+
+const PERK_LABELS: Record = {
+ PROFILE_RING: "Profilring",
+ NAME_BADGE: "Abzeichen neben dem Namen",
+ FEATURE_POOL: "Auswahl „Tier der Woche“",
+ VERIFICATION_PRIORITY: "Verifizierung bevorzugt",
+ CURATOR_SPOTLIGHT: "Vorstellung durch das Team",
+ EXPLORE_HIGHLIGHT: "Explore-Hervorhebung",
+ HALL_OF_FAME: "Ruhmeshalle",
+ EARLY_ACCESS: "Früher Zugang",
+ VOUCHER: "Partner-Gutschein",
+};
+
+// Hintergründe der App in Hell und Dunkel — für Vorschau und Kontrastprüfung.
+const LIGHT_BG = "#ffffff";
+const DARK_BG = "#0a0a0a";
+const MIN_CONTRAST = 3; // WCAG-Mindestwert für Symbole und große Grafiken
+
+function luminance(hex: string): number {
+ const channels = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
+ const [r, g, b] = channels.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+type Props = {
+ open: boolean;
+ mode: "create" | "edit";
+ initial: RankFormValues;
+ isSaving: boolean;
+ onClose: () => void;
+ onSave: (values: RankFormValues) => void;
+};
+
+export function RankEditorDialog({ open, mode, initial, isSaving, onClose, onSave }: Props) {
+ const [form, setForm] = useState(initial);
+ const set = (key: K, value: RankFormValues[K]) => setForm((f) => ({ ...f, [key]: value }));
+
+ const isColorValid = REFERRAL_COLOR_PATTERN.test(form.color);
+ const isValid =
+ isColorValid && form.nameDe.trim() && form.nameEn.trim() && form.threshold >= 1 &&
+ (mode === "edit" || /^[a-z0-9-]{2,40}$/.test(form.key));
+ const lowContrast = isColorValid && Math.min(contrast(form.color, LIGHT_BG), contrast(form.color, DARK_BG)) < MIN_CONTRAST;
+
+ function togglePerk(perk: ReferralPerkKey) {
+ set("perks", form.perks.includes(perk) ? form.perks.filter((p) => p !== perk) : [...form.perks, perk]);
+ }
+
+ return (
+ !next && onClose()}>
+
+
+ {mode === "create" ? "Rang hinzufügen" : `Rang bearbeiten: ${initial.nameDe}`}
+
+
+
+ {/* Vorschau in Hell und Dunkel */}
+
+ {[LIGHT_BG, DARK_BG].map((bg) => (
+
+
+
+
+
+ Bubi
+
+
+
+ ))}
+
+ {lowContrast && (
+
+ Die Farbe hat auf hellem oder dunklem Hintergrund wenig Kontrast. Abzeichen und Ring sind dort schlecht zu sehen.
+
+ )}
+
+ {mode === "create" && (
+
+ set("key", e.target.value.toLowerCase())} className="font-mono" />
+
+ )}
+
+
+
+
+
+ {REFERRAL_ICON_KEYS.map((key) => {
+ return (
+ set("icon", key)}
+ className={cn("grid h-9 w-9 place-items-center rounded-lg border", form.icon === key && "border-orange-500 bg-orange-50 dark:bg-orange-950")}
+ >
+
+
+ );
+ })}
+
+
+
+
+
+ {REFERRAL_PERKS.map((perk) => (
+
+ togglePerk(perk)} className="mt-1 accent-orange-500" />
+
+ {PERK_LABELS[perk]}
+ {!IMPLEMENTED_REFERRAL_PERKS.has(perk) && noch ohne Wirkung }
+
+
+ ))}
+
+
+
+ {mode === "edit" && (
+
+
+ Aktiv
+ Deaktivierte Ränge werden nicht mehr vergeben und nicht angezeigt. Erreichte Ränge bleiben gespeichert.
+
+ set("isActive", checked)} />
+
+ )}
+
+
+
+ Abbrechen
+ onSave(form)}>
+ {isSaving ? "Wird gespeichert…" : "Speichern und neu berechnen"}
+
+
+
+
+ );
+}
+
+function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
+ return (
+
+
{label}
+ {children}
+ {hint &&
{hint}
}
+
+ );
+}
diff --git a/src/components/admin/referrals/RanksTab.tsx b/src/components/admin/referrals/RanksTab.tsx
new file mode 100644
index 0000000..389ff9f
--- /dev/null
+++ b/src/components/admin/referrals/RanksTab.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2, Pencil, Plus } from "lucide-react";
+import { toast } from "sonner";
+import { useTRPC } from "@/trpc/client";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { ReferralIcon } from "@/components/referrals/ReferralRankBadge";
+import { EMPTY_RANK, RankEditorDialog, type RankFormValues } from "@/components/admin/referrals/RankEditorDialog";
+import type { ReferralIconKey, ReferralPerkKey } from "@/lib/referral-ranks";
+
+type EditorState = { mode: "create" | "edit"; id: string | null; initial: RankFormValues } | null;
+
+export function RanksTab() {
+ const trpc = useTRPC();
+ const qc = useQueryClient();
+ const [editor, setEditor] = useState(null);
+ const { data: ranks = [], isLoading } = useQuery(trpc.referralAdmin.listRanks.queryOptions());
+
+ function onSaved() {
+ toast.success("Gespeichert. Ränge aller Nutzer wurden neu berechnet.");
+ setEditor(null);
+ void qc.invalidateQueries(trpc.referralAdmin.listRanks.queryFilter());
+ void qc.invalidateQueries(trpc.referrals.ranks.queryFilter());
+ }
+ const onError = (err: { message: string }) => toast.error(err.message);
+
+ const create = useMutation(trpc.referralAdmin.createRank.mutationOptions({ onSuccess: onSaved, onError }));
+ const update = useMutation(trpc.referralAdmin.updateRank.mutationOptions({ onSuccess: onSaved, onError }));
+
+ function handleSave(values: RankFormValues) {
+ const { key, isActive, ...fields } = values;
+ if (editor?.mode === "create") create.mutate({ key, ...fields });
+ else if (editor?.id) update.mutate({ id: editor.id, isActive, ...fields });
+ }
+
+ if (isLoading) return ;
+
+ return (
+
+
+
+ Erreichte Ränge bleiben erhalten, auch wenn du eine Schwelle später erhöhst. Senkst du eine Schwelle, bekommen
+ alle passenden Nutzer den Rang sofort, ohne Benachrichtigung.
+
+
setEditor({ mode: "create", id: null, initial: EMPTY_RANK })}>
+ Rang hinzufügen
+
+
+
+
+
+
+
+ Schwelle
+ Rang
+ Vorteile
+ Träger
+
+
+
+
+ {ranks.map((rank) => {
+ return (
+
+ {rank.threshold}
+
+
+
+ {rank.nameDe}
+ {!rank.isActive && inaktiv }
+
+ {rank.nameEn} · {rank.key}
+
+ {rank.perks.join(", ") || "–"}
+ {rank.holderCount}
+
+
+ setEditor({
+ mode: "edit",
+ id: rank.id,
+ initial: { ...rank, icon: rank.icon as ReferralIconKey, perks: rank.perks as ReferralPerkKey[] },
+ })
+ }
+ >
+
+
+
+
+ );
+ })}
+
+
+
+
+ {editor && (
+
setEditor(null)}
+ onSave={handleSave}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/admin/referrals/SettingsTab.tsx b/src/components/admin/referrals/SettingsTab.tsx
new file mode 100644
index 0000000..eec55cc
--- /dev/null
+++ b/src/components/admin/referrals/SettingsTab.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import { toast } from "sonner";
+import { useTRPC } from "@/trpc/client";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+
+type SettingsValues = {
+ programActive: boolean;
+ redeemWindowDays: number;
+ dailyQualifyCap: number;
+ autoMutualFollow: boolean;
+};
+
+export function SettingsTab() {
+ const trpc = useTRPC();
+ const { data, isLoading } = useQuery(trpc.referralAdmin.getSettings.queryOptions());
+ if (isLoading || !data) return ;
+ return ;
+}
+
+function SettingsForm({ initial }: { initial: SettingsValues }) {
+ const trpc = useTRPC();
+ const qc = useQueryClient();
+ const [form, setForm] = useState({
+ programActive: initial.programActive,
+ redeemWindowDays: initial.redeemWindowDays,
+ dailyQualifyCap: initial.dailyQualifyCap,
+ autoMutualFollow: initial.autoMutualFollow,
+ });
+ const set = (key: K, value: SettingsValues[K]) => setForm((f) => ({ ...f, [key]: value }));
+
+ const save = useMutation(
+ trpc.referralAdmin.updateSettings.mutationOptions({
+ onSuccess: () => {
+ toast.success("Einstellungen gespeichert");
+ void qc.invalidateQueries(trpc.referralAdmin.getSettings.queryFilter());
+ },
+ onError: (err) => toast.error(err.message),
+ })
+ );
+
+ return (
+
+ set("programActive", v)}
+ />
+ set("autoMutualFollow", v)}
+ />
+ set("redeemWindowDays", v)}
+ />
+ set("dailyQualifyCap", v)}
+ />
+ save.mutate(form)} disabled={save.isPending}>
+ {save.isPending ? "Wird gespeichert…" : "Einstellungen speichern"}
+
+
+ );
+}
+
+function ToggleRow({ title, hint, checked, onChange }: { title: string; hint: string; checked: boolean; onChange: (v: boolean) => void }) {
+ return (
+
+
+ {title}
+ {hint}
+
+
+
+ );
+}
+
+type NumberRowProps = { id: string; title: string; hint: string; value: number; min: number; max: number; onChange: (v: number) => void };
+
+function NumberRow({ id, title, hint, value, min, max, onChange }: NumberRowProps) {
+ return (
+
+
+ {title}
+ {hint}
+
+ onChange(Number(e.target.value))} className="w-24 text-right font-mono" />
+
+ );
+}
diff --git a/src/components/feed/PostCard.tsx b/src/components/feed/PostCard.tsx
index 2d2ef2e..1db9adb 100644
--- a/src/components/feed/PostCard.tsx
+++ b/src/components/feed/PostCard.tsx
@@ -81,7 +81,12 @@ type PostPet = {
name: string;
avatarKey: string | null;
isVerified?: boolean;
- owner?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
+ owner?: {
+ sponsorSince: Date | string | null;
+ sponsorPeriodEnd: Date | string | null;
+ referralRankId?: string | null;
+ referralPerks?: string[] | null;
+ } | null;
};
type PostMilestone = { milestoneType: import("@prisma/client").MilestoneType; notes: string | null };
@@ -248,7 +253,7 @@ function PostCardImpl({ post, onDeleted, variant = "feed" }: PostCardProps) {
{formatPetName(pet)}
-
+
{formatRelativeTime(createdAt)}
diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx
index 03a3b64..0637fe3 100644
--- a/src/components/layout/MobileNav.tsx
+++ b/src/components/layout/MobileNav.tsx
@@ -14,6 +14,7 @@ import {
Moon,
Sun,
Sparkles,
+ Gift,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { NotificationBell } from "@/components/notifications/NotificationBell";
@@ -79,6 +80,7 @@ export function MobileNav({ pets }: MobileNavProps) {
const isNotificationsPage = pathname.startsWith("/notifications");
const isMessagesPage = pathname.startsWith("/messages");
const isSponsorPage = pathname.startsWith("/unterstuetzen");
+ const isInvitePage = pathname.startsWith("/einladen");
return (
@@ -147,6 +149,13 @@ export function MobileNav({ pets }: MobileNavProps) {
+
{t("addPet")}
+
router.push("/einladen")}
+ className={`flex items-center gap-2 cursor-pointer ${isInvitePage ? "text-orange-500" : ""}`}
+ >
+
+ {t("invite")}
+
router.push("/unterstuetzen")}
className={`flex items-center gap-2 cursor-pointer ${isSponsorPage ? "text-orange-500" : ""}`}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index 2c2ff18..faf866a 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -17,6 +17,7 @@ import {
ChevronRight,
Plus,
Sparkles,
+ Gift,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
@@ -55,6 +56,7 @@ const navLinks = [
{ href: "/search", key: "search", icon: Search, active: true, special: false },
{ href: "/notifications", key: "notifications", icon: Bell, active: true, special: true },
{ href: "/messages", key: "messages", icon: MessageCircle, active: true, special: false },
+ { href: "/einladen", key: "invite", icon: Gift, active: true, special: false },
{ href: "/unterstuetzen", key: "sponsor", icon: Sparkles, active: true, special: false },
] as const;
diff --git a/src/components/referrals/AcquisitionStep.tsx b/src/components/referrals/AcquisitionStep.tsx
new file mode 100644
index 0000000..c62c923
--- /dev/null
+++ b/src/components/referrals/AcquisitionStep.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { useTranslations } from "next-intl";
+import { CheckCircle2 } from "lucide-react";
+import { useTRPC } from "@/trpc/client";
+import { Button } from "@/components/ui/button";
+import { RedeemCodeForm } from "@/components/referrals/RedeemCodeForm";
+import { forgetReferralCode, readRememberedReferralCode } from "@/components/referrals/RememberReferralCode";
+import { cn } from "@/lib/utils";
+
+const SOURCES = [
+ "FRIENDS",
+ "TIKTOK",
+ "INSTAGRAM",
+ "YOUTUBE",
+ "FACEBOOK",
+ "SHELTER_OR_PARTNER",
+ "STORE_SEARCH",
+ "PRESS",
+ "OTHER",
+] as const;
+type Source = (typeof SOURCES)[number];
+
+/**
+ * Onboarding-Schritt nach dem ersten Tier: freiwillige Herkunftsangabe und,
+ * bei „Freunde“, die Eingabe eines Empfehlungscodes (vorausgefüllt, wenn der
+ * neue Nutzer über /r/ kam). Überspringbar.
+ */
+export function AcquisitionStep({ canRedeem, onDone }: { canRedeem: boolean; onDone: () => void }) {
+ const trpc = useTRPC();
+ const t = useTranslations("ReferralOnboarding");
+ const [rememberedCode] = useState(readRememberedReferralCode);
+ const [source, setSource] = useState(rememberedCode ? "FRIENDS" : null);
+ const [inviterName, setInviterName] = useState(undefined);
+
+ const saveSource = useMutation(trpc.referrals.setAcquisitionSource.mutationOptions({ onSettled: onDone }));
+
+ function handleContinue() {
+ if (source) saveSource.mutate({ source });
+ else onDone();
+ }
+
+ function handleRedeemed(name: string | null) {
+ forgetReferralCode();
+ setInviterName(name);
+ }
+
+ const isRedeemed = inviterName !== undefined;
+
+ return (
+
+
+
{t("title")}
+
{t("subtitle")}
+
+
+
+ {SOURCES.map((option) => (
+ setSource(option)}
+ className={cn(
+ "rounded-full border px-4 py-2 text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-500",
+ source === option
+ ? "border-orange-500 bg-orange-50 text-orange-700 dark:bg-orange-950 dark:text-orange-300"
+ : "hover:bg-muted"
+ )}
+ >
+ {t(`source_${option}`)}
+
+ ))}
+
+
+ {source === "FRIENDS" && canRedeem && (
+
+ {isRedeemed ? (
+
+
+ {inviterName ? t("redeemed", { name: inviterName }) : t("redeemedGeneric")}
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ {t("continue")}
+
+
+ {t("skip")}
+
+
+
+ );
+}
diff --git a/src/components/referrals/InviteeList.tsx b/src/components/referrals/InviteeList.tsx
new file mode 100644
index 0000000..71f23b8
--- /dev/null
+++ b/src/components/referrals/InviteeList.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import Link from "next/link";
+import { useLocale, useTranslations } from "next-intl";
+import { PetAvatar } from "@/components/ui/pet-avatar";
+
+type Invitee = {
+ pet: { id: string; name: string; avatarKey: string | null } | null;
+ invitedAt: Date | string | null;
+ status: "counted" | "waiting" | "in-review" | "revoked";
+};
+
+const STATUS_CLASS: Record = {
+ counted: "text-green-700 dark:text-green-400",
+ waiting: "text-muted-foreground",
+ "in-review": "text-amber-700 dark:text-amber-400",
+ revoked: "text-muted-foreground line-through",
+};
+
+const STATUS_KEY: Record = {
+ counted: "statusCounted",
+ waiting: "statusWaiting",
+ "in-review": "statusInReview",
+ revoked: "statusRevoked",
+};
+
+/** Tiere, die über den eigenen Code gekommen sind, mit Status. */
+export function InviteeList({ invitees }: { invitees: Invitee[] }) {
+ const t = useTranslations("Referrals");
+ const locale = useLocale();
+
+ return (
+
+
+ {t("inviteesHeading")}
+
+ {invitees.length === 0 ? (
+ {t("inviteesEmpty")}
+ ) : (
+
+ {invitees.map((invitee, index) => (
+
+ {invitee.pet ? (
+
+
+ {invitee.pet.name}
+
+ ) : (
+ {t("inviteeNoPet")}
+ )}
+
+ {t(STATUS_KEY[invitee.status])}
+ {invitee.invitedAt && (
+
+ {new Date(invitee.invitedAt).toLocaleDateString(locale)}
+
+ )}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/referrals/RankLadder.tsx b/src/components/referrals/RankLadder.tsx
new file mode 100644
index 0000000..3a95945
--- /dev/null
+++ b/src/components/referrals/RankLadder.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Check } from "lucide-react";
+import { ReferralIcon } from "@/components/referrals/ReferralRankBadge";
+import type { ReferralRankView } from "@/hooks/useReferralRanks";
+import { cn } from "@/lib/utils";
+
+type Props = {
+ ranks: ReferralRankView[];
+ achievedRankIds: ReadonlySet;
+ qualifiedCount: number;
+ nextRankId: string | null;
+};
+
+/** Fortschritt zum nächsten Rang plus alle aktiven Ränge, erreichte markiert. */
+export function RankLadder({ ranks, achievedRankIds, qualifiedCount, nextRankId }: Props) {
+ const t = useTranslations("Referrals");
+ 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 (
+
+
+
+
+ {t("ranksHeading")}
+
+
+ {t("countedInvites", { count: qualifiedCount })}
+
+
+ {next ? (
+ <>
+
+
+ {t("nextRank", { remaining: Math.max(0, next.threshold - qualifiedCount), name: next.name })}
+
+ >
+ ) : (
+
{t("allRanksReached")}
+ )}
+
+
+
+ {activeRanks.map((rank) => {
+ const isAchieved = achievedRankIds.has(rank.id);
+ return (
+
+
+
+
+
+
{rank.name}
+
{rank.description}
+
+
+ {isAchieved ? (
+
+ ) : (
+ t("threshold", { count: rank.threshold })
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/referrals/RedeemCodeForm.tsx b/src/components/referrals/RedeemCodeForm.tsx
new file mode 100644
index 0000000..9b629bd
--- /dev/null
+++ b/src/components/referrals/RedeemCodeForm.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useTranslations } from "next-intl";
+import { Loader2 } from "lucide-react";
+import { useTRPC } from "@/trpc/client";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+/** Fehler-Schlüssel aus REFERRAL_ERRORS (src/lib/referrals.ts) → Übersetzungsschlüssel. */
+const ERROR_KEYS: Record = {
+ REFERRAL_PROGRAM_PAUSED: "errorPaused",
+ REFERRAL_CODE_INVALID: "errorInvalid",
+ REFERRAL_OWN_CODE: "errorOwnCode",
+ REFERRAL_ALREADY_REDEEMED: "errorAlreadyRedeemed",
+ REFERRAL_WINDOW_EXPIRED: "errorWindowExpired",
+ REFERRAL_NO_PET: "errorNoPet",
+};
+
+type Props = {
+ initialCode?: string;
+ onRedeemed?: (inviterPetName: string | null) => void;
+};
+
+export function RedeemCodeForm({ initialCode = "", onRedeemed }: Props) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const t = useTranslations("Referrals");
+ const [code, setCode] = useState(initialCode);
+ const [errorKey, setErrorKey] = useState(null);
+
+ const redeem = useMutation(
+ trpc.referrals.redeem.mutationOptions({
+ onSuccess: (result) => {
+ setErrorKey(null);
+ void queryClient.invalidateQueries(trpc.referrals.me.queryFilter());
+ onRedeemed?.(result.inviterPetName);
+ },
+ onError: (err) => {
+ const isRateLimited = err.data?.code === "TOO_MANY_REQUESTS";
+ setErrorKey(isRateLimited ? "errorTooMany" : (ERROR_KEYS[err.message] ?? "errorGeneric"));
+ },
+ })
+ );
+
+ function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ if (code.trim()) redeem.mutate({ code: code.trim() });
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/referrals/ReferralAvatarRing.tsx b/src/components/referrals/ReferralAvatarRing.tsx
new file mode 100644
index 0000000..fa35cd4
--- /dev/null
+++ b/src/components/referrals/ReferralAvatarRing.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useReferralRingColor, type ReferralOwnerFields } from "@/components/referrals/ReferralRankBadge";
+
+/**
+ * Ring in der Rangfarbe um das Tierbild im Profilkopf (Vorteil PROFILE_RING).
+ * Bewusst nur im Profil, nicht im Feed — dort steht der Ring für Storys.
+ */
+export function ReferralAvatarRing({ owner, children }: { owner: ReferralOwnerFields | null | undefined; children: ReactNode }) {
+ const color = useReferralRingColor(owner);
+ if (!color) return <>{children}>;
+ return (
+
+ );
+}
diff --git a/src/components/referrals/ReferralRankBadge.tsx b/src/components/referrals/ReferralRankBadge.tsx
new file mode 100644
index 0000000..cdcb4f5
--- /dev/null
+++ b/src/components/referrals/ReferralRankBadge.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { createElement, type ComponentProps } from "react";
+import { Award, Crown, Flame, Gem, Heart, Leaf, Medal, PawPrint, Rocket, Shield, Star, Trophy, type LucideIcon } from "lucide-react";
+import { useReferralRanks } from "@/hooks/useReferralRanks";
+import { cn } from "@/lib/utils";
+
+/** Schlüssel aus REFERRAL_ICON_KEYS (src/lib/referral-ranks.ts) → lucide-react. */
+const ICONS: Record = {
+ "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 } & ComponentProps) {
+ return createElement(ICONS[icon] ?? PawPrint, props);
+}
+
+export type ReferralOwnerFields = {
+ referralRankId?: string | null;
+ referralPerks?: readonly string[] | null;
+};
+
+const ICON_SIZE = { sm: "h-3 w-3", md: "h-3.5 w-3.5", lg: "h-4 w-4" } as const;
+
+/**
+ * Rang-Abzeichen neben dem Tiernamen. Sichtbar, sobald der Owner den Vorteil
+ * NAME_BADGE hat (kumulativ, siehe Owner.referralPerks); Farbe und Symbol
+ * kommen vom höchsten erreichten Rang.
+ */
+export function ReferralRankBadge({
+ owner,
+ size = "md",
+}: {
+ owner: ReferralOwnerFields | null | undefined;
+ size?: keyof typeof ICON_SIZE;
+}) {
+ const { byId } = useReferralRanks();
+ if (!owner?.referralRankId || !owner.referralPerks?.includes("NAME_BADGE")) return null;
+ const rank = byId.get(owner.referralRankId);
+ if (!rank) return null;
+
+ return (
+
+
+
+ );
+}
+
+/** Farbe des Profilrings (Vorteil PROFILE_RING) oder null. */
+export function useReferralRingColor(owner: ReferralOwnerFields | null | undefined): string | null {
+ const { byId } = useReferralRanks();
+ if (!owner?.referralRankId || !owner.referralPerks?.includes("PROFILE_RING")) return null;
+ return byId.get(owner.referralRankId)?.color ?? null;
+}
diff --git a/src/components/referrals/RememberReferralCode.tsx b/src/components/referrals/RememberReferralCode.tsx
new file mode 100644
index 0000000..e54d406
--- /dev/null
+++ b/src/components/referrals/RememberReferralCode.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { useEffect } from "react";
+
+export const REFERRAL_COOKIE = "pf_ref";
+const REMEMBER_DAYS = 7; // entspricht dem Standard-Einlösefenster
+
+/**
+ * Merkt sich den Code einer Einladungsseite, damit das Onboarding ihn
+ * vorausfüllen kann. Reine Komfortfunktion: geht das Cookie verloren (andere
+ * Browser, App-Registrierung), tippt der neue Nutzer den Code einfach ab.
+ */
+export function RememberReferralCode({ code }: { code: string }) {
+ useEffect(() => {
+ const maxAge = REMEMBER_DAYS * 24 * 60 * 60;
+ document.cookie = `${REFERRAL_COOKIE}=${encodeURIComponent(code)}; Max-Age=${maxAge}; Path=/; SameSite=Lax`;
+ }, [code]);
+ return null;
+}
+
+export function readRememberedReferralCode(): string {
+ if (typeof document === "undefined") return "";
+ const match = document.cookie.match(new RegExp(`(?:^|; )${REFERRAL_COOKIE}=([^;]*)`));
+ return match ? decodeURIComponent(match[1]) : "";
+}
+
+export function forgetReferralCode() {
+ document.cookie = `${REFERRAL_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`;
+}
diff --git a/src/components/referrals/ShareInviteCard.tsx b/src/components/referrals/ShareInviteCard.tsx
new file mode 100644
index 0000000..83998e2
--- /dev/null
+++ b/src/components/referrals/ShareInviteCard.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import { Check, Copy, Share2 } from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+
+const COPIED_RESET_MS = 2000;
+
+export function referralLink(code: string): string {
+ return `${window.location.origin}/r/${code}`;
+}
+
+/** Eigener Einladungslink mit Teilen (wo verfügbar) und Kopieren. */
+export function ShareInviteCard({ code }: { code: string }) {
+ const t = useTranslations("Referrals");
+ const [isCopied, setIsCopied] = useState(false);
+ const canShare = typeof navigator !== "undefined" && typeof navigator.share === "function";
+
+ function handleCopy() {
+ void navigator.clipboard.writeText(referralLink(code)).then(() => {
+ setIsCopied(true);
+ toast.success(t("copiedToast"));
+ setTimeout(() => setIsCopied(false), COPIED_RESET_MS);
+ });
+ }
+
+ async function handleShare() {
+ try {
+ await navigator.share({ title: t("shareTitle"), text: t("shareText", { code }), url: referralLink(code) });
+ } catch {
+ // Teilen-Dialog abgebrochen — nichts zu tun
+ }
+ }
+
+ return (
+
+
+
+ {t("shareHeading")}
+
+
{t("shareSubtitle")}
+
+
+
+
{t("yourCode")}
+
{code}
+
+
+
+ {canShare && (
+ void handleShare()} className="flex-1 min-w-[140px]">
+
+ {t("share")}
+
+ )}
+
+ {isCopied ? : }
+ {isCopied ? t("copied") : t("copyLink")}
+
+
+
+ );
+}
diff --git a/src/components/ui/pet-identity-badges.tsx b/src/components/ui/pet-identity-badges.tsx
index 13830be..f7e4d59 100644
--- a/src/components/ui/pet-identity-badges.tsx
+++ b/src/components/ui/pet-identity-badges.tsx
@@ -1,6 +1,7 @@
import { BadgeCheck, Sparkles } from "lucide-react";
import { isActiveSponsor } from "@/lib/sponsor";
import { cn } from "@/lib/utils";
+import { ReferralRankBadge, type ReferralOwnerFields } from "@/components/referrals/ReferralRankBadge";
type SponsorWindow = {
sponsorSince: Date | string | null;
@@ -11,6 +12,8 @@ type Props = {
isVerified?: boolean;
/** Omit entirely (rather than passing null) when the caller has no owner/sponsor data to show. */
sponsor?: SponsorWindow | null;
+ /** Owner-Felder referralRankId/referralPerks (petCardSelect.owner) — Rang-Abzeichen des Empfehlungsprogramms. */
+ referral?: ReferralOwnerFields | null;
size?: "sm" | "md" | "lg";
verifiedLabel?: string;
sponsorLabel?: string;
@@ -30,13 +33,14 @@ function isSponsorActive(sponsor: SponsorWindow): boolean {
}
/**
- * Verified checkmark + sponsor sparkle, shown inline next to a pet's name.
- * Renders nothing when neither applies. sponsor is optional — pass it only
+ * Verified checkmark + sponsor sparkle + referral rank badge, shown inline
+ * next to a pet's name. Renders nothing when none applies. sponsor is optional — pass it only
* where the caller actually has owner sponsor data (see pet-repository.ts).
*/
export function PetIdentityBadges({
isVerified,
sponsor,
+ referral,
size = "md",
verifiedLabel = "Verified",
sponsorLabel = "Sponsor",
@@ -44,7 +48,7 @@ export function PetIdentityBadges({
const iconClass = ICON_SIZE[size];
const showSponsor = sponsor != null && isSponsorActive(sponsor);
- if (!isVerified && !showSponsor) return null;
+ if (!isVerified && !showSponsor && !referral?.referralRankId) return null;
return (
<>
@@ -60,6 +64,7 @@ export function PetIdentityBadges({
aria-label={sponsorLabel}
/>
)}
+
>
);
}
diff --git a/src/hooks/useReferralRanks.ts b/src/hooks/useReferralRanks.ts
new file mode 100644
index 0000000..64ee7a4
--- /dev/null
+++ b/src/hooks/useReferralRanks.ts
@@ -0,0 +1,39 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { useLocale } from "next-intl";
+import { useTRPC } from "@/trpc/client";
+
+const RANKS_STALE_MS = 30 * 60 * 1000; // Ränge ändern sich selten — einmal laden, lange halten
+
+export type ReferralRankView = {
+ id: string;
+ key: string;
+ threshold: number;
+ name: string;
+ description: string;
+ icon: string;
+ color: string;
+ perks: string[];
+ isActive: boolean;
+};
+
+/** Rangliste des Empfehlungsprogramms in der aktuellen Sprache, für Abzeichen und Einladen-Seite. */
+export function useReferralRanks(): { ranks: ReferralRankView[]; byId: Map } {
+ const trpc = useTRPC();
+ const locale = useLocale();
+ const { data = [] } = useQuery({ ...trpc.referrals.ranks.queryOptions(), staleTime: RANKS_STALE_MS });
+
+ const ranks = data.map((rank) => ({
+ id: rank.id,
+ key: rank.key,
+ threshold: rank.threshold,
+ name: locale === "de" ? rank.nameDe : rank.nameEn,
+ description: locale === "de" ? 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])) };
+}
diff --git a/src/lib/notify.ts b/src/lib/notify.ts
index 9da48e9..e4cf60c 100644
--- a/src/lib/notify.ts
+++ b/src/lib/notify.ts
@@ -39,6 +39,8 @@ const PUSH_COPY: Record<
MENTION: { title: "Erwähnung", genericBody: "Du wurdest in einem Beitrag erwähnt.", actorBody: (a) => `${a} hat dich erwähnt.` },
PAW_BACK: { title: "Paw-Back", genericBody: "Jemand hat deinen Beitrag geteilt.", actorBody: (a) => `${a} hat deinen Beitrag geteilt.` },
PET_SUGGESTION: { title: "Vorschlag für dich", genericBody: "Ein Tier könnte dir gefallen.", actorBody: (a) => `${a} könnte dir gefallen!` },
+ REFERRAL_JOINED: { title: "Deine Einladung hat geklappt", genericBody: "Ein neues Tier ist über deine Einladung zu PawFeed gekommen.", actorBody: (a) => `${a} ist über deine Einladung zu PawFeed gekommen.` },
+ REFERRAL_RANK_UP: { title: "Neuer Rang", genericBody: "Du hast einen neuen Empfehlungsrang erreicht.", actorBody: () => "Du hast einen neuen Empfehlungsrang erreicht." },
};
/**
diff --git a/src/lib/referral-code.ts b/src/lib/referral-code.ts
new file mode 100644
index 0000000..f316aa3
--- /dev/null
+++ b/src/lib/referral-code.ts
@@ -0,0 +1,46 @@
+/**
+ * Persönliche Empfehlungscodes (Empfehlungsprogramm): bis zu 6 Buchstaben aus
+ * dem Namen des ersten Tiers, Bindestrich, 4 Zeichen — z. B. "BUBI-7K3M".
+ * Pure, ohne Server-Abhängigkeiten, damit Web-Client und Tests sie nutzen können.
+ */
+
+// Gleicher Zeichensatz wie src/lib/invite-codes.ts — ohne verwechselbare 0/1/I/O.
+const SUFFIX_CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
+const SUFFIX_LENGTH = 4;
+const MAX_PREFIX_LENGTH = 6;
+const MIN_PREFIX_LENGTH = 2;
+const FALLBACK_PREFIX = "PAWS";
+
+export const REFERRAL_CODE_PATTERN = /^[A-Z]{2,6}-[2-9A-HJ-NP-Z]{4}$/;
+
+const UMLAUTS: Record = { Ä: "AE", Ö: "OE", Ü: "UE", ẞ: "SS" };
+
+function toPrefix(petName: string | null | undefined): string {
+ if (!petName) return FALLBACK_PREFIX;
+ const ascii = petName
+ .toUpperCase()
+ .replace(/ß/g, "SS")
+ .replace(/[ÄÖÜẞ]/g, (ch) => UMLAUTS[ch] ?? "")
+ .normalize("NFD")
+ .replace(/[^A-Z]/g, "")
+ .slice(0, MAX_PREFIX_LENGTH);
+ return ascii.length >= MIN_PREFIX_LENGTH ? ascii : FALLBACK_PREFIX;
+}
+
+export function generateReferralCode(
+ petName: string | null | undefined,
+ random: () => number = Math.random
+): string {
+ const suffix = Array.from(
+ { length: SUFFIX_LENGTH },
+ () => SUFFIX_CHARS[Math.floor(random() * SUFFIX_CHARS.length)]
+ ).join("");
+ return `${toPrefix(petName)}-${suffix}`;
+}
+
+/** Normalisiert Nutzereingaben (" bubi-7k3m ") und gibt null zurück, wenn das Format nicht passt. */
+export function normalizeReferralCode(input: string | null | undefined): string | null {
+ if (!input) return null;
+ const code = input.trim().toUpperCase().replace(/\s+/g, "");
+ return REFERRAL_CODE_PATTERN.test(code) ? code : null;
+}
diff --git a/src/lib/referral-ranks.ts b/src/lib/referral-ranks.ts
new file mode 100644
index 0000000..2d09d36
--- /dev/null
+++ b/src/lib/referral-ranks.ts
@@ -0,0 +1,113 @@
+/**
+ * Reine Rang-Logik des Empfehlungsprogramms — ohne Prisma, damit Server,
+ * Web-Client und Tests dieselben Regeln nutzen (die Mobile-App hat ein
+ * Gegenstück in mobile/src/lib/referral-ranks.ts).
+ *
+ * Regeln:
+ * - Vorteile sind kumulativ über alle erreichten, aktiven Ränge.
+ * - Erreichte Ränge bleiben erhalten, auch wenn eine Schwelle später erhöht
+ * wird (Bestandsschutz) — "erreicht" kommt aus ReferralRankAchievement,
+ * nicht aus dem aktuellen Zähler.
+ * - Angezeigt wird der erreichte aktive Rang mit der höchsten Schwelle.
+ */
+
+export const REFERRAL_PERKS = [
+ "PROFILE_RING",
+ "NAME_BADGE",
+ "FEATURE_POOL",
+ "VERIFICATION_PRIORITY",
+ "CURATOR_SPOTLIGHT",
+ "EXPLORE_HIGHLIGHT",
+ "HALL_OF_FAME",
+ "EARLY_ACCESS",
+ "VOUCHER",
+] as const;
+export type ReferralPerkKey = (typeof REFERRAL_PERKS)[number];
+
+/** Vorteile, die schon Code haben. Die übrigen sind im Admin auswählbar, wirken aber noch nicht. */
+export const IMPLEMENTED_REFERRAL_PERKS: ReadonlySet = new Set([
+ "PROFILE_RING",
+ "NAME_BADGE",
+ "FEATURE_POOL",
+ "VERIFICATION_PRIORITY",
+ "CURATOR_SPOTLIGHT",
+]);
+
+/**
+ * Symbole, die es in lucide-react UND lucide-react-native gibt. Sparkles und
+ * BadgeCheck fehlen absichtlich — die stehen für Sponsor und Verifiziert.
+ */
+export const REFERRAL_ICON_KEYS = [
+ "paw-print",
+ "heart",
+ "shield",
+ "star",
+ "award",
+ "crown",
+ "gem",
+ "medal",
+ "trophy",
+ "flame",
+ "rocket",
+ "leaf",
+] as const;
+export type ReferralIconKey = (typeof REFERRAL_ICON_KEYS)[number];
+
+export const MAX_REFERRAL_RANKS = 20;
+export const REFERRAL_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
+
+export interface ReferralRankLike {
+ id: string;
+ threshold: number;
+ isActive: boolean;
+ perks: readonly string[];
+}
+
+function activeAchieved(
+ ranks: readonly T[],
+ achievedRankIds: ReadonlySet
+): T[] {
+ return ranks.filter((rank) => rank.isActive && achievedRankIds.has(rank.id));
+}
+
+/** Höchster erreichter aktiver Rang — das, was neben dem Namen angezeigt wird. */
+export function pickDisplayRank(
+ ranks: readonly T[],
+ achievedRankIds: ReadonlySet
+): T | null {
+ return activeAchieved(ranks, achievedRankIds).reduce(
+ (best, rank) => (best === null || rank.threshold > best.threshold ? rank : best),
+ null
+ );
+}
+
+/** Vereinigung der Vorteile aller erreichten aktiven Ränge. */
+export function collectPerks(
+ ranks: readonly ReferralRankLike[],
+ achievedRankIds: ReadonlySet
+): Set {
+ return new Set(activeAchieved(ranks, achievedRankIds).flatMap((rank) => rank.perks));
+}
+
+/** Ränge, die bei diesem Zähler neu dazukommen (aktiv, Schwelle erreicht, noch nicht erreicht). */
+export function ranksToAward(
+ ranks: readonly T[],
+ count: number,
+ achievedRankIds: ReadonlySet
+): T[] {
+ return ranks.filter(
+ (rank) => rank.isActive && rank.threshold <= count && !achievedRankIds.has(rank.id)
+ );
+}
+
+/** Nächster noch nicht erreichter aktiver Rang, für die Fortschrittsanzeige. */
+export function pickNextRank(
+ ranks: readonly T[],
+ achievedRankIds: ReadonlySet
+): T | null {
+ return (
+ ranks
+ .filter((rank) => rank.isActive && !achievedRankIds.has(rank.id))
+ .sort((a, b) => a.threshold - b.threshold)[0] ?? null
+ );
+}
diff --git a/src/lib/referral-recompute.ts b/src/lib/referral-recompute.ts
new file mode 100644
index 0000000..242195e
--- /dev/null
+++ b/src/lib/referral-recompute.ts
@@ -0,0 +1,45 @@
+import type { Prisma } from "@prisma/client";
+
+// Bewusst ohne "server-only": wird auch von prisma/scripts/referral-setup.ts
+// (plain Node via tsx) genutzt. Enthält nur Datenbankzugriffe, keine Geheimnisse.
+
+/**
+ * Nach jeder Rang-Änderung im Admin: fehlende Achievements für alle Owner
+ * anlegen (Bestandsschutz: nichts wird entfernt) und beide Caches neu setzen.
+ * Mengenbasiert in SQL, damit es auch bei 100.000 Ownern eine Abfrage bleibt.
+ * Verschickt bewusst keine Benachrichtigungen (keine Push-Welle).
+ */
+export async function recomputeReferralRanks(tx: Pick): Promise {
+ await tx.$executeRaw`
+ INSERT INTO "ReferralRankAchievement" ("id", "ownerId", "rankId", "achievedAt")
+ SELECT gen_random_uuid()::text, o."id", r."id", now()
+ FROM "Owner" o
+ JOIN "ReferralRank" r ON r."isActive" AND o."qualifiedReferralCount" >= r."threshold"
+ WHERE o."qualifiedReferralCount" > 0
+ ON CONFLICT ("ownerId", "rankId") DO NOTHING`;
+
+ await tx.$executeRaw`
+ UPDATE "Owner" o
+ SET "referralRankId" = x."rankId", "referralPerks" = x."perks"
+ FROM (
+ SELECT a."ownerId",
+ (array_agg(r."id" ORDER BY r."threshold" DESC))[1] AS "rankId",
+ COALESCE(array_agg(DISTINCT p) FILTER (WHERE p IS NOT NULL), '{}') AS "perks"
+ FROM "ReferralRankAchievement" a
+ JOIN "ReferralRank" r ON r."id" = a."rankId" AND r."isActive"
+ LEFT JOIN LATERAL unnest(r."perks") AS p ON true
+ GROUP BY a."ownerId"
+ ) x
+ WHERE o."id" = x."ownerId"`;
+
+ // Owner, deren erreichte Ränge alle deaktiviert wurden, verlieren nur den Cache — nicht die Achievements.
+ await tx.$executeRaw`
+ UPDATE "Owner" o
+ SET "referralRankId" = NULL, "referralPerks" = '{}'
+ WHERE (o."referralRankId" IS NOT NULL OR cardinality(o."referralPerks") > 0)
+ AND NOT EXISTS (
+ SELECT 1 FROM "ReferralRankAchievement" a
+ JOIN "ReferralRank" r ON r."id" = a."rankId" AND r."isActive"
+ WHERE a."ownerId" = o."id"
+ )`;
+}
diff --git a/src/lib/referrals.ts b/src/lib/referrals.ts
new file mode 100644
index 0000000..92bb6e3
--- /dev/null
+++ b/src/lib/referrals.ts
@@ -0,0 +1,321 @@
+import "server-only";
+import { Prisma, type PrismaClient, type ReferralPerk } from "@prisma/client";
+import { TRPCError } from "@trpc/server";
+import { redis } from "@/lib/redis";
+import { backfillOnFollow } from "@/lib/feed-helpers";
+import { notify } from "@/lib/notify";
+import { generateReferralCode, normalizeReferralCode } from "@/lib/referral-code";
+import { collectPerks, pickDisplayRank, ranksToAward } from "@/lib/referral-ranks";
+
+/**
+ * Empfehlungsprogramm — Server-Logik. Bewusst unabhängig von Clerk: ein Code
+ * wird erst eingelöst, wenn der Owner schon angemeldet ist und ein Tier hat
+ * (Onboarding-Schritt bzw. /einladen). Clerk liefert nur die userId.
+ *
+ * Ablauf: redeem → (erster Beitrag) → maybeQualifyReferral → Zähler +1 beim
+ * Einladenden → awardRanks → ggf. REFERRAL_RANK_UP.
+ */
+
+type Db = PrismaClient | Prisma.TransactionClient;
+
+export const REFERRAL_ERRORS = {
+ PROGRAM_PAUSED: "REFERRAL_PROGRAM_PAUSED",
+ CODE_INVALID: "REFERRAL_CODE_INVALID",
+ OWN_CODE: "REFERRAL_OWN_CODE",
+ ALREADY_REDEEMED: "REFERRAL_ALREADY_REDEEMED",
+ WINDOW_EXPIRED: "REFERRAL_WINDOW_EXPIRED",
+ NO_PET: "REFERRAL_NO_PET",
+} as const;
+
+export const DEFAULT_REFERRAL_SETTINGS = {
+ programActive: true,
+ redeemWindowDays: 7,
+ dailyQualifyCap: 20,
+ autoMutualFollow: true,
+} as const;
+
+export type ReferralSettingsValues = {
+ programActive: boolean;
+ redeemWindowDays: number;
+ dailyQualifyCap: number;
+ autoMutualFollow: boolean;
+};
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+const CODE_GENERATION_ATTEMPTS = 5;
+
+/** Bann oder Shadowban zählt, solange er nicht abgelaufen ist (expiresAt null = dauerhaft). */
+function isSanctionActive(sanction: { expiresAt: Date | null } | null, now: Date): boolean {
+ return !!sanction && (sanction.expiresAt === null || sanction.expiresAt > now);
+}
+
+export async function getReferralSettings(prisma: Db): Promise {
+ const row = await prisma.referralSettings.findUnique({ where: { id: 1 } });
+ return row ?? { ...DEFAULT_REFERRAL_SETTINGS };
+}
+
+/** Ältestes Tier eines Owners — Absender/Empfänger für Empfehlungs-Benachrichtigungen. */
+async function firstPetOf(prisma: Db, ownerId: string) {
+ return prisma.pet.findFirst({
+ where: { ownerId },
+ orderBy: { createdAt: "asc" },
+ select: { id: true, name: true },
+ });
+}
+
+// ─── Codes ───────────────────────────────────────────────────────────────────
+
+/** Gibt den Code des Owners zurück und legt ihn bei Bedarf an (Präfix aus dem ersten Tiernamen). */
+export async function ensureReferralCode(prisma: PrismaClient, ownerId: string): Promise {
+ const owner = await prisma.owner.findUnique({ where: { id: ownerId }, select: { referralCode: true } });
+ if (owner?.referralCode) return owner.referralCode;
+
+ const pet = await firstPetOf(prisma, ownerId);
+ for (let attempt = 0; attempt < CODE_GENERATION_ATTEMPTS; attempt++) {
+ try {
+ // count 0 heißt: parallel schon gesetzt oder Owner fehlt — beides klärt das Neulesen unten.
+ await prisma.owner.updateMany({
+ where: { id: ownerId, referralCode: null },
+ data: { referralCode: generateReferralCode(pet?.name) },
+ });
+ break;
+ } catch (err) {
+ const isCollision = err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
+ if (!isCollision) throw err;
+ }
+ }
+
+ const fresh = await prisma.owner.findUnique({ where: { id: ownerId }, select: { referralCode: true } });
+ if (!fresh?.referralCode) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Could not create a referral code." });
+ }
+ return fresh.referralCode;
+}
+
+// ─── Einlösen ────────────────────────────────────────────────────────────────
+
+function fail(code: TRPCError["code"], message: string): never {
+ throw new TRPCError({ code, message });
+}
+
+async function followIfAllowed(prisma: PrismaClient, followerPetId: string, followeePetId: string) {
+ await prisma.follow.upsert({
+ where: { followerPetId_followeePetId: { followerPetId, followeePetId } },
+ create: { followerPetId, followeePetId },
+ update: {},
+ });
+ await backfillOnFollow(redis, followerPetId, followeePetId, prisma).catch((err) =>
+ console.error("[referrals] feed backfill failed", err)
+ );
+}
+
+/** Gegenseitiges Folgen nach dem Einlösen — nie blockierend, Blockaden werden respektiert. */
+async function connectPets(prisma: PrismaClient, inviterPetId: string, inviteePetId: string) {
+ const block = await prisma.block.findFirst({
+ where: {
+ OR: [
+ { blockerPetId: inviterPetId, blockedPetId: inviteePetId },
+ { blockerPetId: inviteePetId, blockedPetId: inviterPetId },
+ ],
+ },
+ });
+ if (block) return;
+ await followIfAllowed(prisma, inviteePetId, inviterPetId);
+ await followIfAllowed(prisma, inviterPetId, inviteePetId);
+ // Der Eingeladene erfährt es als normalen Follow; der Einladende bekommt REFERRAL_JOINED (unten).
+ notify(prisma, { recipientPetId: inviteePetId, type: "FOLLOW", actorPetId: inviterPetId });
+}
+
+export async function redeemReferralCode(
+ prisma: PrismaClient,
+ ownerId: string,
+ rawCode: string,
+ now: Date = new Date()
+): Promise<{ inviterPetName: string | null }> {
+ const settings = await getReferralSettings(prisma);
+ if (!settings.programActive) fail("PRECONDITION_FAILED", REFERRAL_ERRORS.PROGRAM_PAUSED);
+
+ const code = normalizeReferralCode(rawCode);
+ if (!code) fail("NOT_FOUND", REFERRAL_ERRORS.CODE_INVALID);
+
+ const owner = await prisma.owner.findUnique({
+ where: { id: ownerId },
+ select: { createdAt: true, invitedById: true },
+ });
+ if (!owner) fail("PRECONDITION_FAILED", REFERRAL_ERRORS.NO_PET);
+ if (owner.invitedById) fail("CONFLICT", REFERRAL_ERRORS.ALREADY_REDEEMED);
+ if (now.getTime() - owner.createdAt.getTime() > settings.redeemWindowDays * DAY_MS) {
+ fail("PRECONDITION_FAILED", REFERRAL_ERRORS.WINDOW_EXPIRED);
+ }
+
+ const inviter = await prisma.owner.findUnique({
+ where: { referralCode: code },
+ select: { id: true, invitedById: true, ban: { select: { expiresAt: true } } },
+ });
+ // Gebannte Einladende und direkte Kreis-Einladungen (A lädt B, B lädt A) wirken wie ein ungültiger Code.
+ if (!inviter || isSanctionActive(inviter.ban, now) || inviter.invitedById === ownerId) fail("NOT_FOUND", REFERRAL_ERRORS.CODE_INVALID);
+ if (inviter.id === ownerId) fail("BAD_REQUEST", REFERRAL_ERRORS.OWN_CODE);
+
+ const inviteePet = await firstPetOf(prisma, ownerId);
+ if (!inviteePet) fail("PRECONDITION_FAILED", REFERRAL_ERRORS.NO_PET);
+
+ // Bewacht mit invitedById: null — zwei parallele Einlösungen können nicht beide gewinnen.
+ const claimed = await prisma.owner.updateMany({
+ where: { id: ownerId, invitedById: null },
+ data: { invitedById: inviter.id, invitedAt: now },
+ });
+ if (claimed.count === 0) fail("CONFLICT", REFERRAL_ERRORS.ALREADY_REDEEMED);
+
+ const inviterPet = await firstPetOf(prisma, inviter.id);
+ if (inviterPet) {
+ if (settings.autoMutualFollow) {
+ await connectPets(prisma, inviterPet.id, inviteePet.id).catch((err) =>
+ console.error("[referrals] mutual follow failed", err)
+ );
+ }
+ notify(prisma, { recipientPetId: inviterPet.id, type: "REFERRAL_JOINED", actorPetId: inviteePet.id });
+ }
+
+ // Wer erst nach dem ersten Beitrag einlöst (z. B. später über /einladen), zählt sofort.
+ await maybeQualifyReferral(prisma, ownerId, now).catch((err) =>
+ console.error("[referrals] qualification after redeem failed", err)
+ );
+
+ return { inviterPetName: inviterPet?.name ?? null };
+}
+
+// ─── Qualifizieren ───────────────────────────────────────────────────────────
+
+/** Ein echter eigener Beitrag: Foto, oder Video, das bei Mux fertig verarbeitet ist. */
+async function hasQualifyingPost(prisma: Db, ownerId: string): Promise {
+ const count = await prisma.post.count({
+ where: {
+ pet: { ownerId },
+ OR: [{ type: "PHOTO" }, { type: "VIDEO", videoPost: { status: "READY" } }],
+ },
+ });
+ return count > 0;
+}
+
+export type QualifyResult = "not-invited" | "already-done" | "no-post" | "flagged" | "qualified";
+
+/**
+ * Nach jedem eigenen Beitrag aufrufen (posts.create, Mux-Webhook READY).
+ * Billig im Normalfall: ein findUnique, dann früher Abbruch.
+ */
+export async function maybeQualifyReferral(
+ prisma: PrismaClient,
+ ownerId: string,
+ now: Date = new Date()
+): Promise {
+ const owner = await prisma.owner.findUnique({
+ where: { id: ownerId },
+ select: {
+ invitedById: true,
+ referralQualifiedAt: true,
+ referralFlaggedAt: true,
+ referralRevokedAt: true,
+ ban: { select: { expiresAt: true } },
+ shadowban: { select: { expiresAt: true } },
+ },
+ });
+ if (!owner?.invitedById) return "not-invited";
+ if (owner.referralQualifiedAt || owner.referralFlaggedAt || owner.referralRevokedAt) return "already-done";
+ if (isSanctionActive(owner.ban, now) || isSanctionActive(owner.shadowban, now)) return "already-done";
+ if (!(await hasQualifyingPost(prisma, ownerId))) return "no-post";
+
+ const inviterId = owner.invitedById;
+ const settings = await getReferralSettings(prisma);
+ const capWindowStart = new Date(now.getTime() - DAY_MS); // gleitende 24 Stunden
+
+ const outcome = await prisma.$transaction(async (tx) => {
+ const qualifiedToday = await tx.owner.count({
+ where: { invitedById: inviterId, referralQualifiedAt: { gte: capWindowStart } },
+ });
+ const pending = { id: ownerId, referralQualifiedAt: null, referralFlaggedAt: null, referralRevokedAt: null };
+
+ if (qualifiedToday >= settings.dailyQualifyCap) {
+ await tx.owner.updateMany({ where: pending, data: { referralFlaggedAt: now } });
+ return { status: "flagged" as const, newRanks: [] };
+ }
+
+ const claimed = await tx.owner.updateMany({ where: pending, data: { referralQualifiedAt: now } });
+ if (claimed.count === 0) return { status: "already-done" as const, newRanks: [] };
+
+ const newRanks = await creditInviter(tx, inviterId);
+ return { status: "qualified" as const, newRanks };
+ });
+
+ if (outcome.newRanks.length > 0) await notifyRankUp(prisma, inviterId);
+ return outcome.status;
+}
+
+/** Zähler des Einladenden erhöhen und neue Ränge vergeben. Gelöschte Einladende werden still übersprungen. */
+export async function creditInviter(tx: Prisma.TransactionClient, inviterId: string) {
+ const updated = await tx.owner.updateMany({
+ where: { id: inviterId },
+ data: { qualifiedReferralCount: { increment: 1 } },
+ });
+ if (updated.count === 0) return [];
+ const inviter = await tx.owner.findUnique({
+ where: { id: inviterId },
+ select: { qualifiedReferralCount: true },
+ });
+ return awardRanks(tx, inviterId, inviter?.qualifiedReferralCount ?? 0);
+}
+
+// ─── Ränge ───────────────────────────────────────────────────────────────────
+
+/**
+ * Vergibt fehlende Ränge für einen Owner und aktualisiert den Cache
+ * (referralRankId, referralPerks). Entzieht nie etwas (Bestandsschutz).
+ * Gibt die neu vergebenen Ränge zurück.
+ */
+export async function awardRanks(tx: Prisma.TransactionClient, ownerId: string, count: number) {
+ const ranks = await tx.referralRank.findMany({ orderBy: { threshold: "asc" } });
+ const existing = await tx.referralRankAchievement.findMany({ where: { ownerId }, select: { rankId: true } });
+ const achieved = new Set(existing.map((a) => a.rankId));
+
+ const newRanks = ranksToAward(ranks, count, achieved);
+ if (newRanks.length > 0) {
+ await tx.referralRankAchievement.createMany({
+ data: newRanks.map((rank) => ({ ownerId, rankId: rank.id })),
+ skipDuplicates: true,
+ });
+ newRanks.forEach((rank) => achieved.add(rank.id));
+ }
+
+ await refreshRankCache(tx, ownerId, ranks, achieved);
+ return newRanks;
+}
+
+/** Setzt Owner.referralRankId und Owner.referralPerks aus den erreichten Rängen. */
+export async function refreshRankCache(
+ tx: Prisma.TransactionClient,
+ ownerId: string,
+ ranks: { id: string; threshold: number; isActive: boolean; perks: ReferralPerk[] }[],
+ achieved: ReadonlySet
+) {
+ const display = pickDisplayRank(ranks, achieved);
+ const perks = [...collectPerks(ranks, achieved)] as ReferralPerk[];
+ await tx.owner.update({
+ where: { id: ownerId },
+ data: { referralRankId: display?.id ?? null, referralPerks: perks },
+ });
+}
+
+/** Cache eines Owners aus seinen gespeicherten Achievements neu aufbauen (z. B. nach einem Rang-Entzug). */
+export async function rebuildOwnerRankCache(tx: Prisma.TransactionClient, ownerId: string) {
+ const [ranks, achievements] = await Promise.all([
+ tx.referralRank.findMany({ orderBy: { threshold: "asc" } }),
+ tx.referralRankAchievement.findMany({ where: { ownerId }, select: { rankId: true } }),
+ ]);
+ await refreshRankCache(tx, ownerId, ranks, new Set(achievements.map((a) => a.rankId)));
+}
+
+async function notifyRankUp(prisma: PrismaClient, ownerId: string) {
+ const pet = await firstPetOf(prisma, ownerId);
+ if (pet) notify(prisma, { recipientPetId: pet.id, type: "REFERRAL_RANK_UP" });
+}
+
+export { recomputeReferralRanks } from "@/lib/referral-recompute";
diff --git a/src/proxy.ts b/src/proxy.ts
index c25f30a..226403e 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -28,6 +28,7 @@ const isPublicRoute = createRouteMatcher([
"/ueber-uns",
"/kinderschutz",
"/join(.*)",
+ "/r/(.*)", // öffentliche Einladungsseite des Empfehlungsprogramms
"/api/webhooks/(.*)",
"/api/auth/(.*)",
"/api/cron/(.*)",
diff --git a/src/repositories/pet-repository.ts b/src/repositories/pet-repository.ts
index 3d730a9..a047fea 100644
--- a/src/repositories/pet-repository.ts
+++ b/src/repositories/pet-repository.ts
@@ -14,13 +14,14 @@ export const petIdentitySelect = {
/**
* Pet identity plus the fields a feed/explore/profile card renders alongside
- * the name: the verified checkmark and the owner's sponsor window (consumed
- * via isActiveSponsor() in src/lib/sponsor.ts).
+ * the name: the verified checkmark, the owner's sponsor window (consumed
+ * via isActiveSponsor() in src/lib/sponsor.ts) and the owner's cached
+ * referral rank + perks (resolved client-side against referrals.ranks).
*/
export const petCardSelect = {
...petIdentitySelect,
isVerified: true,
- owner: { select: { sponsorSince: true, sponsorPeriodEnd: true } },
+ owner: { select: { sponsorSince: true, sponsorPeriodEnd: true, referralRankId: true, referralPerks: true } },
} satisfies Prisma.PetSelect;
export type PetIdentity = Prisma.PetGetPayload<{ select: typeof petIdentitySelect }>;
diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts
index 80f2e6b..965092b 100644
--- a/src/trpc/routers/_app.ts
+++ b/src/trpc/routers/_app.ts
@@ -26,6 +26,8 @@ import { sponsorRouter } from "@/trpc/routers/sponsor";
import { statsRouter } from "@/trpc/routers/stats";
import { broadcastRouter } from "@/trpc/routers/broadcast";
import { insightsRouter } from "@/trpc/routers/insights";
+import { referralsRouter } from "@/trpc/routers/referrals";
+import { referralAdminRouter } from "@/trpc/routers/referral-admin";
export const appRouter = router({
pets: petsRouter,
@@ -56,6 +58,8 @@ export const appRouter = router({
stats: statsRouter,
broadcast: broadcastRouter,
insights: insightsRouter,
+ referrals: referralsRouter,
+ referralAdmin: referralAdminRouter,
});
// Export the type for use in the client
diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts
index 8bd8b34..061f77b 100644
--- a/src/trpc/routers/admin.ts
+++ b/src/trpc/routers/admin.ts
@@ -878,6 +878,7 @@ export const adminRouter = router({
select: {
ban: { select: { reason: true, expiresAt: true } },
_count: { select: { warnings: true } },
+ referralPerks: true,
},
},
},
@@ -889,7 +890,11 @@ export const adminRouter = router({
});
const hasNext = requests.length > input.limit;
const items = hasNext ? requests.slice(0, input.limit) : requests;
- return { requests: items, nextCursor: hasNext ? items[items.length - 1]!.id : null };
+ // Empfehlungsprogramm-Vorteil VERIFICATION_PRIORITY: innerhalb der geladenen Seite nach oben sortieren.
+ // Der Cursor bleibt die letzte ID der ursprünglichen Reihenfolge, damit die Paginierung stimmt.
+ const hasPriority = (r: (typeof items)[number]) => r.pet.owner.referralPerks.includes("VERIFICATION_PRIORITY");
+ const sorted = [...items.filter(hasPriority), ...items.filter((r) => !hasPriority(r))];
+ return { requests: sorted, nextCursor: hasNext ? items[items.length - 1]!.id : null };
}),
// The checklist IS the decision — there's no separate approve/reject
diff --git a/src/trpc/routers/pets.ts b/src/trpc/routers/pets.ts
index 5c6af87..871cccc 100644
--- a/src/trpc/routers/pets.ts
+++ b/src/trpc/routers/pets.ts
@@ -125,12 +125,25 @@ export const petsRouter = router({
include: {
species: { include: { translations: { where: { locale: ctx.locale } } } },
breed: { include: { translations: { where: { locale: ctx.locale } } } },
- owner: petCardSelect.owner,
+ owner: { select: { ...petCardSelect.owner.select, invitedById: true } },
},
});
if (!pet) throw new TRPCError({ code: "NOT_FOUND" });
+
+ // Empfehlungsprogramm: "Eingeladen von " — nur das Tier verlassen den Server, nie die Clerk-ID.
+ const { invitedById, ...owner } = pet.owner;
+ const invitedByPet = invitedById
+ ? await ctx.prisma.pet.findFirst({
+ where: { ownerId: invitedById },
+ orderBy: { createdAt: "asc" },
+ select: { id: true, name: true },
+ })
+ : null;
+
return {
...pet,
+ owner,
+ invitedByPet,
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
breed: pet.breed
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts
index 1ec15ff..2b35afa 100644
--- a/src/trpc/routers/posts.ts
+++ b/src/trpc/routers/posts.ts
@@ -16,6 +16,7 @@ import { checkAgainstBlacklist } from "@/lib/blacklist";
import { createMentionNotifications } from "@/lib/mention-helpers";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
import { petIdentitySelect } from "@/repositories/pet-repository";
+import { maybeQualifyReferral } from "@/lib/referrals";
/**
* Posts tRPC router — photo post creation, caption edit, deletion, feed hydration.
@@ -141,6 +142,11 @@ export const postsRouter = router({
console.error("[posts] feed fan-out failed", err)
);
+ // Empfehlungsprogramm: der erste eigene Beitrag macht eine Einladung gültig — never fails post creation
+ await maybeQualifyReferral(ctx.prisma, ctx.userId).catch((err) =>
+ console.error("[posts] referral qualification failed", err)
+ );
+
// Index hashtags — never fails post creation
const tags = extractHashtags(input.caption ?? null);
await saveHashtags(ctx.prisma, post.id, tags).catch((err) =>
diff --git a/src/trpc/routers/referral-admin.ts b/src/trpc/routers/referral-admin.ts
new file mode 100644
index 0000000..33d7868
--- /dev/null
+++ b/src/trpc/routers/referral-admin.ts
@@ -0,0 +1,261 @@
+import "server-only";
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { AdminRoleType, Prisma } from "@prisma/client";
+import { router, protectedProcedure } from "@/trpc/init";
+import { assertAdmin } from "@/trpc/routers/admin";
+import { creditInviter, rebuildOwnerRankCache, recomputeReferralRanks } from "@/lib/referrals";
+import { MAX_REFERRAL_RANKS, REFERRAL_COLOR_PATTERN, REFERRAL_ICON_KEYS, REFERRAL_PERKS } from "@/lib/referral-ranks";
+
+/**
+ * Admin-Seite „Empfehlungen“ (/p/[secret]/referrals). Lesen ab MODERATOR,
+ * Ändern nur SUPER_ADMIN. Jede Änderung landet im ModerationLog. Nach jeder
+ * Rang-Änderung werden alle Owner in derselben Transaktion neu berechnet
+ * (Bestandsschutz: erreichte Ränge werden nie entfernt).
+ */
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+const LIST_LIMIT = 30;
+
+const rankFields = z.object({
+ nameDe: z.string().trim().min(1).max(40),
+ nameEn: z.string().trim().min(1).max(40),
+ descriptionDe: z.string().trim().max(200),
+ descriptionEn: z.string().trim().max(200),
+ threshold: z.number().int().min(1).max(100_000),
+ icon: z.enum(REFERRAL_ICON_KEYS),
+ color: z.string().regex(REFERRAL_COLOR_PATTERN),
+ perks: z.array(z.enum(REFERRAL_PERKS)).max(REFERRAL_PERKS.length),
+});
+
+const firstPetSelect = {
+ pets: { orderBy: { createdAt: "asc" as const }, take: 1, select: { id: true, name: true, avatarKey: true } },
+};
+
+function rethrowUniqueConflict(err: unknown): never {
+ if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
+ throw new TRPCError({ code: "CONFLICT", message: "Schwelle oder Schlüssel ist schon vergeben." });
+ }
+ throw err;
+}
+
+export const referralAdminRouter = router({
+ listRanks: protectedProcedure.query(async ({ ctx }) => {
+ await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
+ const [ranks, holders] = await Promise.all([
+ ctx.prisma.referralRank.findMany({ orderBy: { threshold: "asc" } }),
+ ctx.prisma.referralRankAchievement.groupBy({ by: ["rankId"], _count: { _all: true } }),
+ ]);
+ const holderCount = new Map(holders.map((h) => [h.rankId, h._count._all]));
+ return ranks.map((rank) => ({ ...rank, holderCount: holderCount.get(rank.id) ?? 0 }));
+ }),
+
+ createRank: protectedProcedure
+ .input(rankFields.extend({ key: z.string().regex(/^[a-z0-9-]{2,40}$/) }))
+ .mutation(async ({ ctx, input }) => {
+ const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
+ if ((await ctx.prisma.referralRank.count()) >= MAX_REFERRAL_RANKS) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: `Höchstens ${MAX_REFERRAL_RANKS} Ränge.` });
+ }
+ return ctx.prisma
+ .$transaction(async (tx) => {
+ const rank = await tx.referralRank.create({ data: input });
+ await recomputeReferralRanks(tx);
+ await tx.moderationLog.create({
+ data: { action: "CREATE_REFERRAL_RANK", targetType: "SYSTEM", targetId: rank.id, reason: `${input.key} ab ${input.threshold}`, moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
+ });
+ return rank;
+ })
+ .catch(rethrowUniqueConflict);
+ }),
+
+ updateRank: protectedProcedure
+ .input(rankFields.partial().extend({ id: z.string().min(1), isActive: z.boolean().optional() }))
+ .mutation(async ({ ctx, input }) => {
+ const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
+ const { id, ...data } = input;
+ return ctx.prisma
+ .$transaction(async (tx) => {
+ const rank = await tx.referralRank.update({ where: { id }, data });
+ await recomputeReferralRanks(tx);
+ const action = data.isActive === false ? "DEACTIVATE_REFERRAL_RANK" : "UPDATE_REFERRAL_RANK";
+ await tx.moderationLog.create({
+ data: { action, targetType: "SYSTEM", targetId: id, reason: `${rank.key}: ${Object.keys(data).join(", ")}`, moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
+ });
+ return rank;
+ })
+ .catch(rethrowUniqueConflict);
+ }),
+
+ getSettings: protectedProcedure.query(async ({ ctx }) => {
+ await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
+ return ctx.prisma.referralSettings.upsert({ where: { id: 1 }, create: { id: 1 }, update: {} });
+ }),
+
+ updateSettings: protectedProcedure
+ .input(
+ z.object({
+ programActive: z.boolean(),
+ redeemWindowDays: z.number().int().min(1).max(90),
+ dailyQualifyCap: z.number().int().min(1).max(1000),
+ autoMutualFollow: z.boolean(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
+ const [settings] = await ctx.prisma.$transaction([
+ ctx.prisma.referralSettings.upsert({
+ where: { id: 1 },
+ create: { id: 1, ...input, updatedBy: ctx.userId },
+ update: { ...input, updatedBy: ctx.userId },
+ }),
+ ctx.prisma.moderationLog.create({
+ data: { action: "UPDATE_REFERRAL_SETTINGS", targetType: "SYSTEM", targetId: "referral-settings", reason: JSON.stringify(input), moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
+ }),
+ ]);
+ return settings;
+ }),
+
+ overview: protectedProcedure.query(async ({ ctx }) => {
+ await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
+ const since7 = new Date(Date.now() - 7 * DAY_MS);
+ const since30 = new Date(Date.now() - 30 * DAY_MS);
+ const [redeemed7, redeemed30, qualified7, qualified30, ownersWithPet, sources, topInviters, recent, flagged] =
+ await Promise.all([
+ ctx.prisma.owner.count({ where: { invitedAt: { gte: since7 } } }),
+ ctx.prisma.owner.count({ where: { invitedAt: { gte: since30 } } }),
+ ctx.prisma.owner.count({ where: { referralQualifiedAt: { gte: since7 } } }),
+ ctx.prisma.owner.count({ where: { referralQualifiedAt: { gte: since30 } } }),
+ ctx.prisma.owner.count({ where: { pets: { some: {} } } }),
+ ctx.prisma.owner.groupBy({ by: ["acquisitionSource"], _count: { _all: true } }),
+ ctx.prisma.owner.findMany({
+ where: { qualifiedReferralCount: { gt: 0 } },
+ orderBy: { qualifiedReferralCount: "desc" },
+ take: LIST_LIMIT,
+ select: { id: true, qualifiedReferralCount: true, referralRankId: true, referralPerks: true, ...firstPetSelect },
+ }),
+ ctx.prisma.owner.findMany({
+ where: { invitedAt: { not: null } },
+ orderBy: { invitedAt: "desc" },
+ take: LIST_LIMIT,
+ select: {
+ id: true, invitedAt: true, invitedById: true, registrationIp: true,
+ referralQualifiedAt: true, referralFlaggedAt: true, referralRevokedAt: true, ...firstPetSelect,
+ },
+ }),
+ ctx.prisma.owner.findMany({
+ where: { referralFlaggedAt: { not: null }, referralQualifiedAt: null, referralRevokedAt: null },
+ orderBy: { referralFlaggedAt: "asc" },
+ take: LIST_LIMIT,
+ select: { id: true, invitedById: true, referralFlaggedAt: true, ...firstPetSelect },
+ }),
+ ]);
+
+ // Einladende der letzten Einlösungen nachladen — für Namen und den IP-Vergleich.
+ const inviterIds = [...new Set([...recent, ...flagged].map((o) => o.invitedById).filter((id): id is string => !!id))];
+ const inviters = await ctx.prisma.owner.findMany({
+ where: { id: { in: inviterIds } },
+ select: { id: true, registrationIp: true, ...firstPetSelect },
+ });
+ const inviterById = new Map(inviters.map((i) => [i.id, i]));
+
+ return {
+ redeemed7, redeemed30, qualified7, qualified30, ownersWithPet,
+ inviteFactor30: ownersWithPet > 0 ? qualified30 / ownersWithPet : 0,
+ sources: sources.map((s) => ({ source: s.acquisitionSource, count: s._count._all })),
+ topInviters: topInviters.map(({ pets, ...o }) => ({ ...o, pet: pets[0] ?? null })),
+ recent: recent.map(({ pets, registrationIp, invitedById, ...o }) => {
+ const inviter = invitedById ? inviterById.get(invitedById) : undefined;
+ return {
+ ...o,
+ pet: pets[0] ?? null,
+ inviterPet: inviter?.pets[0] ?? null,
+ // Nur ein Hinweis für Moderatoren, keine automatische Sperre — die IP selbst verlässt den Server nicht.
+ sameIpAsInviter: !!registrationIp && registrationIp === inviter?.registrationIp,
+ };
+ }),
+ flagged: flagged.map(({ pets, invitedById, ...o }) => ({
+ ...o,
+ pet: pets[0] ?? null,
+ inviterPet: invitedById ? (inviterById.get(invitedById)?.pets[0] ?? null) : null,
+ })),
+ };
+ }),
+
+ /** Tiere von Ownern mit einem bestimmten Vorteil — Kandidaten für Tier der Woche bzw. Team-Vorstellung. */
+ perkHolders: protectedProcedure
+ .input(z.object({ perk: z.enum(["FEATURE_POOL", "CURATOR_SPOTLIGHT"]) }))
+ .query(async ({ ctx, input }) => {
+ await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
+ const owners = await ctx.prisma.owner.findMany({
+ where: { referralPerks: { has: input.perk }, ban: null },
+ orderBy: { qualifiedReferralCount: "desc" },
+ take: 100,
+ select: {
+ id: true,
+ qualifiedReferralCount: true,
+ referralRankId: true,
+ pets: { orderBy: { createdAt: "asc" }, select: { id: true, name: true, avatarKey: true, _count: { select: { followers: true, posts: true } } } },
+ },
+ });
+ return owners;
+ }),
+
+ approveFlagged: protectedProcedure
+ .input(z.object({ ownerId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
+ await ctx.prisma.$transaction(async (tx) => {
+ const claimed = await tx.owner.updateMany({
+ where: { id: input.ownerId, referralFlaggedAt: { not: null }, referralQualifiedAt: null, referralRevokedAt: null },
+ data: { referralQualifiedAt: new Date(), referralFlaggedAt: null },
+ });
+ if (claimed.count === 0) throw new TRPCError({ code: "NOT_FOUND" });
+ const invitee = await tx.owner.findUnique({ where: { id: input.ownerId }, select: { invitedById: true } });
+ if (invitee?.invitedById) await creditInviter(tx, invitee.invitedById);
+ await tx.moderationLog.create({
+ data: { action: "APPROVE_REFERRAL", targetType: "USER", targetId: input.ownerId, reason: null, moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
+ });
+ });
+ return { success: true };
+ }),
+
+ revokeReferral: protectedProcedure
+ .input(z.object({ ownerId: z.string().min(1), reason: z.string().trim().min(1).max(500) }))
+ .mutation(async ({ ctx, input }) => {
+ const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
+ await ctx.prisma.$transaction(async (tx) => {
+ const invitee = await tx.owner.findUnique({
+ where: { id: input.ownerId },
+ select: { invitedById: true, referralQualifiedAt: true, referralRevokedAt: true },
+ });
+ if (!invitee?.invitedById || invitee.referralRevokedAt) throw new TRPCError({ code: "NOT_FOUND" });
+ await tx.owner.update({ where: { id: input.ownerId }, data: { referralRevokedAt: new Date(), referralFlaggedAt: null } });
+ if (invitee.referralQualifiedAt) {
+ // Ränge bleiben (Bestandsschutz) — bei Betrug zusätzlich revokeRank nutzen.
+ await tx.owner.updateMany({
+ where: { id: invitee.invitedById, qualifiedReferralCount: { gt: 0 } },
+ data: { qualifiedReferralCount: { decrement: 1 } },
+ });
+ }
+ await tx.moderationLog.create({
+ data: { action: "REVOKE_REFERRAL", targetType: "USER", targetId: input.ownerId, reason: input.reason, moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
+ });
+ });
+ return { success: true };
+ }),
+
+ revokeRank: protectedProcedure
+ .input(z.object({ ownerId: z.string().min(1), rankId: z.string().min(1), reason: z.string().trim().min(1).max(500) }))
+ .mutation(async ({ ctx, input }) => {
+ const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
+ await ctx.prisma.$transaction(async (tx) => {
+ await tx.referralRankAchievement.deleteMany({ where: { ownerId: input.ownerId, rankId: input.rankId } });
+ await rebuildOwnerRankCache(tx, input.ownerId);
+ await tx.moderationLog.create({
+ data: { action: "REVOKE_REFERRAL_RANK", targetType: "USER", targetId: input.ownerId, reason: `${input.rankId}: ${input.reason}`, moderatorId: ctx.userId, moderatorRole: callerRole, ipAddress: ctx.ip },
+ });
+ });
+ return { success: true };
+ }),
+});
diff --git a/src/trpc/routers/referrals.ts b/src/trpc/routers/referrals.ts
new file mode 100644
index 0000000..f515566
--- /dev/null
+++ b/src/trpc/routers/referrals.ts
@@ -0,0 +1,155 @@
+import "server-only";
+import { z } from "zod";
+import { router, protectedProcedure, rateLimited } from "@/trpc/init";
+import { ensureReferralCode, getReferralSettings, redeemReferralCode } from "@/lib/referrals";
+import { pickNextRank } from "@/lib/referral-ranks";
+
+/**
+ * Empfehlungsprogramm — nutzerseitige Prozeduren. Admin-Prozeduren liegen in
+ * referral-admin.ts. Fehler tragen stabile Schlüssel (REFERRAL_ERRORS) als
+ * message, die Web und App selbst übersetzen.
+ */
+
+const ACQUISITION_SOURCES = [
+ "TIKTOK",
+ "INSTAGRAM",
+ "YOUTUBE",
+ "FACEBOOK",
+ "FRIENDS",
+ "SHELTER_OR_PARTNER",
+ "STORE_SEARCH",
+ "PRESS",
+ "OTHER",
+] as const;
+
+const MAX_LISTED_INVITEES = 100;
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+export const referralsRouter = router({
+ /**
+ * Alle Ränge (auch inaktive, damit alte Caches noch auflösbar sind) — klein
+ * und selten geändert, der Client hält sie lange im Cache und löst damit
+ * Owner.referralRankId in Abzeichen auf.
+ */
+ ranks: protectedProcedure.query(async ({ ctx }) => {
+ return ctx.prisma.referralRank.findMany({
+ orderBy: { threshold: "asc" },
+ select: {
+ id: true,
+ key: true,
+ threshold: true,
+ nameDe: true,
+ nameEn: true,
+ descriptionDe: true,
+ descriptionEn: true,
+ icon: true,
+ color: true,
+ perks: true,
+ isActive: true,
+ },
+ });
+ }),
+
+ /** Alles für die Seite „Freunde einladen“. Legt den eigenen Code bei Bedarf an. */
+ me: protectedProcedure.query(async ({ ctx }) => {
+ const code = await ensureReferralCode(ctx.prisma, ctx.userId);
+ const [owner, settings, ranks, achievements, invitees] = await Promise.all([
+ ctx.prisma.owner.findUnique({
+ where: { id: ctx.userId },
+ select: {
+ createdAt: true,
+ invitedById: true,
+ qualifiedReferralCount: true,
+ referralRankId: true,
+ referralPerks: true,
+ },
+ }),
+ getReferralSettings(ctx.prisma),
+ ctx.prisma.referralRank.findMany({ orderBy: { threshold: "asc" } }),
+ ctx.prisma.referralRankAchievement.findMany({
+ where: { ownerId: ctx.userId },
+ select: { rankId: true, achievedAt: true },
+ }),
+ ctx.prisma.owner.findMany({
+ where: { invitedById: ctx.userId },
+ orderBy: { invitedAt: "desc" },
+ take: MAX_LISTED_INVITEES,
+ select: {
+ invitedAt: true,
+ referralQualifiedAt: true,
+ referralFlaggedAt: true,
+ referralRevokedAt: true,
+ pets: { orderBy: { createdAt: "asc" }, take: 1, select: { id: true, name: true, avatarKey: true } },
+ },
+ }),
+ ]);
+
+ const achievedIds = new Set(achievements.map((a) => a.rankId));
+ const next = pickNextRank(ranks, achievedIds);
+ const redeemDeadline = owner
+ ? new Date(owner.createdAt.getTime() + settings.redeemWindowDays * DAY_MS)
+ : null;
+
+ return {
+ code,
+ programActive: settings.programActive,
+ qualifiedCount: owner?.qualifiedReferralCount ?? 0,
+ rankId: owner?.referralRankId ?? null,
+ perks: owner?.referralPerks ?? [],
+ achievedRankIds: [...achievedIds],
+ nextRankId: next?.id ?? null,
+ canRedeem:
+ settings.programActive && !!owner && !owner.invitedById && !!redeemDeadline && redeemDeadline > new Date(),
+ wasInvited: !!owner?.invitedById,
+ invitees: invitees.map((invitee) => ({
+ pet: invitee.pets[0] ?? null,
+ invitedAt: invitee.invitedAt,
+ status: invitee.referralRevokedAt
+ ? ("revoked" as const)
+ : invitee.referralQualifiedAt
+ ? ("counted" as const)
+ : invitee.referralFlaggedAt
+ ? ("in-review" as const)
+ : ("waiting" as const),
+ })),
+ };
+ }),
+
+ /**
+ * Soll das Onboarding den Schritt „Wie hast du von PawFeed erfahren?“ zeigen?
+ * Nur einmal pro Owner (noch nicht beantwortet) — bei weiteren Tieren nicht.
+ */
+ onboardingState: protectedProcedure.query(async ({ ctx }) => {
+ const [owner, settings] = await Promise.all([
+ ctx.prisma.owner.findUnique({
+ where: { id: ctx.userId },
+ select: { createdAt: true, invitedById: true, acquisitionAnsweredAt: true },
+ }),
+ getReferralSettings(ctx.prisma),
+ ]);
+ if (!owner) return { showStep: false, canRedeem: false };
+ const withinWindow = Date.now() - owner.createdAt.getTime() <= settings.redeemWindowDays * DAY_MS;
+ return {
+ showStep: !owner.acquisitionAnsweredAt && withinWindow,
+ canRedeem: settings.programActive && !owner.invitedById && withinWindow,
+ };
+ }),
+
+ redeem: protectedProcedure
+ .use(rateLimited("referrals.redeem", 5, 600))
+ .input(z.object({ code: z.string().min(1).max(32) }))
+ .mutation(async ({ ctx, input }) => {
+ return redeemReferralCode(ctx.prisma, ctx.userId, input.code);
+ }),
+
+ /** Freiwillige Onboarding-Angabe „Wie hast du von PawFeed erfahren?“ — nur einmal. */
+ setAcquisitionSource: protectedProcedure
+ .input(z.object({ source: z.enum(ACQUISITION_SOURCES) }))
+ .mutation(async ({ ctx, input }) => {
+ await ctx.prisma.owner.updateMany({
+ where: { id: ctx.userId, acquisitionSource: null },
+ data: { acquisitionSource: input.source, acquisitionAnsweredAt: new Date() },
+ });
+ return { ok: true };
+ }),
+});