feat(portal): M11.1 catalog flow + M12.1 self-serve trial
Closes the customer loop: /start signup → tenant + KC org + IT_ADMIN invite → portal dashboard with trial banner → /[slug]/catalog with Request + Start trial server actions wired to tenant-registry. Refs: M11.1 + M12.1
This commit was merged in pull request #11.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { auth, signIn, signOut } from "@/auth";
|
||||
import { ShellEmpty } from "@/components/ShellEmpty";
|
||||
import type { SessionWithExtras } from "@/lib/session";
|
||||
import { fetchTenantBySlug } from "@/lib/tenant-registry";
|
||||
|
||||
export default async function Dashboard({
|
||||
params,
|
||||
@@ -43,10 +44,20 @@ export default async function Dashboard({
|
||||
await signOut({ redirectTo: `/${slug}/dashboard` });
|
||||
}
|
||||
|
||||
const tenant = await fetchTenantBySlug(slug);
|
||||
const products = session.products ?? [];
|
||||
const trialDaysLeft = computeTrialDaysLeft(tenant?.trial_ends_at);
|
||||
|
||||
return (
|
||||
<section>
|
||||
{tenant?.status === "trial" && tenant.trial_ends_at && (
|
||||
<TrialBanner
|
||||
endsAt={tenant.trial_ends_at}
|
||||
slug={slug}
|
||||
daysLeft={trialDaysLeft}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Dashboard</h1>
|
||||
<p style={{ color: "#444", marginBottom: 24 }}>
|
||||
Welcome, {session.user?.name ?? session.user?.email ?? "user"}. Signed in
|
||||
@@ -61,7 +72,15 @@ export default async function Dashboard({
|
||||
milestone="M11.1"
|
||||
/>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", padding: 0, display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))" }}>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
padding: 0,
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||
}}
|
||||
>
|
||||
{products.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
@@ -100,3 +119,54 @@ export default async function Dashboard({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Pure compute, lives outside any render path so react-hooks/purity is satisfied.
|
||||
function computeTrialDaysLeft(endsAt: string | null | undefined): number {
|
||||
if (!endsAt) return 0;
|
||||
const ms = new Date(endsAt).getTime() - Date.now();
|
||||
return Math.max(0, Math.ceil(ms / (24 * 3600 * 1000)));
|
||||
}
|
||||
|
||||
function TrialBanner({
|
||||
endsAt,
|
||||
slug,
|
||||
daysLeft,
|
||||
}: {
|
||||
endsAt: string;
|
||||
slug: string;
|
||||
daysLeft: number;
|
||||
}) {
|
||||
const ends = new Date(endsAt);
|
||||
const urgent = daysLeft <= 3;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
borderRadius: 8,
|
||||
background: urgent ? "#fdecea" : "#fff7e0",
|
||||
color: urgent ? "#a82626" : "#7a5a00",
|
||||
border: `1px solid ${urgent ? "#e8a5a5" : "#e6d28a"}`,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Trial — <strong>{daysLeft}</strong> day{daysLeft === 1 ? "" : "s"} left
|
||||
{" "}(ends {ends.toLocaleDateString()}).
|
||||
</span>
|
||||
<a
|
||||
href={`/${slug}/billing`}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: urgent ? "#a82626" : "#7a5a00",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
Upgrade →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user