Files
pieced-portal/src/components/onboarding/wizard.tsx
admin 3521a0ff4f
All checks were successful
Build and Push / build (push) Successful in 1m30s
Personal accounts
2026-04-26 22:26:33 +02:00

861 lines
33 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, type PackageDef } from "@/lib/packages";
import { isPersonalOrgName, PERSONAL_ORG_SUFFIX } from "@/lib/personal-org";
type Step = "welcome" | "configure" | "billing" | "confirm";
const STEPS: Step[] = ["welcome", "configure", "billing", "confirm"];
// 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: "channel" as const, labelKey: "categories.channels" },
{ key: "skill" as const, labelKey: "categories.skills" },
] as const;
interface WizardProps {
orgName: string;
onComplete: () => void;
}
export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
const t = useTranslations("onboarding");
const tPkg = useTranslations("packages");
const tCommon = useTranslations("common");
// Slice 4: personal accounts have an org name of the form
// "{givenName} {familyName} (Personal)". For SOUL.md and the billing
// company line, strip the suffix so the visible string is the user's
// actual name (no stray "(Personal)" leaking onto invoices or into
// the assistant's prompt).
const isPersonal = isPersonalOrgName(orgName);
const displayOrgName = isPersonal
? orgName.slice(0, -PERSONAL_ORG_SUFFIX.length).trim()
: orgName;
const [step, setStep] = useState<Step>("welcome");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [advancedOpen, setAdvancedOpen] = useState(false);
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
const [config, setConfig] = useState({
instanceName: "",
agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
agentsMd: FALLBACK_AGENTS,
packages: [] 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",
},
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);
const goNext = () => {
if (stepIndex < STEPS.length - 1) setStep(STEPS[stepIndex + 1]);
};
const goBack = () => {
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 () => {
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];
}
}
const res = await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...config,
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>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("agentName")}
</label>
<input
type="text"
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"
/>
</div>
<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 cursor-pointer hover:bg-surface-3/30 transition-colors"
>
<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">
<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>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingStreet")}
</label>
<input
type="text"
value={config.billingAddress.street}
onChange={(e) =>
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"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingPostalCode")}
</label>
<input
type="text"
value={config.billingAddress.postalCode}
onChange={(e) =>
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"
/>
</div>
<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"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCountry")}
</label>
<input
type="text"
value={config.billingAddress.country}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
country: 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>
<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("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>
<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>
</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>
)}
</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>
)}
<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") : t("submitRequest")}
</button>
</div>
</Card>
)}
</div>
);
}