feat: medical disclaimer onboarding + V1 + recipe schema (no migration yet)
Schema changes (require a single migration to apply): - User gains disclaimerAcceptedAt — null until the user accepts the medical disclaimer, blocks /profile until set (PRD §14.3) - New WeightEntry (PRD §5.2) — weight/measurements/notes encrypted at rest, energy/sleep/hunger left in the clear so we can produce aggregate analytics - New FastEvent (PRD §5.3) — symptoms/notes encrypted, protocol/status/timer in the clear; indexed on (userId, startedAt) - New Ingredient / Recipe / RecipeIngredient (PRD §5.1) — italian keto recipe database with macros and exclusion groups, ready for the matchmaking algo - New MealPlan / MealSlot — generated weekly plan with selected recipe and alternatives per (day, meal) - New enums: FastStatus, MealCategory, Difficulty, MealPlanStatus Web — disclaimer flow: - /welcome page (server component, requires auth) lists the 4 PRD-mandated points and surfaces the 3 mandatory checkboxes; submit triggers a server action that stamps disclaimerAcceptedAt and redirects to /profile - /profile redirects to /welcome whenever disclaimerAcceptedAt is null, so the disclaimer becomes a hard prerequisite for any sensitive page - New Italian copy under Welcome.* in messages/it.json - @ketopath/db added to apps/web dependencies (was indirect via @ketopath/auth) @ketopath/db re-exports the new models and enums. Run the migration before testing: `pnpm db:migrate --name onboarding_v1_recipes`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
50d68fc522
commit
f6423844da
9 files changed
+374
-14
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { prisma } from '@ketopath/db';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
@@ -13,6 +14,12 @@ export default async function ProfilePage({ params: { locale } }: { params: { lo
|
||||
const session = await getServerSession();
|
||||
if (!session?.user) redirect('/sign-in');
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { disclaimerAcceptedAt: true },
|
||||
});
|
||||
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
||||
|
||||
const profile = (await fetchProfile()) as Parameters<typeof ProfileForm>[0]['initial'];
|
||||
|
||||
return <ProfilePageContent initial={profile} />;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@ketopath/db';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
export async function acceptDisclaimer(): Promise<void> {
|
||||
const session = await getServerSession();
|
||||
if (!session?.user) redirect('/sign-in');
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { disclaimerAcceptedAt: new Date() },
|
||||
});
|
||||
|
||||
revalidatePath('/', 'layout');
|
||||
redirect('/profile');
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { acceptDisclaimer } from './actions';
|
||||
|
||||
export function DisclaimerForm() {
|
||||
const t = useTranslations('Welcome');
|
||||
const [adult, setAdult] = useState(false);
|
||||
const [aware, setAware] = useState(false);
|
||||
const [accepts, setAccepts] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const allChecked = adult && aware && accepts;
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!allChecked) return;
|
||||
setPending(true);
|
||||
await acceptDisclaimer();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-3">
|
||||
<Checkbox checked={adult} onChange={setAdult} label={t('checkAdult')} />
|
||||
<Checkbox checked={aware} onChange={setAware} label={t('checkAware')} />
|
||||
<Checkbox checked={accepts} onChange={setAccepts} label={t('checkAccepts')} />
|
||||
<Button type="submit" disabled={!allChecked || pending} className="mt-4 w-full">
|
||||
{pending ? t('submitting') : t('submit')}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="border-input text-primary focus-visible:ring-ring mt-0.5 h-4 w-4 rounded border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { prisma } from '@ketopath/db';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { DisclaimerForm } from './disclaimer-form';
|
||||
|
||||
export default async function WelcomePage({ params: { locale } }: { params: { locale: string } }) {
|
||||
setRequestLocale(locale);
|
||||
const session = await getServerSession();
|
||||
if (!session?.user) redirect('/sign-in');
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { disclaimerAcceptedAt: true },
|
||||
});
|
||||
if (user?.disclaimerAcceptedAt) redirect('/profile');
|
||||
|
||||
return <WelcomePageContent />;
|
||||
}
|
||||
|
||||
function WelcomePageContent() {
|
||||
const t = useTranslations('Welcome');
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-16">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="space-y-3 text-sm">
|
||||
<li>
|
||||
<span className="font-medium">1. </span>
|
||||
{t('point1')}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">2. </span>
|
||||
{t('point2')}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">3. </span>
|
||||
{t('point3')}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">4. </span>
|
||||
{t('point4')}
|
||||
</li>
|
||||
</ol>
|
||||
<DisclaimerForm />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className="text-muted-foreground text-xs">{t('legalNote')}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user