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:
1 parent
ff607a604d
commit
bb1515cb93
6 files changed
+148
-7
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user