feat(onboarding): add community-guidelines acknowledgment step before pet creation

New users landed directly on pet creation with no prompt to acknowledge
community guidelines — the existing LegalUpdateBanner/LegalDocumentVersion
mechanism only ever ran *after* the first pet existed (since the Owner
row wasn't created until pets.create's owner.upsert), and turned out to
be entirely dormant (zero LegalDocumentVersion rows had ever been
published in prod).

Adds a step 0 to /onboarding/pet: OnboardingGuidelines queries the same
legal.getPendingUpdate the in-app update banner uses and shows the
latest published summary before letting the user continue to pet
creation. Reuses the existing LegalDocumentVersion/admin-publish
infrastructure (/p/[secret]/legal) instead of hardcoding copy, so
content stays editable without a deploy. If nothing has been published
yet, the step is skipped automatically — no dead-end for a fresh
deployment.

legal.acknowledge switched from owner.update to owner.upsert since it
can now be called before the Owner row exists (pre-first-pet).

Deliberately did NOT add a cookie-consent checkbox: Datenschutz §5
already discloses that only strictly-necessary session cookies are used
and no tracking/ad cookies are set without explicit consent — under
GDPR/ePrivacy, strictly-necessary cookies don't require active consent,
only disclosure. The guidelines step links to both Nutzungsbedingungen
and Datenschutz instead of a redundant separate consent mechanism.

Side effect (intentional): existing owners who never explicitly
acknowledged anything will see the same content once via the in-app
LegalUpdateBanner the next time a version is published, since it's the
same underlying data model.
This commit is contained in:
admin committed 2026-07-23 19:37:40 +02:00
1 parent ff607a604d
commit bb1515cb93
6 files changed
+148 -7

No files matched your search

