Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07, B-01..B-07, R-01..R-02, O-03. Security (28 deletions + 10 auth gates): - Delete 28 unauthenticated debug/cursor/firebase/test routes - Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth - Add HMAC-SHA256 signature verification to webhooks/coolify - Switch all admin secret comparisons to timingSafeStringEq Foundations (lib/server/*): - api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit - logger.ts: structured request-scoped logging with turnId - audit-log.ts: writeAuditLog helper + audit_log table - rate-limit.ts: Postgres sliding window rate limiter - coolify-webhook.ts: verifyCoolifySignature - timing-safe.ts: timingSafeStringEq Chat hardening (chat/route.ts): - MAX_TOOL_ROUNDS 15 → 8 (C-01) - Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02) - Add 6-consecutive-tool-call hard-break (C-02) - Mode: respond first, act second prompt block (C-03) - SSE heartbeat every 25s via setInterval (C-04) - Per-tool 45s timeout via Promise.race (C-05) - turnId per-turn UUID for log correlation (C-06) - Recovery fires when roundsSinceText >= 4 (C-07) - SSE plan event on plan_task_add/edit (B-05) Beta features: - invites table + GET/POST /api/invites (P4.8) - invites/[token] validate + redeem (P4.8) - fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1) - fs_project_secrets table + CRUD routes (P6.D2) - lib/integrations/brief-extract.ts (P3.7) Documentation: - app/api/ROUTES.md: full route map with auth + tenant
295 lines
10 KiB
JavaScript
295 lines
10 KiB
JavaScript
// Consultant path — 4 steps for freelancers building for a client.
|
|
|
|
const CONS_TOTAL = 4;
|
|
const CONS_STEP_NAMES = ["Client", "Brief", "Scope", "Handoff"];
|
|
|
|
function ConsClient({ clientName, industry, contact, onChange }) {
|
|
return (
|
|
<>
|
|
<WizardQ
|
|
title="Who are you building for?"
|
|
sub="Used to brand the preview and the handoff doc."
|
|
/>
|
|
<Field label="Client / company">
|
|
<input
|
|
className="wiz-input"
|
|
placeholder="Rivera & Co Roofing"
|
|
value={clientName}
|
|
onChange={(e) => onChange({ clientName: e.target.value })}
|
|
autoFocus
|
|
/>
|
|
</Field>
|
|
<Field label="What they do">
|
|
<input
|
|
className="wiz-input"
|
|
placeholder="Residential roofing, mostly insurance jobs"
|
|
value={industry}
|
|
onChange={(e) => onChange({ industry: e.target.value })}
|
|
/>
|
|
</Field>
|
|
<Field label="Your point of contact" optional hint="So Vibn can address them in the handoff.">
|
|
<input
|
|
className="wiz-input"
|
|
placeholder="Marisol Rivera, Owner"
|
|
value={contact}
|
|
onChange={(e) => onChange({ contact: e.target.value })}
|
|
/>
|
|
</Field>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const BRIEF_TEMPLATES = [
|
|
{ id: "quote_tool", label: "Quote tool",
|
|
body: "Customers request a quote with a few photos and a project description. The team reviews, sends a polished PDF, customer signs and pays a deposit online." },
|
|
{ id: "booking", label: "Booking system",
|
|
body: "Customers see real availability, book a service window, and get reminders. The team has a daily view of jobs with addresses and contact info." },
|
|
{ id: "portal", label: "Customer portal",
|
|
body: "Logged-in customers see past jobs, invoices, documents, and can message the business. The business sees a single page per customer." },
|
|
{ id: "internal", label: "Internal ops",
|
|
body: "Replace the spreadsheets the team is currently using. CRUD on jobs/customers, simple reports, role-based access, export to accounting." },
|
|
];
|
|
|
|
function ConsBrief({ brief, onChange }) {
|
|
return (
|
|
<>
|
|
<WizardQ
|
|
title="What did they ask for?"
|
|
sub="Paste the brief, or start from a template and edit."
|
|
/>
|
|
<Field label="Start from a template" optional>
|
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
{BRIEF_TEMPLATES.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
className="preset-chip"
|
|
style={{ padding: "8px 14px", fontSize: 13 }}
|
|
onClick={() => onChange(t.body)}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Field>
|
|
<Field label="Brief">
|
|
<textarea
|
|
className="wiz-input"
|
|
style={{ minHeight: 160 }}
|
|
placeholder="The client wants…"
|
|
value={brief}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
/>
|
|
</Field>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const SCOPE_GROUPS = [
|
|
{ label: "Foundations", items: ["Auth & accounts", "Roles & permissions", "Database & admin", "Hosting & domain"] },
|
|
{ label: "Customer-facing", items: ["Marketing site", "Booking / scheduling", "Quote requests", "Customer portal", "Self-serve forms"] },
|
|
{ label: "Money", items: ["Stripe / payments", "Invoices & receipts", "Subscriptions", "Refunds & disputes"] },
|
|
{ label: "Ops", items: ["Dashboards & reports", "CSV import / export", "Email + SMS notifs", "Audit log"] },
|
|
];
|
|
|
|
function ConsScope({ scope, onChange }) {
|
|
const toggle = (item) => {
|
|
if (scope.includes(item)) onChange(scope.filter((x) => x !== item));
|
|
else onChange([...scope, item]);
|
|
};
|
|
return (
|
|
<>
|
|
<WizardQ
|
|
title="What's in scope?"
|
|
sub="Tick what you've signed up to deliver. The rest you can add later — billable."
|
|
/>
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gap: 12,
|
|
gridTemplateColumns: "repeat(2, 1fr)",
|
|
}}
|
|
>
|
|
{SCOPE_GROUPS.map((g) => (
|
|
<div
|
|
key={g.label}
|
|
style={{
|
|
padding: "14px 14px 10px",
|
|
borderRadius: 10,
|
|
border: "1px solid var(--hairline)",
|
|
background: "oklch(0.18 0.009 60 / 0.6)",
|
|
}}
|
|
>
|
|
<div
|
|
className="mono"
|
|
style={{
|
|
fontSize: 10.5, letterSpacing: "0.12em", textTransform: "uppercase",
|
|
color: "var(--fg-mute)", marginBottom: 10,
|
|
}}
|
|
>
|
|
{g.label}
|
|
</div>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
{g.items.map((it) => {
|
|
const active = scope.includes(it);
|
|
return (
|
|
<button
|
|
key={it}
|
|
type="button"
|
|
onClick={() => toggle(it)}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 10,
|
|
textAlign: "left",
|
|
padding: "6px 4px",
|
|
color: active ? "var(--fg)" : "var(--fg-dim)",
|
|
borderRadius: 6,
|
|
fontSize: 13.5,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
width: 16, height: 16,
|
|
flexShrink: 0,
|
|
borderRadius: 4,
|
|
border: `1px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
|
|
background: active ? "var(--accent)" : "transparent",
|
|
color: "var(--accent-fg)",
|
|
display: "grid", placeItems: "center",
|
|
}}
|
|
>
|
|
{active && (
|
|
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
|
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="m3 8.5 3.2 3.2L13 5"/>
|
|
</svg>
|
|
)}
|
|
</span>
|
|
{it}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ConsHandoff({ data, onChange }) {
|
|
return (
|
|
<>
|
|
<WizardQ
|
|
title="And finally — delivery."
|
|
sub="Where it lives, how you bill. Change later from settings."
|
|
/>
|
|
<Field label="Brand colors" optional hint="Hex, or just describe it. Paste a link, drop a screenshot — Vibn will figure it out.">
|
|
<input
|
|
className="wiz-input"
|
|
placeholder="#1B4D3E and #F2E2C4 — matches their truck wraps"
|
|
value={data.brand || ""}
|
|
onChange={(e) => onChange({ brand: e.target.value })}
|
|
/>
|
|
</Field>
|
|
<Field label="Where should it live?">
|
|
<PresetGroup
|
|
options={[
|
|
{ id: "subdomain", label: "Vibn subdomain", desc: "client-name.vibn.app — fastest." },
|
|
{ id: "custom", label: "Their custom domain", desc: "We'll walk you through DNS." },
|
|
{ id: "transfer", label: "Transfer ownership", desc: "They own it. You stay billable as editor." },
|
|
]}
|
|
value={data.handoff}
|
|
onChange={(v) => onChange({ handoff: v })}
|
|
columns={1}
|
|
/>
|
|
</Field>
|
|
<Field label="Your hourly rate" optional hint="Used on the handoff doc & invoice template.">
|
|
<div style={{ position: "relative" }}>
|
|
<span
|
|
className="mono"
|
|
style={{
|
|
position: "absolute", left: 14, top: "50%", transform: "translateY(-50%)",
|
|
color: "var(--fg-faint)", fontSize: 14.5, pointerEvents: "none",
|
|
}}
|
|
>$</span>
|
|
<input
|
|
className="wiz-input"
|
|
type="number"
|
|
min="0"
|
|
placeholder="120"
|
|
value={data.rate || ""}
|
|
onChange={(e) => onChange({ rate: e.target.value })}
|
|
style={{ paddingLeft: 26, paddingRight: 58 }}
|
|
/>
|
|
<span
|
|
className="mono"
|
|
style={{
|
|
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
|
|
color: "var(--fg-faint)", fontSize: 11.5, letterSpacing: "0.04em",
|
|
pointerEvents: "none",
|
|
}}
|
|
>/ hour</span>
|
|
</div>
|
|
</Field>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Path wrapper ───────────────────────────────────────────────────────────
|
|
function ConsultantPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
|
|
const next = () => {
|
|
if (step < CONS_TOTAL - 1) onJumpToStep(step + 1);
|
|
else onComplete();
|
|
};
|
|
const back = () => {
|
|
if (step === 0) onBack();
|
|
else onJumpToStep(step - 1);
|
|
};
|
|
|
|
let body, canNext;
|
|
if (step === 0) {
|
|
body = (
|
|
<ConsClient
|
|
clientName={data.clientName || ""}
|
|
industry={data.industry || ""}
|
|
contact={data.contact || ""}
|
|
onChange={onUpdate}
|
|
/>
|
|
);
|
|
canNext = (data.clientName || "").trim().length >= 2 && (data.industry || "").trim().length >= 3;
|
|
} else if (step === 1) {
|
|
body = <ConsBrief brief={data.brief || ""} onChange={(v) => onUpdate({ brief: v })} />;
|
|
canNext = (data.brief || "").trim().length >= 30;
|
|
} else if (step === 2) {
|
|
body = <ConsScope scope={data.scope || []} onChange={(v) => onUpdate({ scope: v })} />;
|
|
canNext = (data.scope || []).length >= 2;
|
|
} else {
|
|
body = <ConsHandoff data={data} onChange={onUpdate} />;
|
|
canNext = !!data.handoff;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<WizardTop
|
|
onBack={back}
|
|
onClose={onClose}
|
|
lane={LANE_LABELS.consultant}
|
|
stepText={CONS_STEP_NAMES[step]}
|
|
current={step + 2}
|
|
total={5}
|
|
/>
|
|
<WizardBody width={step === 2 ? "wide" : null}>
|
|
{body}
|
|
<WizardFooter
|
|
onNext={next}
|
|
canNext={canNext}
|
|
nextLabel={step === CONS_TOTAL - 1 ? "Spin up project →" : "Continue"}
|
|
hint={canNext ? "⌘↵" : null}
|
|
/>
|
|
</WizardBody>
|
|
</>
|
|
);
|
|
}
|
|
|
|
Object.assign(window, { ConsultantPath, CONS_TOTAL });
|