Files
ketopath/apps/web/src/app/[locale]/profile/profile-form.tsx
T
lucianoandClaude Opus 4.7 5b38ddc41d feat: wire Sentry, conditional on DSN env var
apps/web (@sentry/nextjs):
- sentry.client.config.ts / sentry.server.config.ts / sentry.edge.config.ts
  initialize Sentry only when NEXT_PUBLIC_SENTRY_DSN (or SENTRY_DSN on the
  server) is set; tracesSampleRate 0.1
- next.config.mjs wraps the existing config with withSentryConfig only when a
  DSN is present; source-map upload stays disabled until SENTRY_AUTH_TOKEN is
  provided
- .env.example documents NEXT_PUBLIC_SENTRY_DSN and the optional auth token

apps/api (@sentry/node):
- src/lib/sentry.ts initializes Sentry at module load when SENTRY_DSN is set
- server.ts imports sentry.ts as the very first side-effect so early-boot
  errors (env validation, plugin registration) reach Sentry
- env schema gains optional SENTRY_DSN (URL)
- app.ts registers an errorHandler that captures the exception with the
  authenticated user when SENTRY_DSN is set; logs and re-sends as before

Build-time housekeeping:
- profile route: targetDate ?? null on create to satisfy Prisma's input shape
  under exactOptionalPropertyTypes
- profile form: useForm receives defaultValues only when initial is provided
  (spread instead of `defaultValues: undefined`); Field error prop typed
  `string | undefined` for exactOptionalPropertyTypes

Without a DSN both apps run unchanged (Sentry is inert).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:25:08 +02:00

158 lines
5.4 KiB
TypeScript

'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { ACTIVITY_LEVELS, GENDERS, profileInputSchema, type ProfileInput } from '@ketopath/shared';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { saveProfile } from './actions';
type Derived = { bmr: number; tdee: number; activityMultiplier: number };
type Profile = ProfileInput & { derived?: Derived };
export function ProfileForm({ initial }: { initial: Profile | null }) {
const t = useTranslations('Profile');
const router = useRouter();
const [serverError, setServerError] = useState<string | null>(null);
const [saved, setSaved] = useState<Derived | null>(initial?.derived ?? null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ProfileInput>({
resolver: zodResolver(profileInputSchema),
...(initial
? {
defaultValues: {
age: initial.age,
gender: initial.gender,
heightCm: initial.heightCm,
weightStartKg: initial.weightStartKg,
weightCurrentKg: initial.weightCurrentKg,
weightGoalKg: initial.weightGoalKg,
activityLevel: initial.activityLevel,
},
}
: {}),
});
const onSubmit = handleSubmit(async (input) => {
setServerError(null);
const result = await saveProfile(input);
if (!result.ok) {
setServerError(result.error);
return;
}
const profile = result.profile as Profile;
if (profile.derived) setSaved(profile.derived);
router.refresh();
});
return (
<form onSubmit={onSubmit} className="space-y-5" noValidate>
<div className="grid grid-cols-2 gap-4">
<Field label={t('age')} error={errors.age?.message}>
<Input type="number" inputMode="numeric" {...register('age')} />
</Field>
<Field label={t('gender')} error={errors.gender?.message}>
<select
className="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
{...register('gender')}
>
<option value="">{t('selectPlaceholder')}</option>
{GENDERS.map((g) => (
<option key={g} value={g}>
{t(`genderOptions.${g}`)}
</option>
))}
</select>
</Field>
</div>
<Field label={t('heightCm')} error={errors.heightCm?.message}>
<Input type="number" inputMode="numeric" {...register('heightCm')} />
</Field>
<div className="grid grid-cols-3 gap-4">
<Field label={t('weightStartKg')} error={errors.weightStartKg?.message}>
<Input type="number" step="0.1" inputMode="decimal" {...register('weightStartKg')} />
</Field>
<Field label={t('weightCurrentKg')} error={errors.weightCurrentKg?.message}>
<Input type="number" step="0.1" inputMode="decimal" {...register('weightCurrentKg')} />
</Field>
<Field label={t('weightGoalKg')} error={errors.weightGoalKg?.message}>
<Input type="number" step="0.1" inputMode="decimal" {...register('weightGoalKg')} />
</Field>
</div>
<Field label={t('activityLevel')} error={errors.activityLevel?.message}>
<select
className="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
{...register('activityLevel')}
>
<option value="">{t('selectPlaceholder')}</option>
{ACTIVITY_LEVELS.map((a) => (
<option key={a} value={a}>
{t(`activityOptions.${a}`)}
</option>
))}
</select>
</Field>
{serverError ? (
<p role="alert" className="text-destructive text-sm">
{serverError}
</p>
) : null}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? t('saving') : t('save')}
</Button>
{saved ? (
<div className="bg-secondary mt-6 rounded-md p-4 text-sm">
<p className="font-medium">{t('summary')}</p>
<ul className="mt-2 space-y-1">
<li>
{t('bmr')}: <span className="font-mono">{saved.bmr} kcal</span>
</li>
<li>
{t('tdee')}: <span className="font-mono">{saved.tdee} kcal</span>
</li>
<li>
{t('activityMultiplier')}:{' '}
<span className="font-mono">{saved.activityMultiplier}</span>
</li>
</ul>
<p className="text-muted-foreground mt-2 text-xs">{t('hint')}</p>
</div>
) : null}
</form>
);
}
function Field({
label,
error,
children,
}: {
label: string;
error?: string | undefined;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label>{label}</Label>
{children}
{error ? <p className="text-destructive text-xs">{error}</p> : null}
</div>
);
}