From bb1515cb93a97effc75cc84cff15c05f86082ad4 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Thu, 23 Jul 2026 19:37:40 +0200 Subject: [PATCH] feat(onboarding): add community-guidelines acknowledgment step before pet creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 8 ++ messages/en.json | 8 ++ src/__tests__/legal.test.ts | 9 +- src/app/onboarding/pet/page.tsx | 15 ++- src/components/legal/OnboardingGuidelines.tsx | 107 ++++++++++++++++++ src/trpc/routers/legal.ts | 8 +- 6 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 src/components/legal/OnboardingGuidelines.tsx diff --git a/messages/de.json b/messages/de.json index 0f1098c..3c4f491 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 0ea052f..8b8d6cf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/src/__tests__/legal.test.ts b/src/__tests__/legal.test.ts index 7dc56dc..c5d93af 100644 --- a/src/__tests__/legal.test.ts +++ b/src/__tests__/legal.test.ts @@ -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 }); }); diff --git a/src/app/onboarding/pet/page.tsx b/src/app/onboarding/pet/page.tsx index cc1e597..63608a4 100644 --- a/src/app/onboarding/pet/page.tsx +++ b/src/app/onboarding/pet/page.tsx @@ -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(null); + const [guidelinesAcknowledged, setGuidelinesAcknowledged] = useState(false); + + if (!guidelinesAcknowledged) { + return ( +
+ setGuidelinesAcknowledged(true)} /> +
+ ); + } if (createdPetId) { return ( diff --git a/src/components/legal/OnboardingGuidelines.tsx b/src/components/legal/OnboardingGuidelines.tsx new file mode 100644 index 0000000..ad1bcda --- /dev/null +++ b/src/components/legal/OnboardingGuidelines.tsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+
+
+

{t("subtitle")}

+ +
+ {pending!.map((version) => ( +

+ {version.changeSummary} +

+ ))} +
+ +

+ {t("linksIntro")}{" "} + + {t("termsLink")} + + {" · "} + + {t("privacyLink")} + +

+ + +
+ ); +} diff --git a/src/trpc/routers/legal.ts b/src/trpc/routers/legal.ts index 67645e4..0eaa48e 100644 --- a/src/trpc/routers/legal.ts +++ b/src/trpc/routers/legal.ts @@ -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 }; }),