"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
357 lines
10 KiB
TypeScript
357 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|