feat: §6 Gruppo B residuo — pannello macros, esclusioni granulari, achievement
- Plan: pannello macros laterale "oggi" con consumati/pianificati vs target (kcal/P/F/C). Sticky su lg, in-flow su mobile. Profile/derived ora include kcalTarget e i macro target (BMR aggiustato per condizioni e diet history). - Preferenze: nuova sezione "Ingredienti specifici da evitare" con ricerca debounced sul catalogo (cap 50). Schema Preferences.bannedIngredientIds + matchmaking che scarta ricette contenenti ingredient bandito (test unit). - Achievement: tabella Achievement (userId+key unique), 4 traguardi iniziali (first_weigh_in, first_plan, first_fast_complete, ten_meals_consumed). Service idempotente eval+persist agganciato a 4 trigger (weight POST, plan POST, fast PATCH→COMPLETED, slot consumed). Push notification best-effort all'unlock. Pannello badge nel profilo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3b895191f5
commit
d17c5ef869
29 files changed
+1048
-86
No files matched your search
@@ -740,5 +740,6 @@ function toFormValues(view: PreferencesView): PreferencesValues {
|
||||
trainingType: view.trainingType as PreferencesValues['trainingType'],
|
||||
sessionMinutes: view.sessionMinutes,
|
||||
mealsPerDay: view.mealsPerDay,
|
||||
bannedIngredientIds: view.bannedIngredientIds ?? [],
|
||||
};
|
||||
}
|
||||
@@ -44,6 +44,13 @@ export interface CurrentPlan {
|
||||
phase2Week: number | null;
|
||||
}
|
||||
|
||||
export interface DailyTarget {
|
||||
kcal: number;
|
||||
proteinG: number;
|
||||
fatG: number;
|
||||
netCarbG: number;
|
||||
}
|
||||
|
||||
function cookieHeader(): string {
|
||||
return headers().get('cookie') ?? '';
|
||||
}
|
||||
@@ -59,6 +66,32 @@ export async function fetchCurrentPlan(): Promise<CurrentPlan | null> {
|
||||
return data.plan;
|
||||
}
|
||||
|
||||
export async function fetchDailyTarget(): Promise<DailyTarget | null> {
|
||||
const res = await fetch(`${API_URL}/me/profile`, {
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as {
|
||||
profile?: {
|
||||
derived?: {
|
||||
kcalTarget?: number;
|
||||
proteinTargetG?: number;
|
||||
fatTargetG?: number;
|
||||
netCarbTargetG?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
const d = data.profile?.derived;
|
||||
if (!d || d.kcalTarget == null || d.proteinTargetG == null) return null;
|
||||
return {
|
||||
kcal: d.kcalTarget,
|
||||
proteinG: d.proteinTargetG,
|
||||
fatG: d.fatTargetG ?? 0,
|
||||
netCarbG: d.netCarbTargetG ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Server action invocata via `<form action={regeneratePlan}>`. La firma è
|
||||
// `(FormData) => Promise<void>` per allinearsi al type richiesto da React 18.
|
||||
export async function regeneratePlan(_formData?: FormData): Promise<void> {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Masthead } from '@/components/masthead';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { fetchCurrentPlan, regeneratePlan } from './actions';
|
||||
import { fetchCurrentPlan, fetchDailyTarget, regeneratePlan } from './actions';
|
||||
import { PlanWeek } from './plan-week';
|
||||
|
||||
export default async function PlanPage({ params: { locale } }: { params: { locale: string } }) {
|
||||
@@ -23,9 +23,9 @@ export default async function PlanPage({ params: { locale } }: { params: { local
|
||||
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
||||
if (!user.profile) redirect('/onboarding');
|
||||
|
||||
const plan = await fetchCurrentPlan();
|
||||
const [plan, dailyTarget] = await Promise.all([fetchCurrentPlan(), fetchDailyTarget()]);
|
||||
|
||||
return <PlanPageContent plan={plan} />;
|
||||
return <PlanPageContent plan={plan} dailyTarget={dailyTarget} />;
|
||||
}
|
||||
|
||||
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||||
@@ -45,7 +45,13 @@ function formatWeek(weekStart: string): string {
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
|
||||
function PlanPageContent({ plan }: { plan: Awaited<ReturnType<typeof fetchCurrentPlan>> }) {
|
||||
function PlanPageContent({
|
||||
plan,
|
||||
dailyTarget,
|
||||
}: {
|
||||
plan: Awaited<ReturnType<typeof fetchCurrentPlan>>;
|
||||
dailyTarget: Awaited<ReturnType<typeof fetchDailyTarget>>;
|
||||
}) {
|
||||
const t = useTranslations('Plan');
|
||||
|
||||
return (
|
||||
@@ -114,7 +120,7 @@ function PlanPageContent({ plan }: { plan: Awaited<ReturnType<typeof fetchCurren
|
||||
|
||||
{plan ? (
|
||||
<div className="animate-fade-up [animation-delay:480ms]">
|
||||
<PlanWeek plan={plan} />
|
||||
<PlanWeek plan={plan} dailyTarget={dailyTarget} />
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
toggleConsumed,
|
||||
toggleFreeMeal,
|
||||
type CurrentPlan,
|
||||
type DailyTarget,
|
||||
type PlanSlot,
|
||||
} from './actions';
|
||||
|
||||
@@ -61,7 +62,19 @@ function aggregateMacros(slots: PlanSlot[]): {
|
||||
|
||||
const FREE_MEAL_DEFAULT_KCAL = 750;
|
||||
|
||||
export function PlanWeek({ plan }: { plan: CurrentPlan }) {
|
||||
// PRD §6 — il pannello macros laterale ancora oggi (lun=0…dom=6).
|
||||
function todayDayOfWeek(): number {
|
||||
const d = new Date().getDay(); // 0 = Sunday
|
||||
return d === 0 ? 6 : d - 1;
|
||||
}
|
||||
|
||||
export function PlanWeek({
|
||||
plan,
|
||||
dailyTarget,
|
||||
}: {
|
||||
plan: CurrentPlan;
|
||||
dailyTarget: DailyTarget | null;
|
||||
}) {
|
||||
const t = useTranslations('Plan');
|
||||
|
||||
const byDay = new Map<number, PlanSlot[]>();
|
||||
@@ -70,6 +83,11 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) {
|
||||
byDay.get(slot.dayOfWeek)!.push(slot);
|
||||
}
|
||||
|
||||
const today = todayDayOfWeek();
|
||||
const todaySlots = byDay.get(today) ?? [];
|
||||
const todayConsumed = aggregateMacros(todaySlots.filter((s) => s.consumed));
|
||||
const todayPlanned = aggregateMacros(todaySlots);
|
||||
|
||||
const isPhase3 = plan.currentPhase === 'MAINTENANCE';
|
||||
const weekTotals = aggregateMacros(plan.slots);
|
||||
const activeDays = Array.from(byDay.values()).filter((s) => s.length > 0).length;
|
||||
@@ -93,76 +111,87 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-14">
|
||||
<section className="border-ink/15 bg-carta-light/40 grid grid-cols-2 gap-x-8 gap-y-4 border p-5 sm:grid-cols-5">
|
||||
<MacroSummary label={t('avgKcal')} value={weekAvg.kcal} unit="kcal" big />
|
||||
<MacroSummary label={t('avgProtein')} value={weekAvg.proteinG} unit="g" />
|
||||
<MacroSummary label={t('avgFat')} value={weekAvg.fatG} unit="g" />
|
||||
<MacroSummary label={t('avgNetCarb')} value={weekAvg.netCarbG} unit="g" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="editorial-eyebrow">{t('adherence')}</span>
|
||||
<span className="text-ink font-mono text-3xl font-medium tabular-nums">
|
||||
{adherence.pastSlots === 0 ? '—' : `${Math.round(adherence.rate * 100)}%`}
|
||||
</span>
|
||||
{adherence.pastSlots > 0 ? (
|
||||
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
|
||||
{adherence.consumedSlots}/{adherence.pastSlots}
|
||||
<div className="grid gap-10 lg:grid-cols-[1fr_18rem]">
|
||||
<div className="space-y-14 lg:col-start-1">
|
||||
<section className="border-ink/15 bg-carta-light/40 grid grid-cols-2 gap-x-8 gap-y-4 border p-5 sm:grid-cols-5">
|
||||
<MacroSummary label={t('avgKcal')} value={weekAvg.kcal} unit="kcal" big />
|
||||
<MacroSummary label={t('avgProtein')} value={weekAvg.proteinG} unit="g" />
|
||||
<MacroSummary label={t('avgFat')} value={weekAvg.fatG} unit="g" />
|
||||
<MacroSummary label={t('avgNetCarb')} value={weekAvg.netCarbG} unit="g" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="editorial-eyebrow">{t('adherence')}</span>
|
||||
<span className="text-ink font-mono text-3xl font-medium tabular-nums">
|
||||
{adherence.pastSlots === 0 ? '—' : `${Math.round(adherence.rate * 100)}%`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{DAY_KEYS.map((dayKey, idx) => {
|
||||
const slots = (byDay.get(idx) ?? []).sort(
|
||||
(a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal),
|
||||
);
|
||||
const dayMacros = aggregateMacros(slots);
|
||||
// Nessuno slot → giorno di digiuno completo (ESE_24).
|
||||
const isFastingDay = slots.length === 0;
|
||||
// Solo CENA con dailyKcal ridotto → giorno "leggero" del 5:2.
|
||||
const isLightDay = !isFastingDay && slots.length === 1 && slots[0].meal === 'CENA';
|
||||
|
||||
return (
|
||||
<section key={dayKey} aria-labelledby={`day-${dayKey}`} className="space-y-5">
|
||||
<header className="border-ink/15 grid items-baseline gap-2 border-b pb-3 sm:grid-cols-[auto_1fr_auto]">
|
||||
<span className="font-display text-pomodoro text-xl font-medium italic leading-none">
|
||||
{DAY_NUMERAL[idx]}
|
||||
{adherence.pastSlots > 0 ? (
|
||||
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
|
||||
{adherence.consumedSlots}/{adherence.pastSlots}
|
||||
</span>
|
||||
<h2
|
||||
id={`day-${dayKey}`}
|
||||
className="font-display text-ink text-2xl font-medium leading-none tracking-tight sm:text-3xl"
|
||||
>
|
||||
{t(`days.${dayKey}`)}
|
||||
{isFastingDay ? (
|
||||
<span className="font-display text-ink-dim ml-3 text-base italic">
|
||||
· {t('fastingDay')}
|
||||
</span>
|
||||
) : isLightDay ? (
|
||||
<span className="font-display text-ink-dim ml-3 text-base italic">
|
||||
· {t('lightDay')}
|
||||
</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<span className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
|
||||
{isFastingDay
|
||||
? '— kcal'
|
||||
: `${Math.round(dayMacros.kcal)} kcal · P ${Math.round(dayMacros.proteinG)} · G ${Math.round(dayMacros.fatG)} · C ${Math.round(dayMacros.netCarbG)}`}
|
||||
</span>
|
||||
</header>
|
||||
{isFastingDay ? (
|
||||
<p className="font-display text-ink-soft text-base italic leading-snug">
|
||||
{t('fastingDayHint')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-ink/10 grid grid-cols-1 divide-y sm:grid-cols-2 sm:gap-x-8 sm:divide-y-0 lg:grid-cols-4 lg:gap-x-6">
|
||||
{slots.map((slot, slotIdx) => (
|
||||
<SlotCard key={slot.id} slot={slot} index={slotIdx} canFreeMeal={isPhase3} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{DAY_KEYS.map((dayKey, idx) => {
|
||||
const slots = (byDay.get(idx) ?? []).sort(
|
||||
(a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal),
|
||||
);
|
||||
const dayMacros = aggregateMacros(slots);
|
||||
// Nessuno slot → giorno di digiuno completo (ESE_24).
|
||||
const isFastingDay = slots.length === 0;
|
||||
// Solo CENA con dailyKcal ridotto → giorno "leggero" del 5:2.
|
||||
const isLightDay = !isFastingDay && slots.length === 1 && slots[0].meal === 'CENA';
|
||||
|
||||
return (
|
||||
<section key={dayKey} aria-labelledby={`day-${dayKey}`} className="space-y-5">
|
||||
<header className="border-ink/15 grid items-baseline gap-2 border-b pb-3 sm:grid-cols-[auto_1fr_auto]">
|
||||
<span className="font-display text-pomodoro text-xl font-medium italic leading-none">
|
||||
{DAY_NUMERAL[idx]}
|
||||
</span>
|
||||
<h2
|
||||
id={`day-${dayKey}`}
|
||||
className="font-display text-ink text-2xl font-medium leading-none tracking-tight sm:text-3xl"
|
||||
>
|
||||
{t(`days.${dayKey}`)}
|
||||
{isFastingDay ? (
|
||||
<span className="font-display text-ink-dim ml-3 text-base italic">
|
||||
· {t('fastingDay')}
|
||||
</span>
|
||||
) : isLightDay ? (
|
||||
<span className="font-display text-ink-dim ml-3 text-base italic">
|
||||
· {t('lightDay')}
|
||||
</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<span className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
|
||||
{isFastingDay
|
||||
? '— kcal'
|
||||
: `${Math.round(dayMacros.kcal)} kcal · P ${Math.round(dayMacros.proteinG)} · G ${Math.round(dayMacros.fatG)} · C ${Math.round(dayMacros.netCarbG)}`}
|
||||
</span>
|
||||
</header>
|
||||
{isFastingDay ? (
|
||||
<p className="font-display text-ink-soft text-base italic leading-snug">
|
||||
{t('fastingDayHint')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-ink/10 grid grid-cols-1 divide-y sm:grid-cols-2 sm:gap-x-8 sm:divide-y-0 lg:grid-cols-4 lg:gap-x-6">
|
||||
{slots.map((slot, slotIdx) => (
|
||||
<SlotCard key={slot.id} slot={slot} index={slotIdx} canFreeMeal={isPhase3} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<aside className="lg:col-start-2 lg:row-start-1">
|
||||
<TodayMacrosPanel
|
||||
consumed={todayConsumed}
|
||||
planned={todayPlanned}
|
||||
target={dailyTarget}
|
||||
hasSlots={todaySlots.length > 0}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -327,6 +356,128 @@ function SlotCard({
|
||||
);
|
||||
}
|
||||
|
||||
interface MacroBucket {
|
||||
kcal: number;
|
||||
proteinG: number;
|
||||
fatG: number;
|
||||
netCarbG: number;
|
||||
}
|
||||
|
||||
function TodayMacrosPanel({
|
||||
consumed,
|
||||
planned,
|
||||
target,
|
||||
hasSlots,
|
||||
}: {
|
||||
consumed: MacroBucket;
|
||||
planned: MacroBucket;
|
||||
target: DailyTarget | null;
|
||||
hasSlots: boolean;
|
||||
}) {
|
||||
const t = useTranslations('Plan');
|
||||
|
||||
if (!hasSlots) {
|
||||
return (
|
||||
<div className="border-ink/15 bg-carta-light/40 border p-5 lg:sticky lg:top-6">
|
||||
<p className="editorial-eyebrow">{t('todayPanelTitle')}</p>
|
||||
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
|
||||
{t('todayPanelFasting')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-ink/15 bg-carta-light/40 space-y-5 border p-5 lg:sticky lg:top-6">
|
||||
<div>
|
||||
<p className="editorial-eyebrow">{t('todayPanelTitle')}</p>
|
||||
{target ? (
|
||||
<p className="text-ink-soft mt-1 font-mono text-[10px] uppercase tracking-widest">
|
||||
{t('todayPanelTargetHint')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<MacroBar
|
||||
label={t('avgKcal')}
|
||||
consumed={consumed.kcal}
|
||||
planned={planned.kcal}
|
||||
target={target?.kcal}
|
||||
unit="kcal"
|
||||
/>
|
||||
<MacroBar
|
||||
label={t('avgProtein')}
|
||||
consumed={consumed.proteinG}
|
||||
planned={planned.proteinG}
|
||||
target={target?.proteinG}
|
||||
unit="g"
|
||||
/>
|
||||
<MacroBar
|
||||
label={t('avgFat')}
|
||||
consumed={consumed.fatG}
|
||||
planned={planned.fatG}
|
||||
target={target?.fatG}
|
||||
unit="g"
|
||||
/>
|
||||
<MacroBar
|
||||
label={t('avgNetCarb')}
|
||||
consumed={consumed.netCarbG}
|
||||
planned={planned.netCarbG}
|
||||
target={target?.netCarbG}
|
||||
unit="g"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MacroBar({
|
||||
label,
|
||||
consumed,
|
||||
planned,
|
||||
target,
|
||||
unit,
|
||||
}: {
|
||||
label: string;
|
||||
consumed: number;
|
||||
planned: number;
|
||||
target: number | undefined;
|
||||
unit: string;
|
||||
}) {
|
||||
const ref = target && target > 0 ? target : Math.max(planned, 1);
|
||||
const consumedPct = Math.max(0, Math.min(100, (consumed / ref) * 100));
|
||||
const plannedPct = Math.max(0, Math.min(100, (planned / ref) * 100));
|
||||
const overTarget = target != null && consumed > target;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="editorial-eyebrow">{label}</span>
|
||||
<span className="text-ink font-mono text-xs tabular-nums">
|
||||
{Math.round(consumed)}
|
||||
{target != null ? (
|
||||
<span className="text-ink-soft"> / {Math.round(target)}</span>
|
||||
) : (
|
||||
<span className="text-ink-soft"> / {Math.round(planned)}</span>
|
||||
)}
|
||||
<span className="font-display text-ink-soft ml-1 text-[10px] italic">{unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-ink/10 relative h-1.5 w-full overflow-hidden">
|
||||
<div
|
||||
className="bg-ink/30 absolute inset-y-0 left-0"
|
||||
style={{ width: `${plannedPct}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 ${overTarget ? 'bg-pomodoro' : 'bg-oliva'}`}
|
||||
style={{ width: `${consumedPct}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MacroSummary({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use server';
|
||||
|
||||
import { type AchievementKey } from '@ketopath/shared';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
|
||||
|
||||
export interface UnlockedAchievement {
|
||||
key: AchievementKey;
|
||||
unlockedAt: string;
|
||||
}
|
||||
|
||||
export async function fetchAchievements(): Promise<UnlockedAchievement[]> {
|
||||
const res = await fetch(`${API_URL}/me/achievements`, {
|
||||
headers: { cookie: headers().get('cookie') ?? '' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as { unlocked: UnlockedAchievement[] };
|
||||
return body.unlocked;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { ACHIEVEMENTS } from '@ketopath/shared';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import type { UnlockedAchievement } from './achievements-actions';
|
||||
|
||||
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
export function AchievementsPanel({ unlocked }: { unlocked: UnlockedAchievement[] }) {
|
||||
const t = useTranslations('Achievements');
|
||||
const map = new Map(unlocked.map((u) => [u.key, u.unlockedAt]));
|
||||
const total = ACHIEVEMENTS.length;
|
||||
const got = unlocked.length;
|
||||
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
<header className="space-y-3">
|
||||
<p className="editorial-eyebrow">{t('eyebrow')}</p>
|
||||
<h2 className="font-display text-ink text-3xl font-medium leading-tight tracking-tight">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
|
||||
{t('subtitle', { got, total })}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{ACHIEVEMENTS.map((a) => {
|
||||
const at = map.get(a.key);
|
||||
const unlockedDate = at ? ITALIAN_DATE.format(new Date(at)) : null;
|
||||
return (
|
||||
<li
|
||||
key={a.key}
|
||||
className={`group flex flex-col gap-2 border p-5 transition-colors ${
|
||||
at ? 'border-pomodoro/40 bg-carta-light' : 'border-ink/15 bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span
|
||||
className={`font-mono text-[10px] uppercase tracking-widest ${
|
||||
at ? 'text-pomodoro' : 'text-ink-dim'
|
||||
}`}
|
||||
>
|
||||
{t(`category.${a.category}`)}
|
||||
</span>
|
||||
{at ? (
|
||||
<span className="text-ink-soft font-mono text-[10px] tracking-widest">
|
||||
{unlockedDate}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-ink-dim font-mono text-[10px] uppercase tracking-widest">
|
||||
{t('locked')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={`font-display text-xl font-medium leading-tight ${
|
||||
at ? 'text-ink' : 'text-ink-dim'
|
||||
}`}
|
||||
>
|
||||
{t(`key.${a.key}.title`)}
|
||||
</p>
|
||||
<p
|
||||
className={`font-display text-sm italic leading-snug ${
|
||||
at ? 'text-ink-soft' : 'text-ink-dim'
|
||||
}`}
|
||||
>
|
||||
{t(`key.${a.key}.hint`)}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { setRequestLocale } from 'next-intl/server';
|
||||
import { Masthead } from '@/components/masthead';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { fetchAchievements } from './achievements-actions';
|
||||
import { AchievementsPanel } from './achievements-panel';
|
||||
import { fetchProfile } from './actions';
|
||||
import { DataPanel } from './data-panel';
|
||||
import { fetchNotificationConfig } from './notifications-actions';
|
||||
@@ -28,9 +30,15 @@ export default async function ProfilePage({ params: { locale } }: { params: { lo
|
||||
const profile = (await fetchProfile()) as Parameters<typeof ProfileForm>[0]['initial'];
|
||||
const notifications = await fetchNotificationConfig();
|
||||
const preferences = await fetchPreferences();
|
||||
const achievements = await fetchAchievements();
|
||||
|
||||
return (
|
||||
<ProfilePageContent initial={profile} notifications={notifications} preferences={preferences} />
|
||||
<ProfilePageContent
|
||||
initial={profile}
|
||||
notifications={notifications}
|
||||
preferences={preferences}
|
||||
achievements={achievements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,10 +46,12 @@ function ProfilePageContent({
|
||||
initial,
|
||||
notifications,
|
||||
preferences,
|
||||
achievements,
|
||||
}: {
|
||||
initial: Parameters<typeof ProfileForm>[0]['initial'];
|
||||
notifications: Awaited<ReturnType<typeof fetchNotificationConfig>>;
|
||||
preferences: Awaited<ReturnType<typeof fetchPreferences>>;
|
||||
achievements: Awaited<ReturnType<typeof fetchAchievements>>;
|
||||
}) {
|
||||
const t = useTranslations('Profile');
|
||||
|
||||
@@ -79,6 +89,12 @@ function ProfilePageContent({
|
||||
<div className="rule animate-rule-in my-16 [animation-delay:780ms]" />
|
||||
|
||||
<div className="animate-fade-up [animation-delay:840ms]">
|
||||
<AchievementsPanel unlocked={achievements} />
|
||||
</div>
|
||||
|
||||
<div className="rule animate-rule-in my-16 [animation-delay:900ms]" />
|
||||
|
||||
<div className="animate-fade-up [animation-delay:960ms]">
|
||||
<DataPanel />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -19,6 +19,39 @@ export interface PreferencesView {
|
||||
trainingType: string | null;
|
||||
sessionMinutes: number | null;
|
||||
mealsPerDay: number | null;
|
||||
bannedIngredientIds: string[];
|
||||
}
|
||||
|
||||
export interface IngredientView {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string | null;
|
||||
exclusionGroups: string[];
|
||||
}
|
||||
|
||||
export async function searchIngredients(query: string): Promise<IngredientView[]> {
|
||||
const url = new URL(`${API_URL}/me/ingredients`);
|
||||
if (query.trim()) url.searchParams.set('q', query.trim());
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as { ingredients: IngredientView[] };
|
||||
return body.ingredients;
|
||||
}
|
||||
|
||||
export async function fetchIngredientsByIds(ids: string[]): Promise<IngredientView[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const url = new URL(`${API_URL}/me/ingredients`);
|
||||
url.searchParams.set('ids', ids.join(','));
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as { ingredients: IngredientView[] };
|
||||
return body.ingredients;
|
||||
}
|
||||
|
||||
export async function fetchPreferences(): Promise<PreferencesView | null> {
|
||||
|
||||
@@ -54,5 +54,6 @@ function toFormValues(view: PreferencesView): PreferencesValues {
|
||||
cuisinePreferences: view.cuisinePreferences as CuisineTag[],
|
||||
cookingTime: view.cookingTime as CookingTimeLevel,
|
||||
fastingProtocol: view.fastingProtocol as PreferencesValues['fastingProtocol'],
|
||||
bannedIngredientIds: view.bannedIngredientIds ?? [],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user