Session 6.3

This commit is contained in:
2026-04-10 21:56:31 +02:00
parent f20d5f09ae
commit 94bfd25553
24 changed files with 2398 additions and 104 deletions

View File

@@ -0,0 +1,31 @@
"use client";
import { useState, useEffect } from "react";
import { OnboardingWizard } from "./wizard";
import { ProvisioningStatus } from "./provisioning-status";
interface OnboardingFlowProps {
orgName: string;
initialState: "no_request" | "pending" | "approved" | "provisioning" | "rejected";
}
/**
* Orchestrates the onboarding experience:
* - no_request → show wizard
* - pending/approved/provisioning/rejected → show status
* - After wizard submission → switch to status polling
*/
export function OnboardingFlow({ orgName, initialState }: OnboardingFlowProps) {
const [showWizard, setShowWizard] = useState(initialState === "no_request");
if (showWizard) {
return (
<OnboardingWizard
orgName={orgName}
onComplete={() => setShowWizard(false)}
/>
);
}
return <ProvisioningStatus />;
}

View File

@@ -0,0 +1,240 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge";
interface OnboardingState {
state: string;
request?: {
id: string;
status: string;
companyName: string;
agentName: string;
adminNotes?: string;
};
tenant?: {
name: string;
phase: string;
message?: string;
conditions?: Array<{
type: string;
status: string;
reason?: string;
message?: string;
lastTransitionTime?: string;
}>;
};
}
export function ProvisioningStatus() {
const t = useTranslations("onboarding");
const [data, setData] = useState<OnboardingState | null>(null);
const [error, setError] = useState("");
const poll = useCallback(async () => {
try {
const res = await fetch("/api/onboarding");
if (!res.ok) throw new Error("Failed to fetch status");
const json = await res.json();
setData(json);
} catch (err: any) {
setError(err.message);
}
}, []);
useEffect(() => {
poll();
// Poll every 5 seconds while not in a terminal state
const interval = setInterval(() => {
if (
data?.state === "provisioned" ||
data?.state === "rejected" ||
data?.state === "active"
) {
return;
}
poll();
}, 5000);
return () => clearInterval(interval);
}, [poll, data?.state]);
if (error) {
return (
<Card>
<div className="text-xs text-red-400">{error}</div>
</Card>
);
}
if (!data) {
return (
<Card>
<div className="flex items-center gap-2 text-sm text-text-muted">
<div className="h-4 w-4 border-2 border-accent border-t-transparent rounded-full animate-spin" />
{t("loading")}
</div>
</Card>
);
}
// Pending admin approval
if (data.state === "pending") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-amber-500/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("pendingTitle")}
</h2>
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("pendingDescription")}
</p>
</div>
</Card>
);
}
// Rejected
if (data.state === "rejected") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-red-500/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("rejectedTitle")}
</h2>
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("rejectedDescription")}
</p>
{data.request?.adminNotes && (
<p className="text-xs text-text-muted mt-3 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
{data.request.adminNotes}
</p>
)}
</div>
</Card>
);
}
// Provisioning in progress
if (
data.state === "approved" ||
data.state === "provisioning"
) {
const phase = data.tenant?.phase ?? "Pending";
const conditions = data.tenant?.conditions ?? [];
return (
<Card className="animate-in">
<div className="text-center py-4 mb-4">
<div className="h-14 w-14 rounded-xl bg-accent/15 flex items-center justify-center mx-auto mb-4">
<div className="h-6 w-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("provisioningTitle")}
</h2>
<p className="text-sm text-text-secondary">
{t("provisioningDescription")}
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between bg-surface-2 border border-border rounded-lg px-4 py-2">
<span className="text-xs text-text-muted">{t("phase")}</span>
<StatusBadge phase={phase} />
</div>
{conditions.map((c, i) => (
<div
key={i}
className="flex items-center justify-between bg-surface-2 border border-border rounded-lg px-4 py-2"
>
<span className="text-xs text-text-muted">{c.type}</span>
<span
className={`text-xs font-mono ${
c.status === "True"
? "text-emerald-400"
: c.status === "False"
? "text-red-400"
: "text-text-muted"
}`}
>
{c.reason || c.status}
</span>
</div>
))}
</div>
</Card>
);
}
// Provisioned / Running
if (data.state === "provisioned") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-emerald-500/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-emerald-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("readyTitle")}
</h2>
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
{t("readyDescription")}
</p>
<button
onClick={() => window.location.reload()}
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
{t("goToDashboard")}
</button>
</div>
</Card>
);
}
return null;
}

View File

@@ -0,0 +1,488 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
type Step = "welcome" | "configure" | "billing" | "confirm";
const STEPS: Step[] = ["welcome", "configure", "billing", "confirm"];
const DEFAULT_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 AVAILABLE_PACKAGES = [
"telegram",
"discord",
"email",
"web-search",
"document-processing",
];
interface WizardProps {
orgName: string;
onComplete: () => void;
}
export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
const t = useTranslations("onboarding");
const tCommon = useTranslations("common");
const [step, setStep] = useState<Step>("welcome");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [config, setConfig] = useState({
agentName: "Assistant",
soulMd: DEFAULT_SOUL.replace("{company}", orgName),
packages: [] as string[],
billingAddress: {
company: orgName,
street: "",
city: "",
postalCode: "",
country: "CH",
},
billingNotes: "",
});
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 = (pkg: string) => {
setConfig((prev) => ({
...prev,
packages: prev.packages.includes(pkg)
? prev.packages.filter((p) => p !== pkg)
: [...prev.packages, pkg],
}));
};
const handleSubmit = async () => {
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
});
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("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>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-2">
{t("packages")}
</label>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_PACKAGES.map((pkg) => (
<button
key={pkg}
type="button"
onClick={() => togglePackage(pkg)}
className={`text-left px-3 py-2 border rounded-lg text-xs transition-colors ${
config.packages.includes(pkg)
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-text-secondary hover:border-accent/40"
}`}
>
{pkg}
</button>
))}
</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}
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: 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">
<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.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>
);
}