1280 lines
49 KiB
TypeScript
1280 lines
49 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { Card } from "@/components/ui/card";
|
|
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, 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";
|
|
|
|
// The step list. Composed once and used to compute "next/prev" arrows
|
|
// and progress indicator. Bug 35: the billing step is conditional —
|
|
// orgs that already have billing on file (subsequent tenants, or
|
|
// pre-filled via /settings/billing) skip it. The wizard's submit
|
|
// payload omits billingAddress in that case; the API picks up the
|
|
// existing org_billing row server-side.
|
|
function makeSteps(opts: {
|
|
hasOrgBilling: boolean;
|
|
isEditing: boolean;
|
|
}): Step[] {
|
|
const base: Step[] = ["welcome", "configure", "billing", "confirm"];
|
|
// Edit mode currently still shows the billing step because we want
|
|
// the customer to be able to fix billing on a still-pending request
|
|
// BEFORE it reaches admin. Once approved, edits go through
|
|
// /settings/billing instead. Same step set for editing as new for now.
|
|
if (opts.hasOrgBilling && !opts.isEditing) {
|
|
return base.filter((s) => s !== "billing");
|
|
}
|
|
return base;
|
|
}
|
|
|
|
// Inline fallbacks — only used if the API call to /api/workspace-defaults fails
|
|
const FALLBACK_SOUL = `# AI Assistant
|
|
|
|
You are a helpful AI assistant for {company}. You are professional, concise, and friendly.
|
|
|
|
## Guidelines
|
|
- Answer questions accurately and helpfully
|
|
- If you don't know something, say so
|
|
- Keep responses clear and to the point
|
|
- Respect privacy and confidentiality
|
|
`;
|
|
|
|
const FALLBACK_AGENTS = `# Agents
|
|
|
|
On session start, read the following workspace files in order:
|
|
1. SOUL.md — your personality and behavioural guidelines
|
|
2. TOOLS.md — available tools and how to use them
|
|
3. USER.md — information about the current user (if present)
|
|
|
|
Follow the instructions in SOUL.md for every interaction.
|
|
`;
|
|
|
|
const FALLBACK_TOOLS = `# Tools
|
|
|
|
The following tools are available to you as an AI assistant.
|
|
|
|
## LLM
|
|
You have access to a large language model for text generation, summarisation,
|
|
translation, and general question answering.
|
|
`;
|
|
|
|
const CATEGORIES = [
|
|
{ key: "core" as const, labelKey: "categories.core" },
|
|
{ key: "channel" as const, labelKey: "categories.channels" },
|
|
{ key: "skill" as const, labelKey: "categories.skills" },
|
|
] as const;
|
|
|
|
interface WizardProps {
|
|
orgName: string;
|
|
/**
|
|
* The user's display name. Used as the visible label for personal
|
|
* accounts (where `orgName` is an opaque ID like "personal-3f2a8b1c"
|
|
* or a synthetic legacy "{name} (Personal)" string). Ignored for
|
|
* company accounts.
|
|
*/
|
|
userName?: string;
|
|
userEmail?: string;
|
|
/**
|
|
* Bug 35: when true, the wizard skips the billing step. The org
|
|
* already has billing on file (captured during a previous tenant's
|
|
* onboarding, or set directly via /settings/billing), and we don't
|
|
* re-prompt for it. The submit payload omits billingAddress in that
|
|
* case; the API picks up the existing record server-side.
|
|
*
|
|
* In edit mode this is ignored — the wizard re-renders the step
|
|
* with the request's original billingAddress so the customer can
|
|
* fix it before admin approves.
|
|
*/
|
|
hasOrgBilling?: boolean;
|
|
/**
|
|
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
|
* are pre-populated from the request, the SOUL.md auto-fetch is
|
|
* skipped (we trust the existing values), and the submit button
|
|
* PATCHes /api/onboarding/[id] instead of POSTing /api/onboarding.
|
|
*
|
|
* Per-package secrets are deliberately NOT pre-filled, even if the
|
|
* customer originally supplied them — server-side decryption to
|
|
* the client would be a security regression. The user re-enters
|
|
* any secrets they want to change; if they leave them blank, the
|
|
* existing encrypted blob in the DB is preserved by the PATCH
|
|
* endpoint.
|
|
*/
|
|
editingRequest?: {
|
|
id: string;
|
|
instanceName: string;
|
|
agentName: string;
|
|
soulMd: string;
|
|
agentsMd: string;
|
|
packages: string[];
|
|
billingAddress: {
|
|
company?: string;
|
|
street?: string;
|
|
city?: string;
|
|
postalCode?: string;
|
|
country?: string;
|
|
vatNumber?: string;
|
|
};
|
|
billingNotes: string;
|
|
};
|
|
onComplete: () => void;
|
|
}
|
|
|
|
export function OnboardingWizard({
|
|
orgName,
|
|
userName,
|
|
userEmail,
|
|
hasOrgBilling,
|
|
editingRequest,
|
|
onComplete,
|
|
}: WizardProps) {
|
|
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
|
|
// "personal-{8hex}" form. Either way, the customer-facing display
|
|
// should be the user's own name — never the org name. SOUL.md
|
|
// interpolation and the billing form follow the same rule so
|
|
// invoices and prompts don't leak "(Personal)" or "personal-3f2a..".
|
|
const isPersonal = isPersonalOrgName(orgName);
|
|
const displayOrgName = displayOrgNameFor({
|
|
name: userName,
|
|
email: userEmail,
|
|
orgName,
|
|
isPersonal,
|
|
});
|
|
const isEditing = Boolean(editingRequest);
|
|
// STEPS is recomputed from props so toggling hasOrgBilling at the
|
|
// server level (e.g. between renders if the customer just saved
|
|
// billing on /settings/billing in another tab) flows through. Cheap.
|
|
const STEPS = makeSteps({
|
|
hasOrgBilling: Boolean(hasOrgBilling),
|
|
isEditing,
|
|
});
|
|
|
|
// Edit mode jumps straight to the configure step — the welcome step
|
|
// is a first-time onboarding affordance and only adds friction when
|
|
// the customer is fixing a typo.
|
|
const [step, setStep] = useState<Step>(isEditing ? "configure" : "welcome");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
// In edit mode we already have soulMd/agentsMd from the request;
|
|
// skip the workspace-defaults round trip that would overwrite them.
|
|
const [defaultsLoaded, setDefaultsLoaded] = useState(isEditing);
|
|
|
|
const [config, setConfig] = useState(() => {
|
|
if (editingRequest) {
|
|
return {
|
|
instanceName: editingRequest.instanceName,
|
|
agentName: editingRequest.agentName,
|
|
soulMd: editingRequest.soulMd,
|
|
agentsMd: editingRequest.agentsMd,
|
|
packages: editingRequest.packages,
|
|
billingAddress: {
|
|
company: editingRequest.billingAddress.company ?? "",
|
|
street: editingRequest.billingAddress.street ?? "",
|
|
city: editingRequest.billingAddress.city ?? "",
|
|
postalCode: editingRequest.billingAddress.postalCode ?? "",
|
|
country: editingRequest.billingAddress.country ?? "CH",
|
|
vatNumber: editingRequest.billingAddress.vatNumber ?? "",
|
|
},
|
|
billingNotes: editingRequest.billingNotes,
|
|
};
|
|
}
|
|
return {
|
|
instanceName: "",
|
|
agentName: "Assistant",
|
|
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
|
|
agentsMd: FALLBACK_AGENTS,
|
|
// CORE defaults: heartbeat + cron + active-memory pre-selected so
|
|
// the assistant can be proactive, run scheduled tasks, and recall
|
|
// stable context out of the box. Customers can untoggle any of
|
|
// them before submitting. core-voice is fully wired (Phase B)
|
|
// but stays unselected — opt-in keeps audio spend predictable
|
|
// for tenants who don't intend to use voice channels.
|
|
packages: [...DEFAULT_PACKAGE_IDS] as string[],
|
|
billingAddress: {
|
|
// For personal accounts, leave the company field empty — it'll
|
|
// appear on invoices. The user can still type something if they
|
|
// want to.
|
|
company: isPersonal ? "" : displayOrgName,
|
|
street: "",
|
|
city: "",
|
|
postalCode: "",
|
|
country: "CH",
|
|
vatNumber: "",
|
|
},
|
|
billingNotes: "",
|
|
};
|
|
});
|
|
|
|
// TOOLS.md preview — readonly, auto-generated
|
|
const [toolsMdPreview, setToolsMdPreview] = useState(FALLBACK_TOOLS);
|
|
|
|
// Per-package collected secrets: { "telegram": { "bot-token": "123:ABC" }, ... }
|
|
const [packageSecrets, setPackageSecrets] = useState<
|
|
Record<string, Record<string, string>>
|
|
>({});
|
|
// Per-package disclaimer acceptance
|
|
const [disclaimerAccepted, setDisclaimerAccepted] = useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
|
|
// Fetch DB-stored defaults on mount
|
|
useEffect(() => {
|
|
fetch("/api/workspace-defaults")
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((data) => {
|
|
if (data) {
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
soulMd: data.soulMd ?? prev.soulMd,
|
|
agentsMd: data.agentsMd ?? prev.agentsMd,
|
|
}));
|
|
setToolsMdPreview(data.toolsMd ?? FALLBACK_TOOLS);
|
|
setDefaultsLoaded(true);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
/* use inline fallbacks */
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Re-fetch TOOLS.md preview when packages change
|
|
const packagesKey = config.packages.sort().join(",");
|
|
const prevPackagesKey = useRef(packagesKey);
|
|
useEffect(() => {
|
|
if (prevPackagesKey.current === packagesKey && defaultsLoaded) return;
|
|
prevPackagesKey.current = packagesKey;
|
|
fetch(
|
|
`/api/workspace-defaults?packages=${encodeURIComponent(packagesKey)}`
|
|
)
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((data) => {
|
|
if (data?.toolsMd) setToolsMdPreview(data.toolsMd);
|
|
})
|
|
.catch(() => {});
|
|
}, [packagesKey, defaultsLoaded]);
|
|
|
|
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]);
|
|
};
|
|
|
|
const togglePackage = useCallback((pkgId: string) => {
|
|
setConfig((prev) => {
|
|
const removing = prev.packages.includes(pkgId);
|
|
if (removing) {
|
|
setPackageSecrets((s) => {
|
|
const next = { ...s };
|
|
delete next[pkgId];
|
|
return next;
|
|
});
|
|
setDisclaimerAccepted((d) => {
|
|
const next = { ...d };
|
|
delete next[pkgId];
|
|
return next;
|
|
});
|
|
}
|
|
return {
|
|
...prev,
|
|
packages: removing
|
|
? prev.packages.filter((p) => p !== pkgId)
|
|
: [...prev.packages, pkgId],
|
|
};
|
|
});
|
|
}, []);
|
|
|
|
const updateSecret = useCallback(
|
|
(pkgId: string, key: string, value: string) => {
|
|
setPackageSecrets((prev) => ({
|
|
...prev,
|
|
[pkgId]: { ...(prev[pkgId] || {}), [key]: value },
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
// Validate that all secret-requiring enabled packages have complete credentials
|
|
const packageCredentialsValid = (): boolean => {
|
|
for (const pkgId of config.packages) {
|
|
const def = PACKAGE_CATALOG.find((p) => p.id === pkgId);
|
|
if (!def?.requiresSecrets) continue;
|
|
const secrets = packageSecrets[pkgId] || {};
|
|
for (const field of def.secrets || []) {
|
|
if (!secrets[field.key]?.trim()) return false;
|
|
}
|
|
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
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("");
|
|
|
|
try {
|
|
// Build secrets payload — only for packages that require them
|
|
const secretsPayload: Record<string, Record<string, string>> = {};
|
|
for (const pkgId of config.packages) {
|
|
const def = PACKAGE_CATALOG.find((p) => p.id === pkgId);
|
|
if (def?.requiresSecrets && packageSecrets[pkgId]) {
|
|
secretsPayload[pkgId] = packageSecrets[pkgId];
|
|
}
|
|
}
|
|
|
|
// Bug 6: edit mode targets the per-row endpoint with PATCH;
|
|
// create mode targets the collection endpoint with POST. Body
|
|
// shape is the same — both routes parse it through
|
|
// onboardingSchema.
|
|
const url = editingRequest
|
|
? `/api/onboarding/${encodeURIComponent(editingRequest.id)}`
|
|
: "/api/onboarding";
|
|
const method = editingRequest ? "PATCH" : "POST";
|
|
|
|
// Bug 35: when the org already has billing on file, the wizard
|
|
// skipped the billing step and `config.billingAddress` is the
|
|
// empty default. Strip it from the payload so the API picks up
|
|
// the existing org_billing record server-side rather than
|
|
// validating the empty form against billingStepSchema (which
|
|
// would reject for a company org).
|
|
const submitConfig = hasOrgBilling
|
|
? (() => {
|
|
const { billingAddress: _bill, billingNotes: _notes, ...rest } =
|
|
config;
|
|
return rest;
|
|
})()
|
|
: config;
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
...submitConfig,
|
|
packageSecrets:
|
|
Object.keys(secretsPayload).length > 0
|
|
? secretsPayload
|
|
: undefined,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error || "Submission failed");
|
|
}
|
|
|
|
onComplete();
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Step indicator
|
|
const StepIndicator = () => (
|
|
<div className="flex items-center justify-center gap-2 mb-8">
|
|
{STEPS.map((s, i) => (
|
|
<div key={s} className="flex items-center gap-2">
|
|
<div
|
|
className={`h-2 w-2 rounded-full transition-colors ${
|
|
i <= stepIndex ? "bg-accent" : "bg-border"
|
|
}`}
|
|
/>
|
|
{i < STEPS.length - 1 && (
|
|
<div
|
|
className={`h-px w-8 transition-colors ${
|
|
i < stepIndex ? "bg-accent" : "bg-border"
|
|
}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="max-w-xl mx-auto">
|
|
<StepIndicator />
|
|
|
|
{/* Step: Welcome */}
|
|
{step === "welcome" && (
|
|
<Card className="animate-in">
|
|
<div className="text-center py-4">
|
|
<div className="h-14 w-14 rounded-xl bg-accent/15 flex items-center justify-center mx-auto mb-4">
|
|
<svg
|
|
className="h-7 w-7 text-accent"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={1.5}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<h2 className="font-display text-xl font-semibold text-text-primary mb-2">
|
|
{t("welcomeTitle")}
|
|
</h2>
|
|
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-6">
|
|
{t("welcomeDescription")}
|
|
</p>
|
|
<div className="space-y-2 text-left max-w-sm mx-auto mb-6">
|
|
{["swissHosted", "privacy", "customizable"].map((key) => (
|
|
<div key={key} className="flex items-start gap-2">
|
|
<span className="text-accent mt-0.5 text-sm">✓</span>
|
|
<span className="text-sm text-text-secondary">
|
|
{t(`welcomeFeature_${key}`)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={goNext}
|
|
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
|
>
|
|
{t("getStarted")}
|
|
</button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Step: Configure */}
|
|
{step === "configure" && (
|
|
<Card className="animate-in">
|
|
<h2 className="font-display text-lg font-semibold text-text-primary mb-1">
|
|
{t("configureTitle")}
|
|
</h2>
|
|
<p className="text-sm text-text-secondary mb-6">
|
|
{t("configureDescription")}
|
|
</p>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
|
{t("instanceName")}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={config.instanceName}
|
|
onChange={(e) =>
|
|
setConfig((prev) => ({ ...prev, instanceName: e.target.value }))
|
|
}
|
|
placeholder={t("instanceNamePlaceholder")}
|
|
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"
|
|
/>
|
|
<p className="text-xs text-text-muted mt-1">
|
|
{t("instanceNameHint")}
|
|
</p>
|
|
</div>
|
|
|
|
<FieldWithError error={errors.agentName}>
|
|
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
|
{t("agentName")} <RequiredMark />
|
|
</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={config.agentName}
|
|
onChange={(e) => {
|
|
clearError("agentName");
|
|
setConfig((prev) => ({ ...prev, agentName: e.target.value }));
|
|
}}
|
|
className={inputClass(errors.agentName)}
|
|
/>
|
|
</FieldWithError>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
|
{t("soulMd")}
|
|
</label>
|
|
<textarea
|
|
value={config.soulMd}
|
|
onChange={(e) =>
|
|
setConfig((prev) => ({ ...prev, soulMd: e.target.value }))
|
|
}
|
|
rows={8}
|
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary font-mono text-xs focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-y"
|
|
/>
|
|
<p className="text-xs text-text-muted mt-1">
|
|
{t("soulMdHint")}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Advanced: AGENTS.md + TOOLS.md preview */}
|
|
<div className="border border-border rounded-lg overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setAdvancedOpen((o) => !o)}
|
|
className="w-full flex items-center justify-between px-3 py-2.5 text-left hover:bg-surface-3/30 transition-colors"
|
|
>
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
{t("advancedConfig")}
|
|
</span>
|
|
<svg
|
|
className={`h-4 w-4 text-text-muted transition-transform ${
|
|
advancedOpen ? "rotate-180" : ""
|
|
}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M19 9l-7 7-7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{advancedOpen && (
|
|
<div className="border-t border-border px-3 py-4 space-y-4 bg-surface-1/30">
|
|
{/* AGENTS.md */}
|
|
<div>
|
|
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
|
{t("agentsMd")}
|
|
</label>
|
|
<textarea
|
|
value={config.agentsMd}
|
|
onChange={(e) =>
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
agentsMd: e.target.value,
|
|
}))
|
|
}
|
|
rows={6}
|
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary font-mono text-xs focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-y"
|
|
/>
|
|
<p className="text-xs text-text-muted mt-1">
|
|
{t("agentsMdHint")}
|
|
</p>
|
|
</div>
|
|
|
|
{/* TOOLS.md — readonly preview */}
|
|
<div>
|
|
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
|
{t("toolsMd")}
|
|
</label>
|
|
<textarea
|
|
value={toolsMdPreview}
|
|
readOnly
|
|
rows={6}
|
|
className="w-full px-3 py-2 bg-surface-3/50 border border-border rounded-lg text-sm text-text-secondary font-mono text-xs cursor-not-allowed resize-y"
|
|
/>
|
|
<p className="text-xs text-text-muted mt-1">
|
|
{t("toolsMdHint")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Packages — grouped by category */}
|
|
<div>
|
|
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-2">
|
|
{t("packages")}
|
|
</label>
|
|
|
|
{CATEGORIES.map(({ key, labelKey }) => {
|
|
const packages = PACKAGE_CATALOG.filter(
|
|
(p) => p.category === key
|
|
);
|
|
if (packages.length === 0) return null;
|
|
|
|
return (
|
|
<div key={key} className="mb-4">
|
|
<h4 className="text-[10px] font-semibold uppercase tracking-wider text-text-muted/70 mb-1.5">
|
|
{tPkg(labelKey)}
|
|
</h4>
|
|
<div className="space-y-2">
|
|
{packages.map((pkg) => {
|
|
const isSelected = config.packages.includes(pkg.id);
|
|
const secrets = packageSecrets[pkg.id] || {};
|
|
|
|
return (
|
|
<div
|
|
key={pkg.id}
|
|
className={`border rounded-lg overflow-hidden transition-colors ${
|
|
isSelected
|
|
? "border-accent bg-accent/5"
|
|
: "border-border bg-surface-2"
|
|
}`}
|
|
>
|
|
{/* Toggle row */}
|
|
<button
|
|
type="button"
|
|
onClick={() => togglePackage(pkg.id)}
|
|
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors cursor-pointer hover:bg-surface-3/30"
|
|
>
|
|
<div className="text-left">
|
|
<span
|
|
className={`text-sm font-medium ${
|
|
isSelected
|
|
? "text-accent"
|
|
: "text-text-secondary"
|
|
}`}
|
|
>
|
|
{pkg.name}
|
|
</span>
|
|
{pkg.requiresSecrets && (
|
|
<span className="ml-1.5 text-[10px] text-text-muted">
|
|
({tPkg("requiresApiKey")})
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={`shrink-0 ml-3 h-5 w-9 rounded-full transition-colors ${
|
|
isSelected ? "bg-accent" : "bg-surface-3"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`h-4 w-4 rounded-full bg-white shadow-sm mt-0.5 transition-transform ${
|
|
isSelected
|
|
? "translate-x-4"
|
|
: "translate-x-0.5"
|
|
}`}
|
|
/>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Inline credential inputs — expand when selected + requires secrets */}
|
|
{isSelected && pkg.requiresSecrets && (
|
|
<div className="border-t border-border px-3 py-3 space-y-3 bg-surface-1/50">
|
|
{pkg.instructionsKey && (
|
|
<div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
|
{tPkg(
|
|
pkg.instructionsKey.replace(
|
|
"packages.",
|
|
""
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{(pkg.secrets || []).map((field) => (
|
|
<label key={field.key} className="block">
|
|
<span className="text-xs text-text-secondary mb-1 block">
|
|
{tPkg(
|
|
field.labelKey.replace("packages.", "")
|
|
)}
|
|
</span>
|
|
<input
|
|
type="password"
|
|
placeholder={tPkg(
|
|
field.placeholderKey.replace(
|
|
"packages.",
|
|
""
|
|
)
|
|
)}
|
|
value={secrets[field.key] || ""}
|
|
onChange={(e) =>
|
|
updateSecret(
|
|
pkg.id,
|
|
field.key,
|
|
e.target.value
|
|
)
|
|
}
|
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
|
/>
|
|
</label>
|
|
))}
|
|
|
|
{pkg.disclaimerKey && (
|
|
<label className="flex items-start gap-2 text-xs text-text-secondary">
|
|
<input
|
|
type="checkbox"
|
|
checked={
|
|
disclaimerAccepted[pkg.id] || false
|
|
}
|
|
onChange={(e) =>
|
|
setDisclaimerAccepted((prev) => ({
|
|
...prev,
|
|
[pkg.id]: e.target.checked,
|
|
}))
|
|
}
|
|
className="mt-0.5 accent-accent"
|
|
/>
|
|
<span>
|
|
{tPkg(
|
|
pkg.disclaimerKey.replace(
|
|
"packages.",
|
|
""
|
|
)
|
|
)}
|
|
</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<p className="text-xs text-text-muted mt-1">
|
|
{t("packagesHint")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between mt-6">
|
|
<button
|
|
onClick={goBack}
|
|
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
|
>
|
|
{t("back")}
|
|
</button>
|
|
<button
|
|
onClick={goNext}
|
|
disabled={!packageCredentialsValid()}
|
|
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{t("next")}
|
|
</button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Step: Billing */}
|
|
{step === "billing" && (
|
|
<Card className="animate-in">
|
|
<h2 className="font-display text-lg font-semibold text-text-primary mb-1">
|
|
{t("billingTitle")}
|
|
</h2>
|
|
<p className="text-sm text-text-secondary mb-6">
|
|
{t("billingDescription")}
|
|
</p>
|
|
|
|
<div className="space-y-4">
|
|
{/* 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>
|
|
)}
|
|
|
|
<FieldWithError error={errors["billingAddress.street"]}>
|
|
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
|
{t("billingStreet")} <RequiredMark />
|
|
</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={config.billingAddress.street}
|
|
onChange={(e) => {
|
|
clearError("billingAddress.street");
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
billingAddress: {
|
|
...prev.billingAddress,
|
|
street: e.target.value,
|
|
},
|
|
}));
|
|
}}
|
|
className={inputClass(errors["billingAddress.street"])}
|
|
/>
|
|
</FieldWithError>
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<FieldWithError error={errors["billingAddress.postalCode"]}>
|
|
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
|
{t("billingPostalCode")} <RequiredMark />
|
|
</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={config.billingAddress.postalCode}
|
|
onChange={(e) => {
|
|
clearError("billingAddress.postalCode");
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
billingAddress: {
|
|
...prev.billingAddress,
|
|
postalCode: e.target.value,
|
|
},
|
|
}));
|
|
}}
|
|
className={inputClass(errors["billingAddress.postalCode"])}
|
|
/>
|
|
</FieldWithError>
|
|
<div className="col-span-2">
|
|
<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>
|
|
|
|
{/* 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")} <RequiredMark />
|
|
</label>
|
|
<select
|
|
value={config.billingAddress.country}
|
|
onChange={(e) => {
|
|
clearError("billingAddress.country");
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
billingAddress: {
|
|
...prev.billingAddress,
|
|
country: e.target.value as SupportedCountry,
|
|
},
|
|
}));
|
|
}}
|
|
className={inputClass(errors["billingAddress.country"])}
|
|
>
|
|
{SUPPORTED_COUNTRIES.map((code) => (
|
|
<option key={code} value={code}>
|
|
{tCountries(code)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</FieldWithError>
|
|
|
|
{/* Bug 35: VAT identifier. Required for company customers
|
|
(B2B). Hidden entirely for personal customers (B2C —
|
|
private individuals don't have a VAT number); the API
|
|
enforces the same rule. Editable later via
|
|
/settings/billing for company customers if their VAT
|
|
id changes. */}
|
|
{!isPersonal && (
|
|
<FieldWithError error={errors["billingAddress.vatNumber"]}>
|
|
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
|
{t("billingVatNumber")} <RequiredMark />
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={config.billingAddress.vatNumber ?? ""}
|
|
onChange={(e) => {
|
|
clearError("billingAddress.vatNumber");
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
billingAddress: {
|
|
...prev.billingAddress,
|
|
vatNumber: e.target.value,
|
|
},
|
|
}));
|
|
}}
|
|
placeholder="CHE-123.456.789 MWST"
|
|
className={inputClass(errors["billingAddress.vatNumber"])}
|
|
/>
|
|
<p className="text-xs text-text-muted mt-1">
|
|
{t("billingVatHelp")}
|
|
</p>
|
|
</FieldWithError>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
|
{t("billingNotes")}
|
|
</label>
|
|
<textarea
|
|
value={config.billingNotes}
|
|
onChange={(e) =>
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
billingNotes: e.target.value,
|
|
}))
|
|
}
|
|
rows={3}
|
|
placeholder={t(
|
|
isPersonal
|
|
? "billingNotesPlaceholderPersonal"
|
|
: "billingNotesPlaceholder"
|
|
)}
|
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-y"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between mt-6">
|
|
<button
|
|
onClick={goBack}
|
|
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
|
>
|
|
{t("back")}
|
|
</button>
|
|
<button
|
|
onClick={goNext}
|
|
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
|
>
|
|
{t("next")}
|
|
</button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Step: Confirm */}
|
|
{step === "confirm" && (
|
|
<Card className="animate-in">
|
|
<h2 className="font-display text-lg font-semibold text-text-primary mb-1">
|
|
{t("confirmTitle")}
|
|
</h2>
|
|
<p className="text-sm text-text-secondary mb-6">
|
|
{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 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>
|
|
}
|
|
/>
|
|
{/* Bug 35: VAT review row. Company customers see this so
|
|
they can verify the VAT id they typed before submitting.
|
|
Personal customers never see it — they don't have a
|
|
VAT number, the form didn't ask, the review hides it. */}
|
|
{!isPersonal &&
|
|
config.billingAddress.vatNumber &&
|
|
config.billingAddress.vatNumber.trim().length > 0 && (
|
|
<ReviewRow
|
|
label={t("billingVatNumber")}
|
|
value={config.billingAddress.vatNumber}
|
|
mono
|
|
/>
|
|
)}
|
|
<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>
|
|
|
|
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mt-4">
|
|
{error}
|
|
</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}
|
|
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
|
>
|
|
{t("back")}
|
|
</button>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={submitting}
|
|
className="py-2.5 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{submitting
|
|
? tCommon("loading")
|
|
: isEditing
|
|
? t("saveChanges")
|
|
: t("submitRequest")}
|
|
</button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</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"
|
|
}`;
|
|
}
|