Files
vibn-frontend/components/project-creation/setup-shared.tsx
Mark Henderson c7bb0eea58 feat(project-creation): replace owner-style picker with audience picker
"Myself / A client" was about who *owns* the project (a billing
concern), but at creation time we want to know who *uses* it — that's
what determines which Infrastructure providers we should pre-stage.

  team       = internal users (your team / employees)
               → SSO-style auth, no payments by default, simple roles
  customers  = external users (the public)
               → public sign-up + payments + transactional email by
                 default, custom domain matters

Both choices are reversible from the Infrastructure tab later — the
selector copy makes that explicit so users don't feel locked in.

Changes: - setup-shared: ForWhomSelector ("Myself" / "A client") replaced by
    AudienceSelector ("My team" / "Customers"), with an "you can
    change this later" hint underneath. New Audience union type
    exported for the three setup screens to share.
  - BuildSetup / OssSetup / ImportSetup: swap state + import + payload.
    Defaults: BuildSetup → customers (most "vibe coder" projects are
    public products), ImportSetup → customers (existing repos usually
    are too), OssSetup → team (Twenty / n8n / Plausible style tools
    are most often deployed for internal use).
  - /api/projects/create: drop isForClient (we never read it
    anywhere), persist audience as a first-class field on the
    project record so the AI can branch on it during the first chat.
Made-with: Cursor
2026-04-29 16:24:54 -07:00

