feat(billing): Stripe + abbonamento Pro con trial 30gg (ADR 0004)
Chiude la decisione "payment provider" aperta in CLAUDE.md. **Modello**: free 30gg post-signup (no carta richiesta) → Pro mensile €9,90 / annuale €89. Allinea il paywall al confine fra fase INTENSIVE e TRANSITION (PRD §5.1), quando l'utente ha già visto i primi risultati. Dopo la scadenza l'app non si "spegne": storico restano consultabili (sola lettura), ma generazione piani / nuove pesate / digiuni / foto / export richiedono abbonamento attivo. **Schema**: nuova tabella `subscriptions` (1:1 con users) con stati TRIALING/ACTIVE/PAST_DUE/CANCEL_AT_PERIOD_END/CANCELED/EXPIRED + `billing_webhook_events` per idempotenza dei retry Stripe. **Backend** (`apps/api/src/modules/billing/`): - `GET /me/billing/status` — snapshot + derived (kind, isPro, trialDaysRemaining) - `POST /me/billing/checkout` — crea Stripe Checkout Session (subscription mode + Stripe Tax + tax_id_collection) - `POST /me/billing/portal` — Customer Portal Session - `POST /webhooks/stripe` — raw body, firma HMAC, idempotenza per `event.id`, dispatch su `customer.subscription.*`, `checkout.session.completed`, `invoice.payment_failed` - Plugin `requirePro()` (402 payment_required) applicato a 9 rotte: meal-plans CRUD, weight-entries POST, check-ins POST, fast-events POST/PATCH, fasting/pause POST, export.pdf (plan+tracking) **Shared** (`@ketopath/shared/billing/pro-status`): - `isProActive(snap)` — verifica live (gestisce anche TRIALING con `trialEndsAt` passato in caso di cron in ritardo) - `deriveProStatus(snap)` — kind + isPro + trialDaysRemaining + accessEndsAt per l'UI - `computeTrialEndsAt(signupAt, days=30)` - 11 unit test **Frontend** (`apps/web/src/app/[locale]/billing/`): - Pagina `/billing` editoriale (capitolo VIII) con StatusBlock per ogni kind, BillingActionsBar client (transitions, redirect a Stripe), 3 benefits - `<TrialBanner>` in SignedInDashboard (3 stati: trial in corso oro / scaduto pomodoro / past_due pomodoro) - Nav item "Abbonamento" (chapter VI) nel grid asimmetrico - i18n IT completo (`Billing` namespace) **Soft-degradation**: env Stripe (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ID_*`, `BILLING_RETURN_URL`) sono tutte opzionali. Senza configurazione i route billing rispondono 503 e il banner trial mostra "pagamenti non ancora attivati" — utenti in trial continuano a usare l'app. 99/99 test verdi, lint pulito su tutto il monorepo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
41b3646325
commit
f455cbe499
28 files changed
+2035
-486
No files matched your search
@@ -15,6 +15,7 @@
|
||||
"viewShopping": "Lista della spesa",
|
||||
"viewTracking": "Pesata & misure",
|
||||
"viewFasting": "Digiuno intermittente",
|
||||
"viewBilling": "Abbonamento & fatture",
|
||||
"lettura": "Continua la lettura",
|
||||
"inizia": "Inizia",
|
||||
"greeting": "Buongiorno, <name>{name}</name>.",
|
||||
@@ -710,5 +711,57 @@
|
||||
"email_required": "Email assente sul tuo account.",
|
||||
"api_error": "Errore di rete. Riprova fra poco."
|
||||
}
|
||||
},
|
||||
"Billing": {
|
||||
"title": "Abbonamento",
|
||||
"subtitle": "Trenta giorni di prova gratuiti, senza carta. Poi un piano semplice — mensile o annuale — che si gestisce in autonomia dal portale Stripe.",
|
||||
"currentStatus": "Stato attuale",
|
||||
"actions": "Azioni",
|
||||
"trialActive": "Sei in prova ({days} giorni rimasti).",
|
||||
"trialActiveBody": "Hai accesso completo a piani, tracking, digiuno e foto progress fino al {endsAt}. Allo scadere puoi continuare a leggere lo storico — ma per nuove pesate, piani e foto serve l'abbonamento.",
|
||||
"trialExpired": "La prova è scaduta.",
|
||||
"trialExpiredBody": "Lo storico resta consultabile. Per ricominciare a generare piani, registrare pesate e completare digiuni, attiva un abbonamento.",
|
||||
"proActive": "Abbonamento attivo.",
|
||||
"proActiveBody": "Piano {interval}, prossimo rinnovo il {renewsAt}. Tutto sbloccato.",
|
||||
"pastDue": "Pagamento in sospeso.",
|
||||
"pastDueBody": "L'ultimo addebito è fallito. Stripe sta riprovando: aggiorna la carta dal portale per evitare interruzioni.",
|
||||
"canceling": "Disdetta in corso.",
|
||||
"cancelingBody": "L'abbonamento resta valido fino al {endsAt}. Puoi riattivarlo in qualsiasi momento dal portale.",
|
||||
"canceled": "Abbonamento disdetto.",
|
||||
"canceledBody": "Lo storico resta tuo e consultabile. Quando vuoi rientrare, basta un nuovo upgrade.",
|
||||
"noSubscription": "Nessun abbonamento attivo.",
|
||||
"intervalMonth": "mensile",
|
||||
"intervalYear": "annuale",
|
||||
"upgradeMonthly": "Abbonati — €9,90 / mese",
|
||||
"upgradeYearly": "Abbonati — €89 / anno (≈25% di sconto)",
|
||||
"managePortal": "Gestisci abbonamento",
|
||||
"opening": "Apertura…",
|
||||
"checkoutHint": "Verrai reindirizzato al pagamento sicuro su Stripe.",
|
||||
"notConfigured": "Pagamenti non ancora attivati. La tua prova continua: nessuna interruzione.",
|
||||
"errorLoading": "Impossibile caricare lo stato dell'abbonamento.",
|
||||
"whatYouGet": "Cosa è incluso",
|
||||
"benefit1Title": "Piani settimanali infiniti",
|
||||
"benefit1Body": "Genera quanti piani vuoi, riadattati alla fase, alle preferenze e alle esclusioni alimentari. Con riadattamento automatico se cambi peso o protocollo.",
|
||||
"benefit2Title": "Tracking completo",
|
||||
"benefit2Body": "Pesate quotidiane, check-in soggettivi, foto progress, digiuno con timer e milestone. Tutto cifrato a riposo (art. 9 GDPR).",
|
||||
"benefit3Title": "Export PDF per il medico",
|
||||
"benefit3Body": "Un documento sintetico — peso, misure, aderenza — pronto da inviare al nutrizionista o consultare in studio.",
|
||||
"trialBannerTitle": "Sei in prova",
|
||||
"trialBannerBody": "{days} giorni rimasti. Tutto sbloccato fino al {endsAt}.",
|
||||
"trialBannerCta": "Vai all'abbonamento",
|
||||
"trialEndedBannerTitle": "La prova è scaduta",
|
||||
"trialEndedBannerBody": "Per generare nuovi piani e registrare pesate serve l'abbonamento.",
|
||||
"trialEndedBannerCta": "Abbonati",
|
||||
"paywallTitle": "Funzione Pro",
|
||||
"paywallBody": "Questa azione richiede un abbonamento attivo.",
|
||||
"paywallCta": "Vai all'abbonamento",
|
||||
"navLabel": "Abbonamento",
|
||||
"error": {
|
||||
"billing_not_configured": "Il sistema di pagamento non è ancora configurato. Riprova più tardi.",
|
||||
"no_stripe_customer": "Non hai ancora un abbonamento attivo. Avvia prima il checkout.",
|
||||
"api_error_400": "Richiesta non valida.",
|
||||
"api_error_401": "Devi accedere per gestire l'abbonamento.",
|
||||
"api_error_500": "Errore del server. Riprova fra poco."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use server';
|
||||
|
||||
import type { ProDerivedKind, SubscriptionStatus } from '@ketopath/shared';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
|
||||
|
||||
function cookieHeader(): string {
|
||||
return headers().get('cookie') ?? '';
|
||||
}
|
||||
|
||||
export interface BillingStatus {
|
||||
subscription: {
|
||||
status: SubscriptionStatus;
|
||||
trialEndsAt: string;
|
||||
currentPeriodEnd: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
interval: 'MONTH' | 'YEAR' | null;
|
||||
stripePriceId: string | null;
|
||||
} | null;
|
||||
derived: {
|
||||
kind: ProDerivedKind;
|
||||
isPro: boolean;
|
||||
trialDaysRemaining: number | null;
|
||||
accessEndsAt: string | null;
|
||||
};
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export async function fetchBillingStatus(): Promise<BillingStatus | null> {
|
||||
const res = await fetch(`${API_URL}/me/billing/status`, {
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as BillingStatus;
|
||||
}
|
||||
|
||||
export async function startCheckout(
|
||||
interval: 'MONTH' | 'YEAR',
|
||||
): Promise<{ url: string } | { error: string }> {
|
||||
const res = await fetch(`${API_URL}/me/billing/checkout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
cookie: cookieHeader(),
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ interval }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (res.status === 503) return { error: 'billing_not_configured' };
|
||||
if (!res.ok) return { error: `api_error_${res.status}` };
|
||||
return (await res.json()) as { url: string };
|
||||
}
|
||||
|
||||
export async function openCustomerPortal(): Promise<{ url: string } | { error: string }> {
|
||||
const res = await fetch(`${API_URL}/me/billing/portal`, {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (res.status === 409) return { error: 'no_stripe_customer' };
|
||||
if (res.status === 503) return { error: 'billing_not_configured' };
|
||||
if (!res.ok) return { error: `api_error_${res.status}` };
|
||||
return (await res.json()) as { url: string };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { openCustomerPortal, startCheckout } from './actions';
|
||||
|
||||
interface Props {
|
||||
hasStripeCustomer: boolean;
|
||||
isPro: boolean;
|
||||
}
|
||||
|
||||
export function BillingActionsBar({ hasStripeCustomer, isPro }: Props) {
|
||||
const t = useTranslations('Billing');
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function handleCheckout(interval: 'MONTH' | 'YEAR') {
|
||||
startTransition(async () => {
|
||||
const res = await startCheckout(interval);
|
||||
if ('url' in res) {
|
||||
window.location.href = res.url;
|
||||
} else {
|
||||
alert(t(`error.${res.error}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handlePortal() {
|
||||
startTransition(async () => {
|
||||
const res = await openCustomerPortal();
|
||||
if ('url' in res) {
|
||||
window.location.href = res.url;
|
||||
} else {
|
||||
alert(t(`error.${res.error}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isPro && hasStripeCustomer) {
|
||||
return (
|
||||
<Button onClick={handlePortal} disabled={pending} size="lg">
|
||||
{pending ? t('opening') : t('managePortal')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<Button onClick={() => handleCheckout('MONTH')} disabled={pending} size="lg">
|
||||
{pending ? t('opening') : t('upgradeMonthly')}
|
||||
</Button>
|
||||
<Button onClick={() => handleCheckout('YEAR')} disabled={pending} size="lg" variant="outline">
|
||||
{pending ? t('opening') : t('upgradeYearly')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { prisma } from '@ketopath/db';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { CursorGlow } from '@/components/cursor-glow';
|
||||
import { Masthead } from '@/components/masthead';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { fetchBillingStatus, type BillingStatus } from './actions';
|
||||
import { BillingActionsBar } from './billing-actions-bar';
|
||||
|
||||
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
export default async function BillingPage({ 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('/welcome');
|
||||
|
||||
const status = await fetchBillingStatus();
|
||||
|
||||
return <BillingPageContent status={status} />;
|
||||
}
|
||||
|
||||
function BillingPageContent({ status }: { status: BillingStatus | null }) {
|
||||
const t = useTranslations('Billing');
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
|
||||
<Masthead issueLabel="N. 08 — Abbonamento" />
|
||||
<main className="pb-24 pt-12">
|
||||
<p className="editorial-eyebrow">{t('errorLoading')}</p>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { subscription, derived, configured } = status;
|
||||
const hasStripeCustomer = subscription?.stripePriceId != null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
|
||||
<Masthead issueLabel="N. 08 — Abbonamento" />
|
||||
<main className="relative overflow-hidden pb-24 pt-10 sm:pt-12">
|
||||
<CursorGlow color="hsl(var(--oro))" size={520} />
|
||||
<div
|
||||
aria-hidden
|
||||
className="mesh-blob mesh-blob--oro animate-float-y -right-32 top-12 h-[28rem] w-[28rem] opacity-50"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="mesh-blob mesh-blob--pomodoro animate-float-x -left-24 top-72 h-72 w-72 opacity-35"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="font-display text-stroke-thin text-chapter pointer-events-none absolute -right-4 -top-12 select-none italic"
|
||||
>
|
||||
VIII
|
||||
</span>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute right-0 top-32 hidden md:block"
|
||||
style={{ writingMode: 'vertical-rl' }}
|
||||
>
|
||||
<p className="text-ink-soft font-mono text-xs uppercase tracking-[0.4em]">
|
||||
Capitolo VIII — Abbonamento
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="font-display text-mega bleed-left animate-fade-up relative mt-8 font-medium [animation-delay:120ms]">
|
||||
<span className="text-ink block">
|
||||
{t('title')}
|
||||
<span className="text-pomodoro">.</span>
|
||||
</span>
|
||||
</h1>
|
||||
<p className="font-display text-ink-soft animate-fade-up relative mt-10 max-w-2xl text-2xl italic leading-snug [animation-delay:240ms] md:ml-[16.66%]">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="rule animate-rule-in my-12 [animation-delay:360ms]" />
|
||||
|
||||
<section className="animate-fade-up relative grid gap-12 [animation-delay:420ms] md:grid-cols-12">
|
||||
<div className="md:col-span-7">
|
||||
<p className="editorial-eyebrow mb-4">{t('currentStatus')}</p>
|
||||
<StatusBlock derived={derived} subscription={subscription} />
|
||||
</div>
|
||||
<aside className="md:border-ink/15 md:col-span-5 md:border-l md:pl-10">
|
||||
<p className="editorial-eyebrow mb-4">{t('actions')}</p>
|
||||
{!configured ? (
|
||||
<p className="font-display text-ink-soft text-base italic leading-snug">
|
||||
{t('notConfigured')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<BillingActionsBar hasStripeCustomer={hasStripeCustomer} isPro={derived.isPro} />
|
||||
{!derived.isPro || !hasStripeCustomer ? (
|
||||
<p className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
|
||||
{t('checkoutHint')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<div className="rule animate-rule-in my-16 [animation-delay:540ms]" />
|
||||
|
||||
<section className="animate-fade-up relative [animation-delay:600ms]">
|
||||
<p className="editorial-eyebrow mb-4">{t('whatYouGet')}</p>
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
<Benefit num="01" titleKey="benefit1Title" bodyKey="benefit1Body" />
|
||||
<Benefit num="02" titleKey="benefit2Title" bodyKey="benefit2Body" />
|
||||
<Benefit num="03" titleKey="benefit3Title" bodyKey="benefit3Body" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBlock({
|
||||
derived,
|
||||
subscription,
|
||||
}: {
|
||||
derived: BillingStatus['derived'];
|
||||
subscription: BillingStatus['subscription'];
|
||||
}) {
|
||||
const t = useTranslations('Billing');
|
||||
|
||||
switch (derived.kind) {
|
||||
case 'trial':
|
||||
return (
|
||||
<div>
|
||||
<p className="font-display text-ink text-3xl leading-tight md:text-4xl">
|
||||
{t('trialActive', { days: derived.trialDaysRemaining ?? 0 })}
|
||||
</p>
|
||||
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
|
||||
{t('trialActiveBody', {
|
||||
endsAt: derived.accessEndsAt
|
||||
? ITALIAN_DATE.format(new Date(derived.accessEndsAt))
|
||||
: '—',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case 'trial_expired':
|
||||
return (
|
||||
<div>
|
||||
<p className="font-display text-pomodoro text-3xl leading-tight md:text-4xl">
|
||||
{t('trialExpired')}
|
||||
</p>
|
||||
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
|
||||
{t('trialExpiredBody')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case 'active':
|
||||
return (
|
||||
<div>
|
||||
<p className="font-display text-oliva text-3xl leading-tight md:text-4xl">
|
||||
{t('proActive')}
|
||||
</p>
|
||||
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
|
||||
{t('proActiveBody', {
|
||||
interval:
|
||||
subscription?.interval === 'YEAR'
|
||||
? t('intervalYear')
|
||||
: subscription?.interval === 'MONTH'
|
||||
? t('intervalMonth')
|
||||
: '—',
|
||||
renewsAt: derived.accessEndsAt
|
||||
? ITALIAN_DATE.format(new Date(derived.accessEndsAt))
|
||||
: '—',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case 'past_due':
|
||||
return (
|
||||
<div>
|
||||
<p className="font-display text-pomodoro text-3xl leading-tight md:text-4xl">
|
||||
{t('pastDue')}
|
||||
</p>
|
||||
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
|
||||
{t('pastDueBody')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case 'canceling':
|
||||
return (
|
||||
<div>
|
||||
<p className="font-display text-ink text-3xl leading-tight md:text-4xl">
|
||||
{t('canceling')}
|
||||
</p>
|
||||
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
|
||||
{t('cancelingBody', {
|
||||
endsAt: derived.accessEndsAt
|
||||
? ITALIAN_DATE.format(new Date(derived.accessEndsAt))
|
||||
: '—',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case 'canceled':
|
||||
return (
|
||||
<div>
|
||||
<p className="font-display text-ink-dim text-3xl leading-tight md:text-4xl">
|
||||
{t('canceled')}
|
||||
</p>
|
||||
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
|
||||
{t('canceledBody')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case 'no_subscription':
|
||||
default:
|
||||
return (
|
||||
<p className="font-display text-ink-soft text-base italic leading-snug">
|
||||
{t('noSubscription')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Benefit({ num, titleKey, bodyKey }: { num: string; titleKey: string; bodyKey: string }) {
|
||||
const t = useTranslations('Billing');
|
||||
return (
|
||||
<article>
|
||||
<p className="font-display text-pomodoro text-2xl italic">{num}</p>
|
||||
<h3 className="font-display text-ink mt-2 text-xl font-medium leading-tight">
|
||||
{t(titleKey)}
|
||||
</h3>
|
||||
<p className="font-display text-ink-soft mt-3 text-sm italic leading-snug">{t(bodyKey)}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -6,21 +6,34 @@ import { CursorGlow } from '@/components/cursor-glow';
|
||||
import { Masthead } from '@/components/masthead';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { fetchBillingStatus, type BillingStatus } from './billing/actions';
|
||||
import { SignOutButton } from './sign-out-button';
|
||||
|
||||
export default async function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
||||
setRequestLocale(locale);
|
||||
const session = await getServerSession();
|
||||
const userName = session?.user?.name ?? session?.user?.email ?? null;
|
||||
const billing = userName ? await fetchBillingStatus() : null;
|
||||
|
||||
return <HomeContent userName={session?.user?.name ?? session?.user?.email ?? null} />;
|
||||
return <HomeContent userName={userName} billing={billing} />;
|
||||
}
|
||||
|
||||
function HomeContent({ userName }: { userName: string | null }) {
|
||||
function HomeContent({
|
||||
userName,
|
||||
billing,
|
||||
}: {
|
||||
userName: string | null;
|
||||
billing: BillingStatus | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
|
||||
<Masthead />
|
||||
{userName ? <SignedInDashboard userName={userName} /> : <MarketingLanding />}
|
||||
{userName ? (
|
||||
<SignedInDashboard userName={userName} billing={billing} />
|
||||
) : (
|
||||
<MarketingLanding />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -800,7 +813,13 @@ function Disclaimer() {
|
||||
);
|
||||
}
|
||||
|
||||
function SignedInDashboard({ userName }: { userName: string }) {
|
||||
function SignedInDashboard({
|
||||
userName,
|
||||
billing,
|
||||
}: {
|
||||
userName: string;
|
||||
billing: BillingStatus | null;
|
||||
}) {
|
||||
const t = useTranslations('Home');
|
||||
return (
|
||||
<main className="relative min-h-[80vh] overflow-hidden pb-24">
|
||||
@@ -848,6 +867,8 @@ function SignedInDashboard({ userName }: { userName: string }) {
|
||||
{t('tagline')}
|
||||
</p>
|
||||
|
||||
<TrialBanner billing={billing} />
|
||||
|
||||
{/* Nav asimmetrica: card grande "/plan" a sinistra, le altre 4 in colonna a destra */}
|
||||
<div className="animate-fade-up relative mt-20 grid grid-cols-12 gap-6 [animation-delay:360ms]">
|
||||
<DashboardHero
|
||||
@@ -861,6 +882,7 @@ function SignedInDashboard({ userName }: { userName: string }) {
|
||||
<NavItem href="/fasting" label={t('viewFasting')} eyebrow="Digiuno" chapter="III" />
|
||||
<NavItem href="/tracking" label={t('viewTracking')} eyebrow="Tracking" chapter="IV" />
|
||||
<NavItem href="/profile" label={t('completeProfile')} eyebrow="Profilo" chapter="V" />
|
||||
<NavItem href="/billing" label={t('viewBilling')} eyebrow="Abbonamento" chapter="VI" />
|
||||
<li className="pt-6">
|
||||
<SignOutButton label={t('signOut')} />
|
||||
</li>
|
||||
@@ -957,3 +979,59 @@ function NavItem({
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
|
||||
function TrialBanner({ billing }: { billing: BillingStatus | null }) {
|
||||
const t = useTranslations('Billing');
|
||||
if (!billing) return null;
|
||||
const { derived } = billing;
|
||||
|
||||
if (derived.kind === 'trial' && derived.trialDaysRemaining != null) {
|
||||
const endsAt = derived.accessEndsAt ? ITALIAN_DATE.format(new Date(derived.accessEndsAt)) : '—';
|
||||
return (
|
||||
<aside className="border-oro/35 bg-oro/5 animate-fade-up relative mt-12 border-2 border-dashed p-6 [animation-delay:300ms] md:ml-[16.66%]">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-baseline md:justify-between">
|
||||
<div>
|
||||
<p className="editorial-eyebrow">{t('trialBannerTitle')}</p>
|
||||
<p className="font-display text-ink mt-2 text-lg leading-snug">
|
||||
{t('trialBannerBody', { days: derived.trialDaysRemaining, endsAt })}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/billing"
|
||||
className="text-oro hover:text-pomodoro shrink-0 font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px]"
|
||||
>
|
||||
{t('trialBannerCta')} →
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
if (derived.kind === 'trial_expired' || derived.kind === 'past_due') {
|
||||
return (
|
||||
<aside className="border-pomodoro/40 bg-pomodoro/5 animate-fade-up relative mt-12 border-2 border-dashed p-6 [animation-delay:300ms] md:ml-[16.66%]">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-baseline md:justify-between">
|
||||
<div>
|
||||
<p className="editorial-eyebrow">{t('trialEndedBannerTitle')}</p>
|
||||
<p className="font-display text-ink mt-2 text-lg leading-snug">
|
||||
{t('trialEndedBannerBody')}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/billing"
|
||||
className="text-pomodoro hover:text-ink shrink-0 font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px]"
|
||||
>
|
||||
{t('trialEndedBannerCta')} →
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user