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
52 changes: 52 additions & 0 deletions app/(control)/admin/partners/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export default async function AdminPartnerDetailPage({
await requireAdmin();
const locale = await controlLocale();
const t = await getTranslations({ locale, namespace: "control.admin.partnerDetail" });
// The partner-facing namespace, reused here on purpose: the founder should read
// the same words for these fields that the partner does, or the two pages drift
// into describing the same record differently (§11).
const tb = await getTranslations({ locale, namespace: "control.brand" });
const { id } = await params;
const partner = await db.user.findFirst({
where: { id, role: "PARTNER" },
Expand All @@ -29,11 +33,27 @@ export default async function AdminPartnerDetailPage({
// surfaced rather than acted on — nothing auto-revokes (lib/base-domain-
// verification.ts). This is where "is that still theirs?" gets asked.
baseDomains: { orderBy: [{ verifiedAt: "desc" }, { createdAt: "desc" }] },
// Read-only here (§11). The founder needs to see what a partner would be
// published AS — and, more to the point, whether they have ASKED to be
// published — before the owner decision in §11e is taken. Nothing on this
// page writes it: the record belongs to the partner.
brand: true,
},
});
if (!partner) notFound();

const balance = partner.commissions.reduce((s, c) => s + c.amountCents, 0);
// Joined once each so an all-empty line renders nothing at all rather than a row
// of separators.
const contact = [partner.brand?.email, partner.brand?.phone].filter(Boolean).join(" · ");
const address = [
partner.brand?.addressLine1,
partner.brand?.postalCode,
partner.brand?.city,
partner.brand?.countryCode,
]
.filter(Boolean)
.join(" · ");
// One clock for the whole render, so two domains never disagree about staleness.
const now = new Date();

Expand Down Expand Up @@ -97,6 +117,38 @@ export default async function AdminPartnerDetailPage({
</ul>
</section>

<section>
<h2 className="font-hand text-3xl font-bold">{tb("adminTitle")}</h2>
{partner.brand ? (
<div className="mt-4 grid gap-1 font-label text-sm">
<span className="font-bold">{partner.brand.displayName}</span>
{partner.brand.tagline && (
<span className="text-muted-foreground">{partner.brand.tagline}</span>
)}
{partner.brand.websiteUrl && (
// `rel="noopener noreferrer"` because this is a partner-supplied
// address opened from an admin page; the https-only check at the write
// schema is the other half (lib/partner-brand.ts).
<a
href={partner.brand.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground underline w-fit"
>
{partner.brand.websiteUrl}
</a>
)}
{contact && <span className="text-muted-foreground">{contact}</span>}
{address.length > 0 && <span className="text-muted-foreground">{address}</span>}
<span className={partner.brand.publishToTenants ? "" : "text-muted-foreground"}>
{tb(partner.brand.publishToTenants ? "adminPublishAsked" : "adminPublishOff")}
</span>
</div>
) : (
<p className="mt-4 font-label text-muted-foreground">{tb("adminEmpty")}</p>
)}
</section>

<section className="hand-drawn-border bg-card p-6">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h2 className="font-hand text-3xl font-bold">{t("ledger")}</h2>
Expand Down
71 changes: 71 additions & 0 deletions app/(control)/dashboard/brand/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { getTranslations } from "next-intl/server";
import { requirePartner } from "@/lib/rbac";
import { controlLocale } from "@/lib/control-locale";
import { db } from "@/lib/db";
import { prefillFromBillingIdentity } from "@/lib/partner-brand";
import PartnerBrandForm from "@/components/control/PartnerBrandForm";

/**
* `/dashboard/brand` — the partner's own public details (SOFRA-PARTNER-PLAN §11).
*
* `requirePartner()`, not `requirePartnerOrOwner()`, for the reason
* `/dashboard/domains` states: a direct restaurant owner has one tenant and it is
* their own, so there is no third party for them to be credited as.
*
* Reads are scoped by `partner.id` on both queries — a partner can only ever load
* their own brand and their own billing identity.
*
* Never cached: it renders a row the same request may have just written, and the
* row is per-user.
*/
export const dynamic = "force-dynamic";

export default async function PartnerBrandPage() {
const partner = await requirePartner();
const locale = await controlLocale();
const t = await getTranslations({ locale, namespace: "control.brand" });

const brand = await db.partnerBrand.findUnique({ where: { partnerId: partner.id } });

// Only consulted when there is no brand yet, and only for a NAME. Looked up by
// `userId` — the simple link, which is the right one here: this page is about a
// PARTY, not about one plan, so the plan-scoped `resolveIdentityForPlan` would be
// answering a question nobody asked. Nothing from this record is stored by
// loading the page; the partner sees the value in an editable field and saves it
// themselves (lib/partner-brand.ts explains why only this one field crosses).
// Read whether or not there is a brand already, because it now answers TWO
// questions and only the first is about prefilling. The second is D-B1a: a
// display name that IS the legal name will not be published, so the form has to
// know the legal name to be able to say so — and a partner whose record has no
// TRADE name is the one who needs telling before they type, since the only other
// name we hold is their own. Nothing from this record is stored by loading the
// page, and none of it is rendered: `legalName` reaches the client as a
// comparison input, which is a value the partner typed into their own billing
// record and is being shown their own copy of.
const identity = await db.billingIdentity.findUnique({
where: { userId: partner.id },
select: { legalName: true, tradeName: true },
});
const prefill = brand ? null : prefillFromBillingIdentity(identity);

return (
<div className="grid gap-8">
<div>
<h1 className="font-display font-bold text-5xl">{t("title")}</h1>
<p className="mt-2 font-label text-muted-foreground">{t("intro")}</p>
<p className="mt-2 font-label text-sm text-muted-foreground">{t("privacyNote")}</p>
</div>

<section className="grid gap-4">
{!brand && prefill && (
<p className="font-label text-sm text-muted-foreground">{t("prefillNote")}</p>
)}
<PartnerBrandForm
defaults={brand ?? prefill ?? undefined}
legalName={identity?.legalName}
hasTradeName={Boolean(identity?.tradeName?.trim())}
/>
</section>
</div>
);
}
5 changes: 5 additions & 0 deletions app/(control)/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
// let them claim their FIRST zone, so hiding it until they have one would hide
// the only way to get one (SOFRA-PARTNER-FLEXIBILITY-PLAN D1).
{ href: "/dashboard/domains", label: t("nav.domains") },
// Always present too, and for the same documented reason: the page's job is to
// let a partner enter their public details for the FIRST time, so gating the
// link on already having them would hide the only way to get them
// (SOFRA-PARTNER-PLAN §11).
{ href: "/dashboard/brand", label: t("nav.brand") },
];
if (hasBilling) {
nav.push({ href: "/dashboard/billing", label: t("nav.plan") });
Expand Down
143 changes: 143 additions & 0 deletions components/control/PartnerBrandForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"use client";

import { useActionState, useState } from "react";
import { useTranslations } from "next-intl";
import {
savePartnerBrandAction,
type PartnerBrandState,
} from "@/lib/actions/partner-brand-actions";
import { isLegalNameEcho } from "@/lib/partner-brand";
import ActionError from "./ActionError";

/** What the page hands in — either the stored row or, for a partner who has none
* yet, a prefill carrying nothing but a display name. */
export type BrandDefaults = {
displayName: string;
tagline?: string | null;
websiteUrl?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
postalCode?: string | null;
city?: string | null;
countryCode?: string | null;
publishToTenants?: boolean;
};

type TextField = Exclude<keyof BrandDefaults, "publishToTenants">;

/**
* A partner's PUBLIC details (SOFRA-PARTNER-PLAN §11).
*
* Bound directly to the server action so it also works as a plain form POST with
* no JavaScript (CLAUDE.md §3).
*
* Everything here is typed by hand, including the address, and that is the
* design rather than an oversight: the control plane already holds an address for
* this partner, in `BillingIdentity`, and for a sole trader it is their home.
* Only `displayName` may be prefilled, from the TRADE name (lib/partner-brand.ts).
*/
export default function PartnerBrandForm({
defaults,
legalName,
hasTradeName,
}: Readonly<{ defaults?: BrandDefaults; legalName?: string | null; hasTradeName?: boolean }>) {
const t = useTranslations("control.brand");
const [state, action, pending] = useActionState<PartnerBrandState, FormData>(
savePartnerBrandAction,
{},
);

// Watched, so the partner is told BEFORE they save rather than after — and the
// same predicate the publish choke point uses (lib/partner-brand.ts), so the UI
// cannot promise something `renderableBrand` would then refuse. Saving is still
// allowed: it is their record, and refusing the write would leave them with no way
// to record their own name. What is refused is PUBLISHING it, and the note says so
// rather than the form accepting it silently and dropping it later (D-B1a).
const [displayName, setDisplayName] = useState(defaults?.displayName ?? "");
const echoesLegalName = isLegalNameEcho(displayName, legalName);

const field = (
name: TextField,
opts: { required?: boolean; type?: string; maxLength?: number } = {},
) => (
<label key={name} className="grid gap-1 font-label text-sm text-muted-foreground">
{t(`fields.${name}`)}
<input
name={name}
type={opts.type ?? "text"}
required={opts.required}
maxLength={opts.maxLength ?? 200}
defaultValue={defaults?.[name] ?? ""}
onChange={name === "displayName" ? (e) => setDisplayName(e.target.value) : undefined}
className="input-primary"
/>
</label>
);

return (
<form action={action} className="grid gap-4 sm:grid-cols-2">
{/* Said once, up front, to the partner it applies to: a sole trader whose
billing record has no trade name has nothing we could publish, because the
only other name we hold is their own (§11b). Better here, where they are
about to type, than as a refusal after they save. */}
{legalName && !hasTradeName && (
<p className="sm:col-span-2 font-label text-sm text-muted-foreground">
{t("noTradeNameNote")}
</p>
)}
{field("displayName", { required: true, maxLength: 80 })}
{/* `<output>` rather than `role="status"`: same live region, and the native
element is announced by assistive tech that does not implement the ARIA
role (S6819). It is a live region on purpose — the partner types and the
verdict changes under them, so it has to be spoken, not merely rendered. */}
{echoesLegalName && (
<output className="sm:col-span-2 font-label text-sm text-craft-error-text">
{t("legalNameNotPublished")}
</output>
)}
{field("tagline", { maxLength: 120 })}
{field("websiteUrl", { type: "url", maxLength: 200 })}
{field("email", { type: "email" })}
{field("phone", { maxLength: 40 })}
{field("addressLine1")}
{field("postalCode", { maxLength: 20 })}
{field("city", { maxLength: 120 })}
{field("countryCode", { maxLength: 2 })}

{/* DISABLED on purpose, and the note says why. Nothing consumes
`publishToTenants` yet (§11e is an open owner decision), and a switch
that a partner can flip while nothing changes is a lie told to the one
person entitled to decide this. The column, the action and
`renderableBrand()` are all here, so enabling it later is this one
attribute. A disabled checkbox is not submitted, which the action already
reads as false. */}
<fieldset className="sm:col-span-2 hand-drawn-border bg-card p-4 grid gap-2">
<label className="flex items-start gap-3 font-label">
<input
type="checkbox"
name="publishToTenants"
disabled
defaultChecked={defaults?.publishToTenants ?? false}
className="mt-1"
/>
<span>{t("publishLabel")}</span>
</label>
<p className="font-label text-sm text-muted-foreground">{t("publishMeaning")}</p>
<p className="font-label text-sm text-craft-error-text">{t("publishUnavailable")}</p>
</fieldset>

<div className="sm:col-span-2 flex flex-wrap items-center gap-4">
<button type="submit" disabled={pending} className="btn-primary disabled:opacity-60">
{pending ? t("saving") : t("save")}
</button>
{state.ok && (
<span className="font-label text-craft-success-text dark:text-craft-success">
{t("saved")}
</span>
)}
<ActionError code={state.error} />
</div>
</form>
);
}
Loading
Loading