1920 lines
55 KiB
TypeScript
1920 lines
55 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import {
|
|
WizardTop,
|
|
WizardBody,
|
|
WizardQ,
|
|
WizardFooter,
|
|
LANE_LABELS,
|
|
Field,
|
|
} from "./onboarding-primitives";
|
|
import { CityLookup } from "./onboarding-agency";
|
|
import { type CityRef } from "./onboarding-agency-types";
|
|
|
|
// Owner path — 3 streamlined steps for custom tool builders.
|
|
const OWNER_TOTAL = 3;
|
|
const OWNER_STEP_NAMES = ["Business", "Build", "Design"];
|
|
|
|
export interface PlaceMatch {
|
|
placeId: string;
|
|
name: string; // The business name (e.g. "Wheely Clean")
|
|
vicinity: string; // The physical address from Google Places
|
|
primaryType: string;
|
|
typeLabel: string; // The resolved industry name (e.g. "Dental Clinic")
|
|
bizType: string;
|
|
gcid: string;
|
|
description: string; // AI/mapping inferred industry description & presets
|
|
presetTools?: string[]; // Real softwareNeeds array loaded dynamically from mapping JSON
|
|
alternativeCategories?: Array<{
|
|
gcid: string;
|
|
typeLabel: string;
|
|
presetTools: string[];
|
|
description: string;
|
|
}>; // Other matching niches discovered for this business
|
|
}
|
|
|
|
// Simulated business lookup via GCP Places Text Search or its mock.
|
|
// Returns realistic matches in their city. Calls our real POST /api/agency/places/search
|
|
// proxy first, falling back to local mocks if the server fails or is offline.
|
|
async function searchPlaceMatches(
|
|
name: string,
|
|
city: CityRef | undefined,
|
|
): Promise<PlaceMatch[]> {
|
|
if (!name || !city) return [];
|
|
try {
|
|
const res = await fetch("/api/agency/places/search", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name, city }),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (Array.isArray(data) && data.length > 0) return data as PlaceMatch[];
|
|
}
|
|
} catch {
|
|
/* offline / endpoint missing — fall through to mock list */
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
setTimeout(() => {
|
|
const text = name.toLowerCase();
|
|
const c = city ? city.name : "Victoria, BC";
|
|
if (text.includes("alacrity")) {
|
|
resolve([
|
|
{
|
|
placeId: "alacrity",
|
|
name: "Alacrity Canada",
|
|
vicinity: "400-838 Fort St, " + c,
|
|
primaryType: "business_management_consultant",
|
|
typeLabel: "Business Management Consultant",
|
|
bizType: "consultant",
|
|
gcid: "gcid:business_management_consultant",
|
|
description:
|
|
"Consulting & GTM. Presets: Project boards, document folders, and client invoices.",
|
|
},
|
|
]);
|
|
} else if (
|
|
text.includes("plumb") ||
|
|
text.includes("drain") ||
|
|
text.includes("rooter")
|
|
) {
|
|
resolve([
|
|
{
|
|
placeId: "ace",
|
|
name: "Ace Plumbing & Rooter, Inc.",
|
|
vicinity: "1240 Broad St, " + c,
|
|
primaryType: "plumber",
|
|
typeLabel: "Plumber / Trades",
|
|
bizType: "service",
|
|
gcid: "gcid:plumber",
|
|
description:
|
|
"Field service & dispatcher. Presets: Booking queue, dispatch dispatcher, invoices.",
|
|
},
|
|
{
|
|
placeId: "advanced",
|
|
name: "Advanced Plumbing & Drain",
|
|
vicinity: "3920 Douglas St, " + c,
|
|
primaryType: "plumber",
|
|
typeLabel: "Plumber / Trades",
|
|
bizType: "service",
|
|
gcid: "gcid:plumber",
|
|
description:
|
|
"Field service & dispatcher. Presets: Booking queue, dispatch dispatcher, invoices.",
|
|
},
|
|
]);
|
|
} else if (
|
|
text.includes("salon") ||
|
|
text.includes("hair") ||
|
|
text.includes("beauty")
|
|
) {
|
|
resolve([
|
|
{
|
|
placeId: "velvet",
|
|
name: "The Velvet Hair Salon",
|
|
vicinity: "740 Yates St, " + c,
|
|
primaryType: "hair_salon",
|
|
typeLabel: "Hair Salon",
|
|
bizType: "appointments",
|
|
gcid: "gcid:hair_salon",
|
|
description:
|
|
"Salon scheduling & CRM. Presets: Stylist schedules, booking calendar, client CRM.",
|
|
},
|
|
{
|
|
placeId: "luxe",
|
|
name: "Luxe Beauty & Spa",
|
|
vicinity: "1020 Government St, " + c,
|
|
primaryType: "beauty_salon",
|
|
typeLabel: "Beauty Salon",
|
|
bizType: "appointments",
|
|
gcid: "gcid:beauty_salon",
|
|
description:
|
|
"Salon scheduling & CRM. Presets: Stylist schedules, booking calendar, client CRM.",
|
|
},
|
|
]);
|
|
} else if (
|
|
text.includes("bakery") ||
|
|
text.includes("cafe") ||
|
|
text.includes("café")
|
|
) {
|
|
resolve([
|
|
{
|
|
placeId: "pearl",
|
|
name: "Pearl Lane Bakery",
|
|
vicinity: "1410 Broad St, " + c,
|
|
primaryType: "bakery",
|
|
typeLabel: "Bakery",
|
|
bizType: "food",
|
|
gcid: "gcid:bakery",
|
|
description:
|
|
"Bakery production & retail. Presets: Batch costing, POS register, online ordering.",
|
|
},
|
|
{
|
|
placeId: "sunrise",
|
|
name: "Sunrise Cafe",
|
|
vicinity: "520 Fort St, " + c,
|
|
primaryType: "cafe",
|
|
typeLabel: "Café",
|
|
bizType: "food",
|
|
gcid: "gcid:cafe",
|
|
description:
|
|
"Cafe orders & retail. Presets: POS register, table management, online ordering.",
|
|
},
|
|
]);
|
|
} else {
|
|
resolve([
|
|
{
|
|
placeId: "opt-1",
|
|
name: `${name} (Downtown)`,
|
|
vicinity: "740 Yates St, " + c,
|
|
primaryType: "local_business",
|
|
typeLabel: "Local Business",
|
|
bizType: "other",
|
|
gcid: "gcid:local_business",
|
|
description:
|
|
"Custom local business tool. Presets: Customer history, simple billing, scheduling.",
|
|
},
|
|
{
|
|
placeId: "opt-2",
|
|
name: `${name} (Uptown)`,
|
|
vicinity: "3920 Douglas St, " + c,
|
|
primaryType: "local_business",
|
|
typeLabel: "Local Business",
|
|
bizType: "other",
|
|
gcid: "gcid:local_business",
|
|
description:
|
|
"Custom local business tool. Presets: Customer history, simple billing, scheduling.",
|
|
},
|
|
]);
|
|
}
|
|
}, 1400); // high-craft 1.4s spinner
|
|
});
|
|
}
|
|
|
|
export function OwnerBiz({
|
|
name,
|
|
onNameChange,
|
|
city,
|
|
onCityChange,
|
|
website,
|
|
onWebsiteChange,
|
|
resolving,
|
|
matches,
|
|
onSelectMatch,
|
|
}) {
|
|
if (resolving) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
gap: 16,
|
|
padding: "44px 0",
|
|
}}
|
|
>
|
|
<span className="spinner" style={{ width: 30, height: 30 }}>
|
|
<span className="spinner-line" />
|
|
</span>
|
|
<div style={{ textAlign: "center" }}>
|
|
<div style={{ fontSize: 15, color: "var(--fg)", fontWeight: 500 }}>
|
|
Analyzing {name || "your business"} in{" "}
|
|
{city ? city.name : "your city"}
|
|
</div>
|
|
<div
|
|
className="mono"
|
|
style={{ marginTop: 6, fontSize: 12.5, color: "var(--accent)" }}
|
|
>
|
|
Searching local business directory & mapping industry…
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (matches && matches.length > 0) {
|
|
// Unpack both the primary category and any alternative categories into a flat list of options (tiles)
|
|
const primaryMatch = matches[0];
|
|
const categoryOptions = [
|
|
{
|
|
id: primaryMatch.gcid,
|
|
label: primaryMatch.typeLabel,
|
|
description: primaryMatch.description,
|
|
presetTools: primaryMatch.presetTools || [],
|
|
bizType: primaryMatch.bizType,
|
|
},
|
|
];
|
|
|
|
if (primaryMatch.alternativeCategories) {
|
|
primaryMatch.alternativeCategories.forEach((alt) => {
|
|
categoryOptions.push({
|
|
id: alt.gcid,
|
|
label: alt.typeLabel,
|
|
description: alt.description,
|
|
presetTools: alt.presetTools,
|
|
bizType: primaryMatch.bizType, // inherit baseline FSM vs appointments category
|
|
});
|
|
});
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<WizardQ
|
|
title="Which best describes your business?"
|
|
sub={`We searched Google Places in ${city ? city.name : "your city"} and found these specialized categories for ${primaryMatch.name}. Pick the one that fits best.`}
|
|
/>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
{categoryOptions.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
onClick={() =>
|
|
onSelectMatch({
|
|
placeId: primaryMatch.placeId,
|
|
name: primaryMatch.name,
|
|
vicinity: primaryMatch.vicinity,
|
|
primaryType: primaryMatch.primaryType,
|
|
typeLabel: c.label,
|
|
bizType: c.bizType,
|
|
gcid: c.id,
|
|
presetTools: c.presetTools,
|
|
description: c.description,
|
|
})
|
|
}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 14,
|
|
padding: "14px 16px",
|
|
borderRadius: 12,
|
|
border: "1px solid var(--hairline)",
|
|
background: "var(--bg-1)",
|
|
textAlign: "left",
|
|
color: "var(--fg)",
|
|
cursor: "pointer",
|
|
transition: "border-color .15s, background .15s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = "var(--accent)";
|
|
e.currentTarget.style.background = "var(--accent-soft)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = "var(--hairline)";
|
|
e.currentTarget.style.background = "var(--bg-1)";
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
flexShrink: 0,
|
|
borderRadius: 9,
|
|
background: "var(--bg-2)",
|
|
color: "var(--fg-mute)",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
}}
|
|
>
|
|
🛠
|
|
</span>
|
|
<span
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 3,
|
|
flex: 1,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: 15,
|
|
fontWeight: 600,
|
|
letterSpacing: "-0.008em",
|
|
color: "var(--fg)",
|
|
}}
|
|
>
|
|
{c.label}
|
|
</span>
|
|
<span
|
|
style={{
|
|
fontSize: 13,
|
|
color: "var(--fg-dim)",
|
|
lineHeight: 1.45,
|
|
}}
|
|
>
|
|
{c.description}
|
|
</span>
|
|
<span
|
|
className="mono"
|
|
style={{
|
|
fontSize: 10,
|
|
color: "var(--accent)",
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.05em",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
Match: {primaryMatch.name}{" "}
|
|
{primaryMatch.vicinity
|
|
? `· ${primaryMatch.vicinity.split(",")[0]}`
|
|
: "· Mobile/SAB"}
|
|
</span>
|
|
</span>
|
|
<svg
|
|
width="15"
|
|
height="15"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
style={{ color: "var(--fg-faint)" }}
|
|
>
|
|
<path d="M3 8h10M9 4l4 4-4 4" />
|
|
</svg>
|
|
</button>
|
|
))}
|
|
|
|
{/* None of these fallback card */}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
onSelectMatch({
|
|
placeId: "manual",
|
|
name,
|
|
vicinity: "",
|
|
primaryType: "custom",
|
|
typeLabel: "Custom Business",
|
|
bizType: "other",
|
|
gcid: "gcid:local_business",
|
|
description: "",
|
|
})
|
|
}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 14,
|
|
padding: "14px 16px",
|
|
borderRadius: 12,
|
|
border: "1px solid var(--hairline)",
|
|
background: "transparent",
|
|
textAlign: "left",
|
|
color: "var(--fg-mute)",
|
|
cursor: "pointer",
|
|
transition: "border-color .15s, color .15s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = "var(--accent)";
|
|
e.currentTarget.style.color = "var(--fg)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = "var(--hairline)";
|
|
e.currentTarget.style.color = "var(--fg-mute)";
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
flexShrink: 0,
|
|
borderRadius: 9,
|
|
background: "var(--bg-1)",
|
|
color: "inherit",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
fontSize: 16,
|
|
}}
|
|
>
|
|
✦
|
|
</span>
|
|
<span
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 2,
|
|
flex: 1,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: 14.5,
|
|
fontWeight: 500,
|
|
letterSpacing: "-0.008em",
|
|
}}
|
|
>
|
|
None of these are mine
|
|
</span>
|
|
<span
|
|
style={{
|
|
fontSize: 12.5,
|
|
color: "var(--fg-faint)",
|
|
lineHeight: 1.45,
|
|
}}
|
|
>
|
|
Configure my industry presets manually from scratch
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<WizardQ
|
|
title="Tell us about your business."
|
|
sub="Enter your business details so we can look up your industry and customize your tool presets."
|
|
/>
|
|
<Field label="What is the name of the business?">
|
|
<input
|
|
className="wiz-input"
|
|
placeholder="e.g. Sunrise Plumbing, Pearl Lane Bakery…"
|
|
value={name}
|
|
autoFocus
|
|
onChange={(e) => onNameChange(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Where is it located?">
|
|
<CityLookup value={city} onChange={onCityChange} />
|
|
</Field>
|
|
<Field
|
|
label="What is the business's website?"
|
|
hint="Enter your domain (e.g. domain.com), no http:// or https://"
|
|
optional
|
|
>
|
|
<input
|
|
className="wiz-input"
|
|
placeholder="e.g. sunriseplumbing.com"
|
|
value={website}
|
|
onChange={(e) => onWebsiteChange(e.target.value)}
|
|
/>
|
|
</Field>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const STACK_PRESETS: Record<
|
|
string,
|
|
{ label: string; tools: string[]; costPerTool: number }
|
|
> = {
|
|
"gcid:business_management_consultant": {
|
|
label: "Business Management Consultant",
|
|
tools: [
|
|
"Professional Services Automation (PSA)",
|
|
"Project Management Software",
|
|
"CRM Software",
|
|
"Billing Software",
|
|
"Business Intelligence (BI) Software",
|
|
],
|
|
costPerTool: 120,
|
|
},
|
|
"gcid:plumber": {
|
|
label: "Plumbing / Trades",
|
|
tools: [
|
|
"Plumbing Software",
|
|
"Field Service Management (FSM)",
|
|
"Invoicing & Payments Software",
|
|
"Scheduling Software",
|
|
"Billing Software",
|
|
],
|
|
costPerTool: 110,
|
|
},
|
|
"gcid:hair_salon": {
|
|
label: "Hair Salon",
|
|
tools: [
|
|
"Salon / Spa Management Software",
|
|
"Appointment Scheduling Software",
|
|
"Billing Software",
|
|
"CRM Software",
|
|
"Payroll Software",
|
|
],
|
|
costPerTool: 75,
|
|
},
|
|
"gcid:beauty_salon": {
|
|
label: "Beauty Salon",
|
|
tools: [
|
|
"Salon / Spa Management Software",
|
|
"Appointment Scheduling Software",
|
|
"CRM Software",
|
|
"Retail POS System",
|
|
"Accounting Software",
|
|
],
|
|
costPerTool: 75,
|
|
},
|
|
"gcid:bakery": {
|
|
label: "Bakery",
|
|
tools: [
|
|
"Bakery Software",
|
|
"Restaurant POS Software",
|
|
"Inventory Management Software",
|
|
"Order Management Software",
|
|
"Employee Scheduling Software",
|
|
],
|
|
costPerTool: 95,
|
|
},
|
|
"gcid:cafe": {
|
|
label: "Cafe",
|
|
tools: [
|
|
"Restaurant POS Software",
|
|
"Inventory Management",
|
|
"Online Ordering Platform",
|
|
"Employee Scheduling Software",
|
|
],
|
|
costPerTool: 85,
|
|
},
|
|
fallback: {
|
|
label: "Local Business",
|
|
tools: [
|
|
"Appointment Scheduling Software",
|
|
"Invoicing & Payments",
|
|
"Customer CRM",
|
|
],
|
|
costPerTool: 60,
|
|
},
|
|
};
|
|
|
|
// Detailed software component blueprints derived from your SMB mapping data
|
|
interface ToolComponent {
|
|
label: string;
|
|
short: string;
|
|
desc: string;
|
|
icon: string;
|
|
}
|
|
|
|
const COMPONENT_BLUEPRINTS: Record<string, ToolComponent> = {
|
|
"Professional Services Automation (PSA)": {
|
|
label: "Professional Services Automation (PSA)",
|
|
short: "Team & Dispatch",
|
|
desc: "Coordinate your team, log hours, and track billable work.",
|
|
icon: "users",
|
|
},
|
|
"Project Management Software": {
|
|
label: "Project Management Software",
|
|
short: "Job Tracker",
|
|
desc: "Manage milestones, tasks, and client project timelines.",
|
|
icon: "clipboard",
|
|
},
|
|
"CRM Software": {
|
|
label: "CRM Software",
|
|
short: "Customer CRM",
|
|
desc: "One central place to track leads, clients, and history.",
|
|
icon: "users",
|
|
},
|
|
"Billing Software": {
|
|
label: "Billing Software",
|
|
short: "Billing",
|
|
desc: "Generate professional bills and track ledger records.",
|
|
icon: "receipt",
|
|
},
|
|
"Business Intelligence (BI) Software": {
|
|
label: "Business Intelligence (BI) Software",
|
|
short: "Dashboard",
|
|
desc: "A live, visual real-time dashboard of your team and revenue.",
|
|
icon: "chart",
|
|
},
|
|
"Reporting / Dashboard Software": {
|
|
label: "Reporting / Dashboard Software",
|
|
short: "Reporting",
|
|
desc: "Track metrics, business growth, and daily reports.",
|
|
icon: "chart",
|
|
},
|
|
"Marketing Automation Software": {
|
|
label: "Marketing Automation Software",
|
|
short: "Communications",
|
|
desc: "Automated client emails, follow-ups, and campaigns.",
|
|
icon: "megaphone",
|
|
},
|
|
"Plumbing Software": {
|
|
label: "Plumbing Software",
|
|
short: "Plumbing Log",
|
|
desc: "Log material logs, pipe layout specs, and job history.",
|
|
icon: "clipboard",
|
|
},
|
|
"Field Service Management (FSM)": {
|
|
label: "Field Service Management (FSM)",
|
|
short: "Field Service",
|
|
desc: "Dispatch technicians, route jobs, and coordinate trucks.",
|
|
icon: "clipboard",
|
|
},
|
|
"Invoicing & Payments Software": {
|
|
label: "Invoicing & Payments Software",
|
|
short: "Invoices & Payments",
|
|
desc: "Send digital quotes, take payments, and auto-follow up.",
|
|
icon: "receipt",
|
|
},
|
|
"Scheduling Software": {
|
|
label: "Scheduling Software",
|
|
short: "Dispatch Calendar",
|
|
desc: "A shared calendar to assign jobs, coordinate rotas, and dispatch.",
|
|
icon: "calendar",
|
|
},
|
|
"Salon / Spa Management Software": {
|
|
label: "Salon / Spa Management Software",
|
|
short: "Stylist Queue",
|
|
desc: "Coordinate chairs, stylists, walkthroughs, and walk-ins.",
|
|
icon: "users",
|
|
},
|
|
"Appointment Scheduling Software": {
|
|
label: "Appointment Scheduling Software",
|
|
short: "Online Bookings",
|
|
desc: "Let customers book online, pick stylists, and pay deposits.",
|
|
icon: "calendar",
|
|
},
|
|
"Payroll Software": {
|
|
label: "Payroll Software",
|
|
short: "Payroll",
|
|
desc: "Process wages, compute withholdings, and pay staff.",
|
|
icon: "receipt",
|
|
},
|
|
"Retail POS System": {
|
|
label: "Retail POS System",
|
|
short: "Register POS",
|
|
desc: "A touch checkout register with barcode and receipt printing.",
|
|
icon: "card",
|
|
},
|
|
"Accounting Software": {
|
|
label: "Accounting Software",
|
|
short: "Accounting",
|
|
desc: "Keep books clean, log expenses, and export taxes.",
|
|
icon: "chart",
|
|
},
|
|
"Bakery Software": {
|
|
label: "Bakery Software",
|
|
short: "Recipe & Batch Costing",
|
|
desc: "Compute recipe margins, track batch costing, and prep orders.",
|
|
icon: "box",
|
|
},
|
|
"Restaurant POS Software": {
|
|
label: "Restaurant POS Software",
|
|
short: "Restaurant POS",
|
|
desc: "Mobile table ordering, kitchen displays, and checkout POS.",
|
|
icon: "card",
|
|
},
|
|
"Inventory Management Software": {
|
|
label: "Inventory Management Software",
|
|
short: "Inventory Control",
|
|
desc: "Track stock, manage suppliers, and trigger low-stock alerts.",
|
|
icon: "box",
|
|
},
|
|
"Order Management Software": {
|
|
label: "Order Management Software",
|
|
short: "Order Dispatcher",
|
|
desc: "Pack, ship, and track customer orders in real-time.",
|
|
icon: "clipboard",
|
|
},
|
|
"Employee Scheduling Software": {
|
|
label: "Employee Scheduling Software",
|
|
short: "Staff Rota",
|
|
desc: "Schedule team shifts, dispatch rotas, and track timesheets.",
|
|
icon: "users",
|
|
},
|
|
"Restaurant Management Software": {
|
|
label: "Restaurant Management Software",
|
|
short: "Table Management",
|
|
desc: "Coordinate table layouts, guest booking, and food prep flow.",
|
|
icon: "clipboard",
|
|
},
|
|
"Online Ordering Software": {
|
|
label: "Online Ordering Software",
|
|
short: "Online Orders",
|
|
desc: "Direct takeout and delivery orders from your website.",
|
|
icon: "cart",
|
|
},
|
|
"Event Management Software": {
|
|
label: "Event Management Software",
|
|
short: "Event Scheduler",
|
|
desc: "Coordinate venue bookings, caterers, and rentals.",
|
|
icon: "calendar",
|
|
},
|
|
"Reservations & Bookings Widget": {
|
|
label: "Reservations & Bookings Widget",
|
|
short: "Reservations",
|
|
desc: "Allow customers to reserve slots, tables, or venues.",
|
|
icon: "calendar",
|
|
},
|
|
};
|
|
|
|
// Tool icons matching our onboarding agency icons
|
|
function ToolIcon({ name }: { name: string }) {
|
|
const common = {
|
|
width: 15,
|
|
height: 15,
|
|
viewBox: "0 0 24 24",
|
|
fill: "none",
|
|
stroke: "currentColor",
|
|
strokeWidth: 1.7,
|
|
strokeLinecap: "round" as const,
|
|
strokeLinejoin: "round" as const,
|
|
};
|
|
const paths: Record<string, React.ReactNode> = {
|
|
calendar: (
|
|
<>
|
|
<rect x="3" y="4.5" width="18" height="16" rx="2" />
|
|
<path d="M3 9h18M8 2.5v4M16 2.5v4" />
|
|
</>
|
|
),
|
|
users: (
|
|
<>
|
|
<circle cx="9" cy="8" r="3.2" />
|
|
<path d="M3.5 20a5.5 5.5 0 0 1 11 0M16 5.2a3.2 3.2 0 0 1 0 5.6M20.5 20a5.5 5.5 0 0 0-3.5-5.1" />
|
|
</>
|
|
),
|
|
receipt: (
|
|
<>
|
|
<path d="M5 3v18l2.5-1.5L10 21l2-1.5L14 21l2.5-1.5L19 21V3l-2.5 1.5L14 3l-2 1.5L10 3 7.5 4.5 5 3Z" />
|
|
<path d="M8.5 8.5h7M8.5 12h7" />
|
|
</>
|
|
),
|
|
box: (
|
|
<>
|
|
<path d="M3 7.5 12 3l9 4.5v9L12 21l-9-4.5v-9Z" />
|
|
<path d="M3 7.5 12 12l9-4.5M12 12v9" />
|
|
</>
|
|
),
|
|
card: (
|
|
<>
|
|
<rect x="2.5" y="5" width="19" height="14" rx="2" />
|
|
<path d="M2.5 9.5h19M6 15h4" />
|
|
</>
|
|
),
|
|
cart: (
|
|
<>
|
|
<circle cx="9" cy="20" r="1.4" />
|
|
<circle cx="17" cy="20" r="1.4" />
|
|
<path d="M2.5 3h2.2l2.2 12.2a1.5 1.5 0 0 0 1.5 1.2h8.2a1.5 1.5 0 0 0 1.5-1.2L21 7H6" />
|
|
</>
|
|
),
|
|
clipboard: (
|
|
<>
|
|
<rect x="5" y="4" width="14" height="17" rx="2" />
|
|
<path d="M9 4a1.5 1.5 0 0 1 1.5-1.5h3A1.5 1.5 0 0 1 15 4v1H9V4ZM8.5 10h7M8.5 14h7M8.5 17.5h4" />
|
|
</>
|
|
),
|
|
chart: (
|
|
<>
|
|
<path d="M4 4v16h16" />
|
|
<path d="M8 16v-4M12 16V8M16 16v-7" />
|
|
</>
|
|
),
|
|
};
|
|
return (
|
|
<svg {...common} aria-hidden="true">
|
|
{paths[name] ?? paths.clipboard}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function OwnerStack({
|
|
bizName,
|
|
city,
|
|
bizType,
|
|
tools,
|
|
onToolsChange,
|
|
presetTools,
|
|
buildDesc,
|
|
onBuildDescChange,
|
|
onSkip,
|
|
}) {
|
|
const preset =
|
|
presetTools && presetTools.length > 0
|
|
? { label: bizName, tools: presetTools, costPerTool: 80 }
|
|
: STACK_PRESETS[bizType] || STACK_PRESETS.fallback;
|
|
|
|
// The 4 core universal business-builder pillars that must always be present
|
|
const UNIVERSAL_PILLARS = [
|
|
"CRM Software",
|
|
"Reporting / Dashboard Software",
|
|
"Business Intelligence (BI) Software",
|
|
"Marketing Automation Software",
|
|
];
|
|
|
|
// Merge niche presets with our 4 universal pillars (deduplicated)
|
|
const mergedTools = Array.from(
|
|
new Set([...preset.tools, ...UNIVERSAL_PILLARS]),
|
|
);
|
|
|
|
// Clear selected tools if they change the business type so they don't inherit old choices!
|
|
// Gated with a length check and depending ONLY on bizType to prevent unstable prop-function infinite render loops.
|
|
React.useEffect(() => {
|
|
if (tools.length > 0) {
|
|
onToolsChange([]);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [bizType]);
|
|
|
|
const toggleTool = (tool: string) => {
|
|
if (tools.includes(tool)) {
|
|
onToolsChange(tools.filter((t) => t !== tool));
|
|
} else {
|
|
onToolsChange([...tools, tool]);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<WizardQ
|
|
title="What kind of custom tools do you want to build?"
|
|
sub="Select the custom components you want to build or combine into a single tool."
|
|
/>
|
|
|
|
<Field label="Describe what you want to build">
|
|
<textarea
|
|
className="wiz-input"
|
|
rows={3}
|
|
placeholder="e.g. I want to build a custom scheduler that automatically sends text reminders and handles invoices."
|
|
value={buildDesc}
|
|
onChange={(e) => onBuildDescChange(e.target.value)}
|
|
style={{ resize: "vertical", minHeight: 84 }}
|
|
/>
|
|
</Field>
|
|
|
|
<span
|
|
className="wiz-field-label"
|
|
style={{
|
|
display: "block",
|
|
marginBottom: 8,
|
|
marginTop: 12,
|
|
}}
|
|
>
|
|
Or start with one of these.
|
|
</span>
|
|
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
{mergedTools.map((t) => {
|
|
const comp = COMPONENT_BLUEPRINTS[t] || {
|
|
label: t,
|
|
short: t.replace(/\s*Software$/i, ""),
|
|
desc: "Custom component designed around your workflow.",
|
|
icon: "clipboard",
|
|
};
|
|
const active = tools.includes(t);
|
|
return (
|
|
<button
|
|
key={t}
|
|
type="button"
|
|
onClick={() => toggleTool(t)}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
gap: 12,
|
|
padding: "12px 14px",
|
|
borderRadius: 12,
|
|
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
|
|
background: active ? "var(--accent-soft)" : "var(--bg-1)",
|
|
textAlign: "left",
|
|
color: "var(--fg)",
|
|
cursor: "pointer",
|
|
transition: "border-color .15s, background .15s, transform .1s",
|
|
position: "relative",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!active)
|
|
e.currentTarget.style.borderColor = "var(--hairline-2)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!active)
|
|
e.currentTarget.style.borderColor = "var(--hairline)";
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
width: 30,
|
|
height: 30,
|
|
flexShrink: 0,
|
|
borderRadius: 8,
|
|
display: "grid",
|
|
placeItems: "center",
|
|
background: active ? "var(--accent)" : "var(--bg-2)",
|
|
color: active ? "var(--accent-fg)" : "var(--fg-mute)",
|
|
marginTop: 1,
|
|
}}
|
|
>
|
|
<ToolIcon name={comp.icon} />
|
|
</span>
|
|
<span
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 2,
|
|
flex: 1,
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
letterSpacing: "-0.01em",
|
|
}}
|
|
>
|
|
{comp.short}
|
|
</span>
|
|
<span
|
|
style={{
|
|
fontSize: 12,
|
|
color: "var(--fg-mute)",
|
|
lineHeight: 1.45,
|
|
}}
|
|
>
|
|
{comp.desc}
|
|
</span>
|
|
</span>
|
|
|
|
{/* Top right checked indicator */}
|
|
<span
|
|
style={{
|
|
width: 16,
|
|
height: 16,
|
|
borderRadius: "50%",
|
|
border: `1.5px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
|
|
background: active ? "var(--accent)" : "transparent",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
color: "var(--accent-fg)",
|
|
flexShrink: 0,
|
|
marginTop: 2,
|
|
transition: "border-color .12s, background .12s",
|
|
}}
|
|
>
|
|
{active && (
|
|
<svg
|
|
width="9"
|
|
height="9"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="m3 8.5 3.2 3.2L13 5" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{tools.length > 0 && (
|
|
<div
|
|
style={{
|
|
marginTop: 6,
|
|
padding: "14px 16px",
|
|
borderRadius: 12,
|
|
border: "1px solid var(--accent)",
|
|
background:
|
|
"linear-gradient(180deg, var(--accent-soft), transparent)",
|
|
display: "flex",
|
|
gap: 12,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
width: 20,
|
|
height: 20,
|
|
borderRadius: "50%",
|
|
background: "var(--accent)",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
color: "var(--accent-fg)",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<svg
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="3.2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M3 8.5 6.2 11.7 13 5" />
|
|
</svg>
|
|
</span>
|
|
<p
|
|
style={{
|
|
margin: 0,
|
|
flex: 1,
|
|
fontSize: 13,
|
|
color: "var(--fg-dim)",
|
|
lineHeight: 1.45,
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
Vibn will build these {tools.length} custom components and combine
|
|
them into one seamless, unified tool. No separate subscriptions. No
|
|
double-entry.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Undecided bypass button */}
|
|
<div
|
|
style={{ display: "flex", justifyContent: "flex-start", marginTop: 4 }}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={onSkip}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: "var(--accent)",
|
|
fontSize: 13,
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
padding: "4px 0",
|
|
textDecoration: "underline",
|
|
opacity: 0.85,
|
|
transition: "opacity .15s",
|
|
}}
|
|
onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")}
|
|
onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.85")}
|
|
>
|
|
{"I'm not sure yet"}
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Streamlined GTM themes matching the two visual directions of the Cadence CRM screenshot
|
|
const THEMES = [
|
|
{
|
|
id: "minimal",
|
|
icon: "☀️",
|
|
label: "Light Theme",
|
|
desc: "Warm parchment background and crisp, fine ink rules (Lattice / Justine school).",
|
|
},
|
|
{
|
|
id: "dark",
|
|
icon: "🌙",
|
|
label: "Dark Theme",
|
|
desc: "Deep charcoal surfaces, dark rail chrome, and glowing coral accents (Vercel / Stripe school).",
|
|
},
|
|
];
|
|
|
|
// Reusable live-themed mini-wireframe mockups of the CRM Home Page (from crm-pages.jsx)
|
|
// to let the user preview their exact dashboard before building!
|
|
function ThemeWireframePreview({ themeId }: { themeId: string }) {
|
|
// Map theme variables locally for the mini-preview card
|
|
const themeStyles: Record<string, React.CSSProperties> = {
|
|
minimal: {
|
|
background: "#fdfcfa",
|
|
color: "#1a1510",
|
|
borderColor: "#e8e2d9",
|
|
accentColor: "var(--accent)",
|
|
borderRadius: "8px",
|
|
},
|
|
dark: {
|
|
background: "#121212",
|
|
color: "#fdfcfa",
|
|
borderColor: "#2a2a2a",
|
|
accentColor: "var(--accent)",
|
|
borderRadius: "8px",
|
|
},
|
|
};
|
|
|
|
const style = themeStyles[themeId] || themeStyles.minimal;
|
|
const wireframeBorder = `1px solid ${style.borderColor}`;
|
|
const isDark = themeId === "dark";
|
|
|
|
// High-fidelity mini-wireframe matching your CRM screenshot (Northwind workspace)
|
|
// Scaled down proportionally to 180px height for compact, clean card layouts
|
|
return (
|
|
<div
|
|
style={{
|
|
width: "100%",
|
|
height: 180, // Compact height (restored)
|
|
background: style.background,
|
|
color: style.color,
|
|
border: wireframeBorder,
|
|
borderRadius: style.borderRadius,
|
|
display: "flex",
|
|
overflow: "hidden",
|
|
fontSize: 7.5, // Compact font size
|
|
lineHeight: 1.2,
|
|
transition: "all .2s ease",
|
|
opacity: 0.98,
|
|
}}
|
|
>
|
|
{/* ── Left Sidebar Nav ── */}
|
|
<div
|
|
style={{
|
|
width: "22%",
|
|
borderRight: wireframeBorder,
|
|
background: isDark ? "#17171c" : "#faf8f5",
|
|
padding: "6px 5px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 7,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{/* Workspace Brand Dropdown */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
borderBottom: wireframeBorder,
|
|
paddingBottom: 4,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: 3,
|
|
background: style.accentColor,
|
|
display: "grid",
|
|
placeItems: "center",
|
|
fontSize: 6,
|
|
color: "var(--accent-fg)",
|
|
fontWeight: 700,
|
|
}}
|
|
>
|
|
N
|
|
</div>
|
|
<div style={{ fontWeight: 600, fontSize: 8 }}>Northwind</div>
|
|
</div>
|
|
|
|
{/* Search Jump box */}
|
|
<div
|
|
style={{
|
|
height: 10,
|
|
border: wireframeBorder,
|
|
borderRadius: 3,
|
|
background: isDark ? "#1f1f24" : "#fff",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
paddingLeft: 3,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 4,
|
|
height: 4,
|
|
borderRadius: "50%",
|
|
background: style.color,
|
|
opacity: 0.2,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Nav Items */}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
padding: "2px 3px",
|
|
borderRadius: 3,
|
|
background: isDark
|
|
? "rgba(255,255,255,0.04)"
|
|
: "rgba(0,0,0,0.03)",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 4,
|
|
height: 4,
|
|
borderRadius: "50%",
|
|
background: style.accentColor,
|
|
}}
|
|
/>
|
|
<span style={{ fontWeight: 600, color: style.accentColor }}>
|
|
Home
|
|
</span>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
padding: "2px 3px",
|
|
opacity: 0.5,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 4,
|
|
height: 4,
|
|
borderRadius: "50%",
|
|
background: style.color,
|
|
}}
|
|
/>
|
|
<span>Inbox</span>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
padding: "2px 3px",
|
|
opacity: 0.5,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 4,
|
|
height: 4,
|
|
borderRadius: "50%",
|
|
background: style.color,
|
|
}}
|
|
/>
|
|
<span>Tasks</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section: Records */}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
<div
|
|
style={{
|
|
fontSize: 6,
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.05em",
|
|
color: style.color,
|
|
opacity: 0.35,
|
|
fontWeight: 700,
|
|
paddingLeft: 3,
|
|
}}
|
|
>
|
|
Records
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
padding: "1px 3px",
|
|
opacity: 0.5,
|
|
}}
|
|
>
|
|
<span>Companies</span>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
padding: "1px 3px",
|
|
opacity: 0.5,
|
|
}}
|
|
>
|
|
<span>People</span>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
padding: "1px 3px",
|
|
opacity: 0.5,
|
|
}}
|
|
>
|
|
<span>Deals</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Central Main Dashboard Content ── */}
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
padding: "7px 9px", // Compact padding
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 6, // Compact gap
|
|
background: isDark ? "#121212" : "#fff",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{/* Page Header */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<div>
|
|
<div style={{ fontSize: 10, fontWeight: 700, color: style.color }}>
|
|
Good morning, Mira
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: 6,
|
|
color: style.color,
|
|
opacity: 0.4,
|
|
marginTop: 1,
|
|
}}
|
|
>
|
|
3 deals need a nudge today · 8 unread in Inbox
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
width: 22,
|
|
height: 10,
|
|
background: style.accentColor,
|
|
borderRadius: 3,
|
|
opacity: 0.85,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Top Row: 4 KPI Cards */}
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
|
|
gap: 4, // Compact gap
|
|
}}
|
|
>
|
|
{[
|
|
{ label: "Open pipeline", val: "$1.24M", up: true },
|
|
{ label: "Won · this month", val: "$284K", up: true },
|
|
{ label: "Win rate · 90d", val: "31%", up: true },
|
|
{ label: "Avg. response", val: "2.4h", up: false },
|
|
].map((k, idx) => (
|
|
<div
|
|
key={idx}
|
|
style={{
|
|
padding: "3px 4px", // Compact padding
|
|
border: wireframeBorder,
|
|
borderRadius: 4,
|
|
background: isDark ? "#1a1a20" : "#fafafa",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 2,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
opacity: 0.4,
|
|
fontSize: 5.5,
|
|
}}
|
|
>
|
|
<span>{k.label}</span>
|
|
<span style={{ color: k.up ? "var(--ok)" : "#ff4d5e" }}>
|
|
{k.up ? "↑" : "↓"}
|
|
</span>
|
|
</div>
|
|
<div style={{ fontWeight: 700, fontSize: 9, color: style.color }}>
|
|
{k.val}
|
|
</div>
|
|
{/* mini sparkline */}
|
|
<div
|
|
style={{
|
|
height: 4,
|
|
width: "100%",
|
|
position: "relative",
|
|
marginTop: 1,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: "100%",
|
|
height: 1,
|
|
background: k.up
|
|
? "rgba(34,197,94,0.15)"
|
|
: "rgba(239,68,68,0.15)",
|
|
position: "absolute",
|
|
top: 2,
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
width: "70%",
|
|
height: 1,
|
|
background: k.up ? "var(--ok)" : "#ff4d5e",
|
|
position: "absolute",
|
|
top: 1,
|
|
left: 2,
|
|
borderRadius: 1,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Middle Row: Split Columns */}
|
|
<div style={{ display: "flex", gap: 5, flex: 1, minHeight: 0 }}>
|
|
{/* Left panel: Pipeline by stage (62%) */}
|
|
<div
|
|
style={{
|
|
flex: 62,
|
|
border: wireframeBorder,
|
|
borderRadius: 5,
|
|
padding: 5,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 4,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
fontSize: 6.5,
|
|
fontWeight: 600,
|
|
borderBottom: wireframeBorder,
|
|
paddingBottom: 2,
|
|
marginBottom: 2,
|
|
}}
|
|
>
|
|
<span>Pipeline by stage</span>
|
|
<span style={{ color: style.accentColor, opacity: 0.7 }}>
|
|
View board →
|
|
</span>
|
|
</div>
|
|
{/* Horizontal progress bars */}
|
|
{[
|
|
{ label: "Lead", w: "85%", val: "$420K" },
|
|
{ label: "Qualified", w: "70%", val: "$365K" },
|
|
{ label: "Proposal", w: "55%", val: "$280K" },
|
|
{ label: "Negotiation", w: "30%", val: "$148K" },
|
|
{ label: "Won", w: "20%", val: "$96K" },
|
|
].map((p, idx) => (
|
|
<div
|
|
key={idx}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
fontSize: 5.5,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
width: 34,
|
|
color: style.color,
|
|
opacity: 0.5,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{p.label}
|
|
</span>
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
height: 4,
|
|
background: isDark ? "#2a2a30" : "#ebebe6",
|
|
borderRadius: 2,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: p.w,
|
|
height: "100%",
|
|
background: style.accentColor,
|
|
opacity: 0.75,
|
|
borderRadius: 2,
|
|
}}
|
|
/>
|
|
</div>
|
|
<span
|
|
style={{
|
|
width: 22,
|
|
textAlign: "right",
|
|
fontWeight: 600,
|
|
color: style.color,
|
|
}}
|
|
>
|
|
{p.val}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Right panel: Today tasks (38%) */}
|
|
<div
|
|
style={{
|
|
flex: 38,
|
|
border: wireframeBorder,
|
|
borderRadius: 5,
|
|
padding: 5,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 3,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
fontSize: 6.5,
|
|
fontWeight: 600,
|
|
borderBottom: wireframeBorder,
|
|
paddingBottom: 2,
|
|
marginBottom: 2,
|
|
}}
|
|
>
|
|
<span>Today</span>
|
|
<span style={{ opacity: 0.4 }}>All tasks</span>
|
|
</div>
|
|
{[
|
|
{ text: "Acme Robotics", danger: true },
|
|
{ text: "Halcyon", danger: false },
|
|
{ text: "Call Northstar", danger: false },
|
|
{ text: "QBR deck", danger: false },
|
|
].map((t, idx) => (
|
|
<div
|
|
key={idx}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
fontSize: 5.5,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 6,
|
|
height: 6,
|
|
border: wireframeBorder,
|
|
borderRadius: 2,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<span
|
|
style={{
|
|
flex: 1,
|
|
color: style.color,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{t.text}
|
|
</span>
|
|
{t.danger && (
|
|
<div
|
|
style={{
|
|
width: 3,
|
|
height: 3,
|
|
borderRadius: "50%",
|
|
background: "#ff4d5e",
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Row: Recent Activity */}
|
|
<div
|
|
style={{
|
|
border: wireframeBorder,
|
|
borderRadius: 5,
|
|
padding: "4px 5px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 2,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
fontSize: 6,
|
|
color: style.color,
|
|
opacity: 0.35,
|
|
fontWeight: 700,
|
|
borderBottom: wireframeBorder,
|
|
paddingBottom: 2,
|
|
marginBottom: 1,
|
|
}}
|
|
>
|
|
<span>Recent activity</span>
|
|
<span>All</span>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
fontSize: 5,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: "50%",
|
|
background: "var(--accent-glow)",
|
|
color: style.accentColor,
|
|
display: "grid",
|
|
placeItems: "center",
|
|
fontWeight: 700,
|
|
fontSize: 4.5,
|
|
}}
|
|
>
|
|
MR
|
|
</div>
|
|
<span style={{ color: style.color, opacity: 0.8 }}>
|
|
Mira moved <b>Acme</b> to Negotiation
|
|
</span>
|
|
<span
|
|
style={{ marginLeft: "auto", color: style.color, opacity: 0.3 }}
|
|
>
|
|
12m
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OwnerDesign({ theme, onThemeChange, onTemplateChange }) {
|
|
// Always pre-select CRM template behind the scenes
|
|
React.useEffect(() => {
|
|
onTemplateChange("crm");
|
|
}, [onTemplateChange]);
|
|
|
|
return (
|
|
<>
|
|
<WizardQ
|
|
title="Which style do you like more?"
|
|
sub="Which style do you prefer for your workspace?"
|
|
/>
|
|
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
|
gap: 16,
|
|
width: "100%",
|
|
marginTop: 10,
|
|
}}
|
|
>
|
|
{THEMES.map((t) => {
|
|
const active = theme === t.id;
|
|
return (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
onClick={() => onThemeChange(t.id)}
|
|
style={{
|
|
textAlign: "left",
|
|
padding: "16px 18px",
|
|
borderRadius: 12,
|
|
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
|
|
background: active ? "var(--accent-soft)" : "var(--bg-1)",
|
|
boxShadow: active ? "0 0 0 3px var(--accent-glow)" : "none",
|
|
transition: "border-color .15s, background .15s, transform .1s",
|
|
color: "var(--fg)",
|
|
cursor: "pointer",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 12,
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!active)
|
|
e.currentTarget.style.borderColor = "var(--hairline-2)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!active)
|
|
e.currentTarget.style.borderColor = "var(--hairline)";
|
|
}}
|
|
>
|
|
{/* Render the CRM Home layout wireframe, live-skinned in this card's theme! */}
|
|
<ThemeWireframePreview themeId={t.id} />
|
|
|
|
<span
|
|
style={{ display: "flex", flexDirection: "column", gap: 3 }}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: 14.5,
|
|
fontWeight: 600,
|
|
letterSpacing: "-0.01em",
|
|
}}
|
|
>
|
|
{t.icon} {t.label}
|
|
</span>
|
|
<span
|
|
style={{
|
|
fontSize: 12,
|
|
color: "var(--fg-mute)",
|
|
lineHeight: 1.4,
|
|
}}
|
|
>
|
|
{t.desc}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Path wrapper ───────────────────────────────────────────────────────────
|
|
export function OwnerPath({
|
|
data,
|
|
onUpdate,
|
|
onBack,
|
|
onClose,
|
|
onComplete,
|
|
onJumpToStep,
|
|
step,
|
|
}) {
|
|
const [resolving, setResolving] = useState(false);
|
|
const [matches, setMatches] = useState<PlaceMatch[]>([]);
|
|
|
|
const next = async () => {
|
|
if (step === 0) {
|
|
if (resolving) return;
|
|
setResolving(true);
|
|
try {
|
|
const foundMatches = await searchPlaceMatches(
|
|
data.bizName || "",
|
|
data.bizCity,
|
|
);
|
|
setMatches(foundMatches);
|
|
setResolving(false);
|
|
} catch {
|
|
setResolving(false);
|
|
onUpdate({ biz: "other" });
|
|
onJumpToStep(step + 1);
|
|
}
|
|
} else if (step < OWNER_TOTAL - 1) {
|
|
onJumpToStep(step + 1);
|
|
} else {
|
|
onComplete();
|
|
}
|
|
};
|
|
|
|
const selectMatch = (m: PlaceMatch) => {
|
|
onUpdate({
|
|
biz: m.bizType,
|
|
bizName: m.placeId !== "manual" ? m.name : data.bizName,
|
|
bizNiche: m.typeLabel || "Local Business",
|
|
// Save their exact custom software needs so Step 2 renders them!
|
|
presetTools: m.presetTools || [],
|
|
});
|
|
setMatches([]);
|
|
onJumpToStep(step + 1);
|
|
};
|
|
|
|
const back = () => {
|
|
if (step === 0) {
|
|
if (matches.length > 0) {
|
|
setMatches([]); // go back to inputs
|
|
} else {
|
|
onBack();
|
|
}
|
|
} else {
|
|
onJumpToStep(step - 1);
|
|
}
|
|
};
|
|
|
|
const skipToDashboard = () => {
|
|
onUpdate({
|
|
buildDesc: "Undecided custom tool project",
|
|
tools: [
|
|
"Customer CRM",
|
|
"Invoicing & Payments",
|
|
"Appointment Scheduling Software",
|
|
],
|
|
theme: "minimal",
|
|
template: "crm",
|
|
});
|
|
setTimeout(onComplete, 50); // slight pause so state propagates
|
|
};
|
|
|
|
let body, canNext;
|
|
if (step === 0) {
|
|
body = (
|
|
<OwnerBiz
|
|
name={data.bizName || ""}
|
|
onNameChange={(v) => onUpdate({ bizName: v })}
|
|
city={data.bizCity}
|
|
onCityChange={(v) => onUpdate({ bizCity: v })}
|
|
website={data.bizWebsite || ""}
|
|
onWebsiteChange={(v) => onUpdate({ bizWebsite: v })}
|
|
resolving={resolving}
|
|
matches={matches}
|
|
onSelectMatch={selectMatch}
|
|
/>
|
|
);
|
|
canNext =
|
|
(data.bizName || "").trim().length >= 2 &&
|
|
!!data.bizCity &&
|
|
!resolving &&
|
|
matches.length === 0;
|
|
} else if (step === 1) {
|
|
body = (
|
|
<OwnerStack
|
|
bizName={data.bizName || ""}
|
|
city={data.bizCity}
|
|
bizType={data.biz}
|
|
tools={data.tools || []}
|
|
onToolsChange={(v) => onUpdate({ tools: v })}
|
|
presetTools={data.presetTools}
|
|
buildDesc={data.buildDesc || ""}
|
|
onBuildDescChange={(v) => onUpdate({ buildDesc: v })}
|
|
onSkip={skipToDashboard}
|
|
/>
|
|
);
|
|
canNext = (data.tools || []).length >= 1;
|
|
} else {
|
|
body = (
|
|
<OwnerDesign
|
|
theme={data.theme || "minimal"}
|
|
onThemeChange={(v) => onUpdate({ theme: v })}
|
|
onTemplateChange={(v) => onUpdate({ template: v })}
|
|
/>
|
|
);
|
|
canNext = !!data.theme && !!data.template;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<WizardTop
|
|
onBack={back}
|
|
onClose={onClose}
|
|
lane={LANE_LABELS.owner}
|
|
stepText={OWNER_STEP_NAMES[step]}
|
|
current={step + 2}
|
|
total={4}
|
|
/>
|
|
<WizardBody width={step === 0 ? "wide" : "wide"}>
|
|
{body}
|
|
<WizardFooter
|
|
onNext={next}
|
|
canNext={canNext}
|
|
nextLabel={
|
|
step === OWNER_TOTAL - 1 ? "Build my workspace →" : "Continue"
|
|
}
|
|
hint={canNext ? "⌘↵" : null}
|
|
/>
|
|
</WizardBody>
|
|
</>
|
|
);
|
|
}
|