Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions app/(control)/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,42 @@ export default async function AdminLayout({ children }: { children: React.ReactN
const locale = await controlLocale();
const t = await getTranslations({ locale, namespace: "control.shell" });

// Grouped by the question each section answers — who wants to buy, who is
// running, what is being charged, what happened — not by the order the pages
// were built. Ten equal-weight links in a header row was a list to scan
// rather than a structure to navigate.
return (
<ControlShell
title={t("admin")}
userLabel={user.name}
signOutLabel={t("signOut")}
nav={[
{ href: "/admin", label: t("nav.applications") },
{ href: "/admin/signups", label: t("nav.signups") },
{ href: "/admin/partners", label: t("nav.partners") },
{ href: "/admin/clients", label: t("nav.clients") },
{ href: "/admin/tenants", label: t("nav.tenants") },
{ href: "/admin/fleet", label: t("nav.fleet") },
{ href: "/admin/provision", label: t("nav.provision") },
{ href: "/admin/onboard", label: t("nav.onboard") },
{ href: "/admin/billing", label: t("nav.billing") },
{ href: "/admin/audit", label: t("nav.audit") },
groups={[
{
label: t("groups.pipeline"),
items: [
{ href: "/admin", label: t("nav.applications") },
{ href: "/admin/signups", label: t("nav.signups") },
{ href: "/admin/partners", label: t("nav.partners") },
{ href: "/admin/clients", label: t("nav.clients") },
],
},
{
label: t("groups.tenants"),
items: [
{ href: "/admin/tenants", label: t("nav.tenants") },
{ href: "/admin/provision", label: t("nav.provision") },
{ href: "/admin/onboard", label: t("nav.onboard") },
{ href: "/admin/fleet", label: t("nav.fleet") },
],
},
{
label: t("groups.money"),
items: [{ href: "/admin/billing", label: t("nav.billing") }],
},
{
label: t("groups.system"),
items: [{ href: "/admin/audit", label: t("nav.audit") }],
},
]}
>
{children}
Expand Down
72 changes: 49 additions & 23 deletions components/control/ControlShell.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,58 @@
import Link from "next/link";
import BrandMark from "@/components/BrandMark";
import { logoutAction } from "@/lib/actions/auth-actions";
import SidebarNav, { type NavGroup, type NavItem } from "./SidebarNav";

/** Shared chrome for the partner dashboard and founder admin. Labels arrive
* translated from the server layouts (control-plane i18n, sofra #9). */
* translated from the server layouts (control-plane i18n, sofra #9).
*
* Two nav shapes, because the two surfaces are different sizes: the partner
* dashboard has one to three destinations and keeps them inline in the header
* (`nav`), while admin has ten and gets a grouped sidebar (`groups`) — ten
* equal-weight links in a row is a list to scan, not a structure to navigate.
* The sidebar collapses to a scrollable strip under the header on small
* screens rather than hiding behind a toggle: no JS, nothing to discover. */
export default function ControlShell({
title,
nav,
groups,
userLabel,
signOutLabel,
children,
}: {
title: string;
nav: { href: string; label: string }[];
nav?: NavItem[];
groups?: NavGroup[];
userLabel: string;
signOutLabel: string;
children: React.ReactNode;
}) {
const inlineNav = nav ?? [];
return (
<>
<header className="sticky top-0 z-40 bg-background/90 backdrop-blur-xs border-b-2 border-border">
<div className="mx-auto max-w-6xl px-6 h-16 flex items-center justify-between gap-4">
<div className="mx-auto max-w-7xl px-6 h-16 flex items-center justify-between gap-4">
<div className="flex items-center gap-6 min-w-0">
<Link
href="/"
className="flex items-center gap-2 text-primary shrink-0"
>
<Link href="/" className="flex items-center gap-2 text-primary shrink-0">
<BrandMark className="h-9 w-auto" />
<span className="font-hand text-3xl font-bold">SofraPiwas</span>
</Link>
<span className="masking-tape font-label text-sm px-3 py-0.5 text-muted-foreground shrink-0">
{title}
</span>
<nav className="hidden md:flex items-center gap-5 font-label text-lg">
{nav.map((item) => (
<a key={item.href} href={item.href} className="hover:text-primary transition-colors">
{item.label}
</a>
))}
</nav>
{inlineNav.length > 0 && (
<nav className="hidden md:flex items-center gap-5 font-label text-lg">
{inlineNav.map((item) => (
<a
key={item.href}
href={item.href}
className="hover:text-primary transition-colors"
>
{item.label}
</a>
))}
</nav>
)}
</div>
<div className="flex items-center gap-4 shrink-0">
<span className="hidden sm:block font-label text-sm text-muted-foreground truncate max-w-40">
Expand All @@ -54,16 +68,28 @@ export default function ControlShell({
</form>
</div>
</div>
{/* Mobile nav row */}
<nav className="md:hidden mx-auto max-w-6xl px-6 pb-3 flex flex-wrap gap-4 font-label">
{nav.map((item) => (
<a key={item.href} href={item.href} className="hover:text-primary transition-colors">
{item.label}
</a>
))}
</nav>
{inlineNav.length > 0 && (
/* Mobile nav row (inline shape only — the sidebar has its own) */
<nav className="md:hidden mx-auto max-w-7xl px-6 pb-3 flex flex-wrap gap-4 font-label">
{inlineNav.map((item) => (
<a key={item.href} href={item.href} className="hover:text-primary transition-colors">
{item.label}
</a>
))}
</nav>
)}
</header>
<main className="mx-auto max-w-6xl px-6 py-10">{children}</main>

{groups ? (
<div className="mx-auto max-w-7xl px-6 lg:flex lg:gap-8">
<aside className="lg:w-52 lg:shrink-0 lg:sticky lg:top-16 lg:self-start lg:max-h-[calc(100vh-4rem)] lg:overflow-y-auto py-3 lg:py-8 border-b-2 lg:border-b-0 lg:border-r-2 border-border lg:pr-4">
<SidebarNav groups={groups} />
</aside>
<main className="min-w-0 flex-1 py-8">{children}</main>
</div>
) : (
<main className="mx-auto max-w-6xl px-6 py-10">{children}</main>
)}
</>
);
}
26 changes: 11 additions & 15 deletions components/control/ProvisionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type ProvisionActionState,
} from "@/lib/actions/provisioning-actions";
import ActionError from "./ActionError";
import ProvisionPicker from "./ProvisionPicker";

/**
* Admin form that proposes a new tenant (ADR-012): submits to the server action
Expand Down Expand Up @@ -75,21 +76,16 @@ export default function ProvisionForm({ disabled }: Readonly<{ disabled?: boolea
aria-label={t("provision.currency")}
className="input-primary"
/>
<input
name="languages"
required
defaultValue="en, nl"
placeholder={t("provision.languages")}
aria-label={t("provision.languages")}
className="input-primary"
/>
<input
name="modules"
required
defaultValue="core"
placeholder={t("provision.modules")}
aria-label={t("provision.modules")}
className="input-primary"
<ProvisionPicker
labels={{
modules: t("provision.modules"),
modulesCore: t("provision.modulesCore"),
languages: t("provision.languages"),
languagesHint: t("provision.languagesHint"),
price: t("provision.price"),
priceBundle: t("provision.priceBundle"),
priceALaCarte: t("provision.priceALaCarte"),
}}
/>
<div className="sm:col-span-2 flex flex-wrap items-center gap-4">
<button type="submit" disabled={pending || disabled} className="btn-primary disabled:opacity-60">
Expand Down
130 changes: 130 additions & 0 deletions components/control/ProvisionPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"use client";

import { useState } from "react";
import {
BUNDLES,
MODULES,
TENANT_LANGUAGES,
extraLanguageCount,
quoteModules,
type ModuleId,
} from "@/lib/module-catalog";
import { eur } from "@/lib/format";

/**
* Module + language pickers for the provisioning form (ADR-010 catalog).
*
* These were free-text comma lists, which asked the founder to remember eight
* exact ids and ten locale codes and rejected them after the fact. Checkboxes
* carry the same values — the server action reads `getAll` and joins, so the
* registry entry and the validation are unchanged.
*
* The running price is the reason this is a client component: the selection
* feeds `/admin/onboard`'s amount field, and computing it here means the two
* numbers come from one source instead of a mental sum.
*/
export default function ProvisionPicker({
labels,
}: Readonly<{
labels: {
modules: string;
modulesCore: string;
languages: string;
languagesHint: string;
price: string;
priceBundle: string;
priceALaCarte: string;
};
}>) {
const [modules, setModules] = useState<ModuleId[]>([]);
const [languages, setLanguages] = useState<string[]>(["en", "nl"]);

const toggle = <T extends string>(list: T[], value: T, on: boolean): T[] =>
on ? [...list, value] : list.filter((v) => v !== value);

const extras = extraLanguageCount(languages);
// The add-on is per-tenant, not per-language: selecting it once covers them
// all, so the quote takes the flag from the language count.
const selection: ModuleId[] = extras > 0 ? [...modules, "extra-languages"] : modules;
const quote = quoteModules(selection);
const bundle = BUNDLES.find((b) => b.id === quote.bundle);

return (
<>
<fieldset className="sm:col-span-2 border-2 border-border rounded-craft p-3">
<legend className="font-label px-1 text-sm text-muted-foreground">{labels.modules}</legend>
{/* Core is mandatory — every instance runs it — so it is stated, not offered,
and carried by a hidden input because a disabled checkbox submits nothing. */}
<input type="hidden" name="modules" value="core" />
<p className="font-label text-sm mb-2">
{labels.modulesCore} · {eur(MODULES.find((m) => m.id === "core")!.priceCents)}
</p>
<div className="grid gap-1 sm:grid-cols-2">
{MODULES.filter((m) => m.id !== "core" && m.id !== "extra-languages").map((m) => (
<label key={m.id} className="flex items-start gap-2 font-label text-sm">
<input
type="checkbox"
name="modules"
value={m.id}
checked={modules.includes(m.id)}
onChange={(e) => setModules((prev) => toggle(prev, m.id, e.target.checked))}
className="mt-1 accent-primary"
/>
<span>
<span className="font-bold">{m.id}</span> · {eur(m.priceCents)}
<br />
<span className="text-muted-foreground">{m.surface}</span>
</span>
</label>
))}
</div>
</fieldset>

<fieldset className="sm:col-span-2 border-2 border-border rounded-craft p-3">
<legend className="font-label px-1 text-sm text-muted-foreground">{labels.languages}</legend>
{/* English is the tenant app's fallback locale, so it ships regardless —
disabled inputs don't submit, hence the hidden one. First in source
order so the registry list reads en, …. */}
<input type="hidden" name="languages" value="en" />
<div className="flex flex-wrap gap-x-4 gap-y-1">
{TENANT_LANGUAGES.map((l) => (
<label key={l.code} className="flex items-center gap-2 font-label text-sm">
<input
type="checkbox"
name="languages"
value={l.code}
checked={languages.includes(l.code)}
disabled={l.code === "en"}
onChange={(e) => setLanguages((prev) => toggle(prev, l.code, e.target.checked))}
className="accent-primary"
/>
<span>
{l.label} <span className="text-muted-foreground">({l.code})</span>
</span>
</label>
))}
</div>
{/* Priced AND recorded: the quote adds extra-languages past English + one,
so the registry entry has to carry it too or the tenant is billed for a
module their instance was never marked as having. */}
{extras > 0 && <input type="hidden" name="modules" value="extra-languages" />}
<p className="font-label text-xs text-muted-foreground mt-2">{labels.languagesHint}</p>
</fieldset>

<output className="sm:col-span-2 font-label text-sm">
<span className="font-bold text-lg text-primary">
{labels.price}: {eur(quote.monthlyCents)}
</span>
{bundle && (
<span className="text-muted-foreground">
{" "}
— {labels.priceBundle} «{bundle.id}»
{quote.aLaCarteCents > quote.monthlyCents && (
<> ({labels.priceALaCarte} {eur(quote.aLaCarteCents)})</>
)}
</span>
)}
</output>
</>
);
}
Loading
Loading