357 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { ReactNode, CSSProperties } from "react";
import { JM } from "./modal-theme";
export interface SetupProps {
workspace: string;
onClose: () => void;
onBack: () => void;
}
export function FieldLabel({ children }: { children: ReactNode }) {
return (
<label style={{
display: "block", fontSize: 12, fontWeight: 600, color: JM.mid,
marginBottom: 6, fontFamily: JM.fontSans,
}}>
{children}
</label>
);
}
/** Audience picker — drives default infra (auth, payments, email, domain).
* - "team" → internal users (your team / employees). SSO-style auth,
* no payments by default, simple roles.
* - "customers" → external users (the public). Public sign-up, payments
* on by default, transactional email, custom domain.
* Either choice can be changed later from the Infrastructure tab. */
export type Audience = "team" | "customers";
export function AudienceSelector({
value,
onChange,
}: {
value: Audience;
onChange: (v: Audience) => void;
}) {
const cardBase: CSSProperties = {
flex: 1,
border: `1px solid ${JM.border}`,
borderRadius: 9,
padding: 14,
cursor: "pointer",
textAlign: "center" as const,
background: JM.inputBg,
transition: "all 0.15s",
fontFamily: JM.fontSans,
};
const row = (key: Audience, emoji: string, title: string, sub: string) => {
const sel = value === key;
return (
<button
type="button"
key={key}
onClick={() => onChange(key)}
style={{
...cardBase,
borderColor: sel ? JM.indigo : JM.border,
background: sel ? JM.cream : JM.inputBg,
boxShadow: sel ? "0 0 0 1px rgba(99,102,241,0.2)" : undefined,
}}
>
<div style={{ fontSize: 20, marginBottom: 5 }}>{emoji}</div>
<div style={{ fontSize: 12.5, fontWeight: 600, color: JM.ink }}>{title}</div>
<div style={{ fontSize: 11, color: JM.muted, marginTop: 2 }}>{sub}</div>
</button>
);
};
return (
<div style={{ marginBottom: 22 }}>
<FieldLabel>Who will use this?</FieldLabel>
<div style={{ display: "flex", gap: 8 }}>
{row("team", "🏢", "My team", "Internal tool")}
{row("customers", "🌍", "Customers", "Public product")}
</div>
<p style={{ fontSize: 11, color: JM.muted, marginTop: 7, lineHeight: 1.45 }}>
We'll set up the right defaults (sign-up, payments, email). You can change this later.
</p>
</div>
);
}
export function SetupHeader({
icon,
label,
tagline,
accent,
onBack,
onClose,
}: {
icon: string;
label: string;
tagline: string;
accent: string;
onBack: () => void;
onClose: () => void;
}) {
return (
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 22 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
type="button"
onClick={onBack}
style={{
background: "none", border: "none", cursor: "pointer",
color: JM.muted, fontSize: "1rem", padding: "3px 5px",
borderRadius: 4, lineHeight: 1, flexShrink: 0,
fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = JM.ink)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
</button>
<div>
<h2 style={{
fontFamily: JM.fontDisplay, fontSize: 18, fontWeight: 700,
color: JM.ink, margin: 0, marginBottom: 3, letterSpacing: "-0.02em",
}}>
{label}
</h2>
<p style={{
fontSize: 10.5, fontWeight: 600, color: accent, textTransform: "uppercase",
letterSpacing: "0.07em", margin: 0, fontFamily: JM.fontSans,
}}>
{tagline}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
style={{
background: "none", border: "none", cursor: "pointer",
color: JM.muted, fontSize: 20, lineHeight: 1,
padding: 4, flexShrink: 0, fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = JM.mid)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
×
</button>
</div>
);
}
export function TextInput({
value,
onChange,
placeholder,
onKeyDown,
autoFocus,
inputRef,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
inputRef?: React.RefObject<HTMLInputElement | null> | React.RefObject<HTMLInputElement>;
}) {
const base: CSSProperties = {
width: "100%", padding: "10px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
};
return (
<input
ref={inputRef}
type="text"
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
autoFocus={autoFocus}
style={base}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
);
}
export function TextArea({
value,
onChange,
placeholder,
rows = 5,
autoFocus,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
rows?: number;
autoFocus?: boolean;
}) {
return (
<textarea
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
autoFocus={autoFocus}
style={{
width: "100%", padding: "11px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
resize: "vertical", lineHeight: 1.5,
minHeight: 96,
}}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
);
}
/** Page indicator dots for a multi-step setup screen. */
export function StepDots({ step, total }: { step: number; total: number }) {
return (
<div style={{ display: "flex", gap: 5, alignItems: "center" }}>
{Array.from({ length: total }).map((_, i) => (
<span
key={i}
style={{
width: i === step ? 18 : 6, height: 6, borderRadius: 3,
background: i === step ? JM.indigo : JM.border,
transition: "all 0.2s",
}}
/>
))}
</div>
);
}
/** Inline tab selector — used inside OSS setup to pick paste-link vs describe-it. */
export function SegmentedTabs<T extends string>({
value, onChange, options,
}: {
value: T;
onChange: (v: T) => void;
options: { id: T; label: string }[];
}) {
return (
<div style={{
display: "flex", padding: 3, marginBottom: 14,
background: JM.inputBg, border: `1px solid ${JM.border}`,
borderRadius: 9, gap: 3,
}}>
{options.map(opt => {
const sel = opt.id === value;
return (
<button
key={opt.id}
type="button"
onClick={() => onChange(opt.id)}
style={{
flex: 1, padding: "7px 10px", borderRadius: 6,
border: "none", cursor: "pointer",
background: sel ? "#fff" : "transparent",
color: sel ? JM.ink : JM.mid,
fontSize: 12.5, fontWeight: sel ? 600 : 500,
fontFamily: JM.fontSans,
boxShadow: sel ? "0 1px 3px rgba(0,0,0,0.06)" : "none",
transition: "all 0.15s",
}}
>
{opt.label}
</button>
);
})}
</div>
);
}
export function SecondaryButton({
onClick, children,
}: { onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
onClick={onClick}
style={{
padding: "11px 16px", borderRadius: 8,
border: `1px solid ${JM.border}`, background: "#fff",
color: JM.mid, fontSize: 13.5, fontWeight: 600,
fontFamily: JM.fontSans, cursor: "pointer",
transition: "border-color 0.15s, color 0.15s",
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = JM.mid; e.currentTarget.style.color = JM.ink; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = JM.border; e.currentTarget.style.color = JM.mid; }}
>
{children}
</button>
);
}
export function PrimaryButton({
onClick,
disabled,
loading,
children,
}: {
onClick: () => void;
disabled?: boolean;
loading?: boolean;
children: ReactNode;
}) {
const active = !disabled && !loading;
return (
<button
type="button"
onClick={onClick}
disabled={!active}
style={{
width: "100%", padding: "12px",
borderRadius: 8, border: "none",
background: active ? JM.primaryGradient : "#E5E7EB",
color: active ? "#fff" : JM.muted,
fontSize: 14, fontWeight: 600,
fontFamily: JM.fontSans,
cursor: active ? "pointer" : "not-allowed",
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
boxShadow: active ? JM.primaryShadow : "none",
transition: "box-shadow 0.2s, transform 0.15s, opacity 0.15s",
}}
onMouseEnter={e => {
if (active) {
e.currentTarget.style.boxShadow = JM.primaryShadowHover;
e.currentTarget.style.transform = "translateY(-1px)";
}
}}
onMouseLeave={e => {
e.currentTarget.style.boxShadow = active ? JM.primaryShadow : "none";
e.currentTarget.style.transform = "none";
}}
>
{loading ? (
<>
<span style={{
width: 14, height: 14, borderRadius: "50%",
border: "2px solid rgba(255,255,255,0.35)", borderTopColor: "#fff",
animation: "vibn-spin 0.7s linear infinite", display: "inline-block",
}} />
Creating
</>
) : (
children
)}
</button>
);
}