Group C fixes
All checks were successful
Build and Push / build (push) Successful in 1m47s

This commit is contained in:
2026-04-29 17:20:58 +02:00
parent eeef108f7e
commit 49d81190d4
8 changed files with 767 additions and 269 deletions

View File

@@ -5,6 +5,14 @@ import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages";
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
import {
configureStepSchema,
billingStepSchema,
onboardingSchema,
fieldErrors,
SUPPORTED_COUNTRIES,
type SupportedCountry,
} from "@/lib/validation";
type Step = "welcome" | "configure" | "billing" | "confirm";
@@ -68,6 +76,7 @@ export function OnboardingWizard({
const t = useTranslations("onboarding");
const tPkg = useTranslations("packages");
const tCommon = useTranslations("common");
const tCountries = useTranslations("countries");
// Personal accounts have an org name that is either the legacy
// "{givenName} {familyName} (Personal)" or the current opaque
@@ -159,11 +168,70 @@ export function OnboardingWizard({
const stepIndex = STEPS.indexOf(step);
// Bug 12 — per-step validation. `errors` holds field-path → message
// for the inline labels under each input. We only populate it on
// attempted advancement; touching a field clears its own error so
// valid input doesn't keep showing stale messages.
const [errors, setErrors] = useState<Record<string, string>>({});
const clearError = useCallback((path: string) => {
setErrors((prev) => {
if (!prev[path]) return prev;
const next = { ...prev };
delete next[path];
return next;
});
}, []);
/**
* Validate the current step against its schema. On success: clear
* errors and return true. On failure: populate errors and return
* false so the caller can refuse to advance.
*
* Welcome and configure-step have no schema interaction with billing
* fields — keeping the schemas narrow means we don't surface a
* billing error when the user is still typing on the configure step.
*/
const validateStep = (s: Step): boolean => {
if (s === "welcome") return true;
if (s === "configure") {
const r = configureStepSchema.safeParse({ agentName: config.agentName });
if (r.success) {
setErrors({});
return true;
}
setErrors(fieldErrors(r.error));
return false;
}
if (s === "billing") {
const r = billingStepSchema.safeParse({
billingAddress: config.billingAddress,
});
if (r.success) {
setErrors({});
return true;
}
setErrors(fieldErrors(r.error));
return false;
}
// confirm: validate the union (defence in depth — submit handler
// also runs onboardingSchema before POST).
const r = onboardingSchema.safeParse(config);
if (r.success) {
setErrors({});
return true;
}
setErrors(fieldErrors(r.error));
return false;
};
const goNext = () => {
if (!validateStep(step)) return;
if (stepIndex < STEPS.length - 1) setStep(STEPS[stepIndex + 1]);
};
const goBack = () => {
// Going back never re-validates; the user's existing errors stay
// pinned to fields so they can fix them after navigating back.
if (stepIndex > 0) setStep(STEPS[stepIndex - 1]);
};
@@ -216,6 +284,17 @@ export function OnboardingWizard({
};
const handleSubmit = async () => {
// Defence in depth: re-run the full schema before sending. The
// server schema is the authoritative gate but we save a round trip
// by catching any client-side gaps here. In practice this should
// never fail at this point — the per-step validators have already
// caught everything — but a future regression in the per-step
// schemas would otherwise let the bad payload through.
if (!validateStep("confirm")) {
setError(t("validationError"));
return;
}
setSubmitting(true);
setError("");
@@ -356,19 +435,21 @@ export function OnboardingWizard({
</p>
</div>
<div>
<FieldWithError error={errors.agentName}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("agentName")}
{t("agentName")} <RequiredMark />
</label>
<input
type="text"
required
value={config.agentName}
onChange={(e) =>
setConfig((prev) => ({ ...prev, agentName: e.target.value }))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
onChange={(e) => {
clearError("agentName");
setConfig((prev) => ({ ...prev, agentName: e.target.value }));
}}
className={inputClass(errors.agentName)}
/>
</div>
</FieldWithError>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
@@ -635,106 +716,131 @@ export function OnboardingWizard({
</p>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCompany")}
</label>
<input
type="text"
value={config.billingAddress.company}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
company: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
{/* Bug 2: company line is meaningless for personal accounts.
Hide entirely rather than render an empty disabled field
— the latter would just suggest the customer should
fill it in. */}
{!isPersonal && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCompany")}
</label>
<input
type="text"
value={config.billingAddress.company}
onChange={(e) => {
clearError("billingAddress.company");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
company: e.target.value,
},
}));
}}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
)}
<div>
<FieldWithError error={errors["billingAddress.street"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingStreet")}
{t("billingStreet")} <RequiredMark />
</label>
<input
type="text"
required
value={config.billingAddress.street}
onChange={(e) =>
onChange={(e) => {
clearError("billingAddress.street");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
street: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
}));
}}
className={inputClass(errors["billingAddress.street"])}
/>
</div>
</FieldWithError>
<div className="grid grid-cols-3 gap-3">
<div>
<FieldWithError error={errors["billingAddress.postalCode"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingPostalCode")}
{t("billingPostalCode")} <RequiredMark />
</label>
<input
type="text"
required
value={config.billingAddress.postalCode}
onChange={(e) =>
onChange={(e) => {
clearError("billingAddress.postalCode");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
postalCode: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
}));
}}
className={inputClass(errors["billingAddress.postalCode"])}
/>
</div>
</FieldWithError>
<div className="col-span-2">
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCity")}
</label>
<input
type="text"
value={config.billingAddress.city}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
city: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<FieldWithError error={errors["billingAddress.city"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCity")} <RequiredMark />
</label>
<input
type="text"
required
value={config.billingAddress.city}
onChange={(e) => {
clearError("billingAddress.city");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
city: e.target.value,
},
}));
}}
className={inputClass(errors["billingAddress.city"])}
/>
</FieldWithError>
</div>
</div>
<div>
{/* Bug 3: country was a free-text field — typos broke
invoicing. Now a fixed list of DACH+ neighbours. Add
more codes to SUPPORTED_COUNTRIES in lib/validation.ts
when expanding markets. */}
<FieldWithError error={errors["billingAddress.country"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCountry")}
{t("billingCountry")} <RequiredMark />
</label>
<input
type="text"
<select
value={config.billingAddress.country}
onChange={(e) =>
onChange={(e) => {
clearError("billingAddress.country");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
country: e.target.value,
country: e.target.value as SupportedCountry,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
}));
}}
className={inputClass(errors["billingAddress.country"])}
>
{SUPPORTED_COUNTRIES.map((code) => (
<option key={code} value={code}>
{tCountries(code)}
</option>
))}
</select>
</FieldWithError>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
@@ -782,67 +888,92 @@ export function OnboardingWizard({
{t("confirmDescription")}
</p>
{/* Bug 4 redesign: previously this step only showed agentName
and city — useless for actually reviewing what's about to
be submitted. Now it shows the real config: instance
name, agent name, packages, billing one-liner, contact
email, and notes. Each row uses two columns rather than
flex-justify-between so long values wrap underneath the
label rather than being squashed onto one line. */}
<div className="space-y-4">
<div className="bg-surface-2 border border-border rounded-lg p-4 space-y-3">
{config.instanceName.trim() && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("instanceName")}</span>
<span className="text-text-primary font-mono">
{config.instanceName.trim()}
</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("agentName")}</span>
<span className="text-text-primary font-mono">
{config.agentName}
</span>
</div>
{config.packages.length > 0 && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("packages")}</span>
<div className="flex flex-wrap gap-1 justify-end">
{config.packages.map((pkg) => (
<span
key={pkg}
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2 py-0.5"
>
{pkg}
</span>
))}
<div className="bg-surface-2 border border-border rounded-lg p-4 divide-y divide-border">
<ReviewRow
label={t("instanceName")}
value={
config.instanceName.trim() || (
<span className="text-text-muted italic">
{t("reviewInstanceDefault")}
</span>
)
}
mono
/>
<ReviewRow
label={t("agentName")}
value={config.agentName}
mono
/>
<ReviewRow
label={t("packages")}
value={
config.packages.length === 0 ? (
<span className="text-text-muted italic">
{t("reviewNoPackages")}
</span>
) : (
<div className="flex flex-wrap gap-1 justify-end">
{config.packages.map((pkg) => (
<span
key={pkg}
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2 py-0.5"
>
{pkg}
</span>
))}
</div>
)
}
/>
<ReviewRow
label={t("reviewBillingTo")}
value={
<div className="text-text-primary text-right">
{/* For personal: skip the company line so the
invoice rendering matches what the user actually
entered. For company: include it as the first
line. */}
{!isPersonal &&
config.billingAddress.company &&
config.billingAddress.company.trim().length > 0 && (
<div>{config.billingAddress.company}</div>
)}
<div>{config.billingAddress.street}</div>
<div>
{config.billingAddress.postalCode}{" "}
{config.billingAddress.city}
</div>
<div className="text-text-muted">
{tCountries(
config.billingAddress.country as SupportedCountry
)}
</div>
</div>
</div>
)}
{config.packages.some((id) =>
PACKAGE_CATALOG.find((p) => p.id === id)?.requiresSecrets
) && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">
{t("credentialsProvided")}
</span>
<span className="text-emerald-400 text-xs font-medium">
</span>
</div>
)}
{config.billingAddress.company && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">
{t("billingCompany")}
</span>
<span className="text-text-primary">
{config.billingAddress.company}
</span>
</div>
)}
{config.billingAddress.city && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("billingCity")}</span>
<span className="text-text-primary">
{config.billingAddress.postalCode}{" "}
{config.billingAddress.city}
</span>
</div>
}
/>
<ReviewRow
label={t("reviewContactEmail")}
value={userEmail || ""}
mono
/>
{config.billingNotes.trim().length > 0 && (
<ReviewRow
label={t("billingNotes")}
value={
<span className="text-text-primary whitespace-pre-wrap text-right">
{config.billingNotes}
</span>
}
/>
)}
</div>
@@ -855,6 +986,25 @@ export function OnboardingWizard({
</div>
)}
{/* Aggregate validation errors — if any per-step schema check
missed something (it shouldn't, but defence in depth),
the user sees a consolidated list here rather than a
silent submit failure. */}
{Object.keys(errors).length > 0 && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mt-4">
<div className="font-semibold mb-1">
{t("validationErrorsTitle")}
</div>
<ul className="list-disc list-inside space-y-0.5">
{Object.entries(errors).map(([path, msg]) => (
<li key={path}>
<span className="font-mono">{path}</span>: {msg}
</li>
))}
</ul>
</div>
)}
<div className="flex justify-between mt-6">
<button
onClick={goBack}
@@ -875,3 +1025,74 @@ export function OnboardingWizard({
</div>
);
}
/**
* Two-column review row used by the confirm step. Right-aligned value
* with the label as a muted prefix on the left.
*/
function ReviewRow({
label,
value,
mono,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
<div className="flex justify-between gap-4 text-sm py-2 first:pt-0 last:pb-0">
<span className="text-text-muted shrink-0">{label}</span>
<span
className={`text-text-primary text-right min-w-0 break-words ${
mono ? "font-mono" : ""
}`}
>
{value}
</span>
</div>
);
}
/**
* Renders children + an inline error message if present. Children
* supply the label and input; this wrapper just appends the message.
*/
function FieldWithError({
error,
children,
}: {
error?: string;
children: React.ReactNode;
}) {
return (
<div>
{children}
{error && (
<p className="text-xs text-red-400 mt-1" role="alert">
{error}
</p>
)}
</div>
);
}
function RequiredMark() {
return (
<span aria-hidden="true" className="text-accent">
*
</span>
);
}
/**
* Tailwind class for input/select with optional error-state ring.
* Centralised here to keep the wizard's many fields visually
* consistent without repeating the long class string.
*/
function inputClass(error?: string): string {
return `w-full px-3 py-2 bg-surface-2 border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 transition-colors ${
error
? "border-red-400/60 focus:ring-red-400 focus:border-red-400"
: "border-border focus:ring-accent focus:border-accent"
}`;
}