+8
View File
@@ -431,6 +431,14 @@
"RepostCard": {
"pawedBackBy": "Paw-Back von"
},
"OnboardingGuidelines": {
"title": "Bevor es losgeht",
"subtitle": "Ein kurzer Überblick über unsere Community-Richtlinien.",
"linksIntro": "Alle Details in unseren",
"termsLink": "Nutzungsbedingungen",
"privacyLink": "Datenschutzerklärung (inkl. Cookies)",
"continueCta": "Verstanden, weiter"
},
"PetsPage": {
"title": "Meine Tiere",
"addPet": "Tier anlegen",
+8
View File
@@ -431,6 +431,14 @@
"RepostCard": {
"pawedBackBy": "Pawed-back by"
},
"OnboardingGuidelines": {
"title": "Before you get started",
"subtitle": "A quick overview of our community guidelines.",
"linksIntro": "Full details in our",
"termsLink": "Terms of Service",
"privacyLink": "Privacy Policy (incl. cookies)",
"continueCta": "I understand, continue"
},
"PetsPage": {
"title": "My Pets",
"addPet": "Add Pet",
+5 -4
View File
@@ -110,15 +110,16 @@ describe("legal.getPendingUpdate", () => {
});
describe("legal.acknowledge", () => {
it("sets legalAcknowledgedAt on the calling owner", async () => {
it("upserts legalAcknowledgedAt on the calling owner (works even before the Owner row exists)", async () => {
const { caller, mockPrisma } = await createLegalCaller();
mockPrisma.owner.update.mockResolvedValue({ id: "owner-1" });
mockPrisma.owner.upsert.mockResolvedValue({ id: "owner-1" });
const result = await caller.legal.acknowledge();
expect(mockPrisma.owner.update).toHaveBeenCalledWith({
expect(mockPrisma.owner.upsert).toHaveBeenCalledWith({
where: { id: "owner-1" },
data: { legalAcknowledgedAt: expect.any(Date) },
create: { id: "owner-1", legalAcknowledgedAt: expect.any(Date) },
update: { legalAcknowledgedAt: expect.any(Date) },
});
expect(result).toEqual({ ok: true });
});
+14 -1
View File
@@ -7,11 +7,15 @@ import { PetForm } from "@/components/pet/PetForm";
import { AvatarUpload } from "@/components/pet/AvatarUpload";
import { Button } from "@/components/ui/button";
import { useActivePet } from "@/context/ActivePetContext";
import { OnboardingGuidelines } from "@/components/legal/OnboardingGuidelines";
/**
* Onboarding pet page — /onboarding/pet
*
* Two-step flow:
* Three-step flow:
* Step 0: Acknowledge community guidelines (skipped automatically once
* acknowledged, or if nothing has been published — see
* OnboardingGuidelines)
* Step 1: Create pet profile (name, species, etc.)
* Step 2: Upload avatar photo (now that petId exists)
*
@@ -22,6 +26,15 @@ export default function OnboardingPetPage() {
const t = useTranslations("Onboarding");
const { setActivePet } = useActivePet();
const [createdPetId, setCreatedPetId] = useState<string | null>(null);
const [guidelinesAcknowledged, setGuidelinesAcknowledged] = useState(false);
if (!guidelinesAcknowledged) {
return (
<main className="min-h-screen flex items-center justify-center bg-background px-4">
<OnboardingGuidelines onContinue={() => setGuidelinesAcknowledged(true)} />
</main>
);
}
if (createdPetId) {
return (
@@ -0,0 +1,107 @@
"use client";
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { ScrollText, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
interface OnboardingGuidelinesProps {
/** Called once the owner has acknowledged (or there was nothing to acknowledge). */
onContinue: () => void;
}
/**
* First-run onboarding gate — shown before pet creation, once, for owners
* who have never acknowledged the legal terms (Owner.legalAcknowledgedAt
* is null). Reuses the same legal.getPendingUpdate/legal.acknowledge
* procedures and LegalDocumentVersion content as LegalUpdateBanner (the
* in-app "terms changed" notice) — same data model, admin-editable via
* /p/[secret]/legal, different framing/copy for a first-time welcome.
*
* If no LegalDocumentVersion has been published yet (fresh deployment),
* there's nothing to show — skip straight through via onContinue().
*/
export function OnboardingGuidelines({ onContinue }: OnboardingGuidelinesProps) {
const t = useTranslations("OnboardingGuidelines");
const trpc = useTRPC();
const qc = useQueryClient();
const { data: pending, isLoading } = useQuery(trpc.legal.getPendingUpdate.queryOptions());
const acknowledgeMutation = useMutation(
trpc.legal.acknowledge.mutationOptions({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.legal.getPendingUpdate.queryKey() });
onContinue();
},
})
);
const hasNothingToAcknowledge = !isLoading && (!pending || pending.length === 0);
useEffect(() => {
if (hasNothingToAcknowledge) onContinue();
}, [hasNothingToAcknowledge, onContinue]);
if (isLoading || hasNothingToAcknowledge) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="w-full max-w-[512px] p-8">
<div className="flex items-center gap-2 mb-2">
<ScrollText 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>
<div className="space-y-4 mb-6 rounded-xl border bg-card p-4">
{pending!.map((version) => (
<p key={version.id} className="text-sm text-foreground whitespace-pre-line leading-relaxed">
{version.changeSummary}
</p>
))}
</div>
<p className="text-xs text-muted-foreground mb-6">
{t("linksIntro")}{" "}
<Link
href="/nutzungsbedingungen"
target="_blank"
rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 hover:underline"
>
{t("termsLink")}
</Link>
{" · "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 hover:underline"
>
{t("privacyLink")}
</Link>
</p>
<Button
className="w-full min-h-[44px] bg-orange-500 hover:bg-orange-600 text-white"
onClick={() => acknowledgeMutation.mutate()}
disabled={acknowledgeMutation.isPending}
>
{acknowledgeMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t("continueCta")
)}
</Button>
</div>
);
}
+6 -2
View File
@@ -47,10 +47,14 @@ export const legalRouter = router({
return versions.length > 0 ? versions : null;
}),
// Upsert, not update: this is now also called from onboarding, before the
// pet-creation step that normally first creates the Owner row (pets.create's
// owner.upsert). Without this, acknowledging pre-first-pet would 404.
acknowledge: protectedProcedure.mutation(async ({ ctx }) => {
await ctx.prisma.owner.update({
await ctx.prisma.owner.upsert({
where: { id: ctx.userId },
data: { legalAcknowledgedAt: new Date() },
create: { id: ctx.userId, legalAcknowledgedAt: new Date() },
update: { legalAcknowledgedAt: new Date() },
});
return { ok: true };
}),