Files
vibn-agent-runner/vibn-frontend/_onboarding/onboarding-agency-mock.ts

597 lines
15 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.
// Mock data for the contractor onboarding. The UI is fully interactive against
// these. The cheaper model replaces each with a real endpoint (see
// onboarding-agency-types.ts for the exact shapes) — DO NOT delete the types;
// only swap the data source.
//
// Vibn builds CUSTOM TOOLS for local businesses. The consultant picks the tool
// categories they want to build; we intersect those with each SMB type's
// `softwareNeeds` (market_data_assets/smb_to_software_mapping_final.json) to
// recommend local businesses they could target.
import type {
CityRef,
TerritoryOpportunity,
ToolCategory,
} from "./onboarding-agency-types";
// ── Cities (DEV FALLBACK ONLY) ───────────────────────────────────────────────
// Production city lookup hits Places API (New) Autocomplete (global, 200M+
// places) via GET /api/agency/cities — see CityLookup, which calls that endpoint
// and only falls back to this list when offline.
export const STARTER_CITIES: CityRef[] = [
{
id: "victoria-bc",
name: "Victoria",
region: "BC",
country: "Canada",
countryCode: "CA",
},
{
id: "vancouver-bc",
name: "Vancouver",
region: "BC",
country: "Canada",
countryCode: "CA",
},
{
id: "nanaimo-bc",
name: "Nanaimo",
region: "BC",
country: "Canada",
countryCode: "CA",
},
{
id: "kelowna-bc",
name: "Kelowna",
region: "BC",
country: "Canada",
countryCode: "CA",
},
{
id: "calgary-ab",
name: "Calgary",
region: "AB",
country: "Canada",
countryCode: "CA",
},
{
id: "edmonton-ab",
name: "Edmonton",
region: "AB",
country: "Canada",
countryCode: "CA",
},
{
id: "toronto-on",
name: "Toronto",
region: "ON",
country: "Canada",
countryCode: "CA",
},
{
id: "ottawa-on",
name: "Ottawa",
region: "ON",
country: "Canada",
countryCode: "CA",
},
{
id: "montreal-qc",
name: "Montreal",
region: "QC",
country: "Canada",
countryCode: "CA",
},
{
id: "halifax-ns",
name: "Halifax",
region: "NS",
country: "Canada",
countryCode: "CA",
},
{
id: "seattle-wa",
name: "Seattle",
region: "WA",
country: "United States",
countryCode: "US",
},
{
id: "portland-or",
name: "Portland",
region: "OR",
country: "United States",
countryCode: "US",
},
{
id: "san-francisco-ca",
name: "San Francisco",
region: "CA",
country: "United States",
countryCode: "US",
},
{
id: "austin-tx",
name: "Austin",
region: "TX",
country: "United States",
countryCode: "US",
},
{
id: "denver-co",
name: "Denver",
region: "CO",
country: "United States",
countryCode: "US",
},
{
id: "chicago-il",
name: "Chicago",
region: "IL",
country: "United States",
countryCode: "US",
},
{
id: "new-york-ny",
name: "New York",
region: "NY",
country: "United States",
countryCode: "US",
},
{
id: "london-uk",
name: "London",
region: "England",
country: "United Kingdom",
countryCode: "GB",
},
{
id: "sydney-au",
name: "Sydney",
region: "NSW",
country: "Australia",
countryCode: "AU",
},
];
export const DEFAULT_CITY: CityRef = STARTER_CITIES[0];
export const cityLabel = (c: CityRef) => `${c.name}, ${c.region}`;
/** GET /api/agency/cities?q= — mock typeahead over the seed list. */
export function searchCities(query: string): CityRef[] {
const q = query.trim().toLowerCase();
if (!q) return STARTER_CITIES.slice(0, 8);
return STARTER_CITIES.filter(
(c) =>
cityLabel(c).toLowerCase().includes(q) ||
c.country.toLowerCase().includes(q),
).slice(0, 8);
}
// ── Tool categories the consultant can build ─────────────────────────────────
// GET /api/agency/tool-categories. `label` MUST match the strings in
// smb_to_software_mapping so the targeting intersection works. These are the
// common horizontal tools; the backend can expose the full ~524 from the mapping.
export const TOOL_CATEGORIES: ToolCategory[] = [
{
id: "reporting",
label: "Reporting / Dashboard Software",
short: "Reporting & dashboards",
icon: "chart",
},
{
id: "scheduling",
label: "Appointment Scheduling Software",
short: "Booking & scheduling",
icon: "calendar",
},
{ id: "crm", label: "CRM Software", short: "Customer CRM", icon: "users" },
{
id: "invoicing",
label: "Invoicing & Payments Software",
short: "Invoicing & payments",
icon: "receipt",
},
{
id: "billing",
label: "Billing Software",
short: "Billing",
icon: "receipt",
},
{
id: "inventory",
label: "Inventory Management Software",
short: "Inventory",
icon: "box",
},
{
id: "pos",
label: "Retail POS System",
short: "Point of sale",
icon: "card",
},
{
id: "ordering",
label: "Online Ordering Software",
short: "Online ordering",
icon: "cart",
},
{
id: "reservations",
label: "Reservations & Online Bookings",
short: "Reservations",
icon: "calendar",
},
{
id: "fsm",
label: "Field Service Management (FSM)",
short: "Field service / dispatch",
icon: "clipboard",
},
{
id: "staff",
label: "Employee Scheduling Software",
short: "Staff scheduling",
icon: "users",
},
{
id: "membership",
label: "Membership Management Software",
short: "Memberships",
icon: "badge",
},
{
id: "marketing",
label: "Marketing Automation Software",
short: "Marketing automation",
icon: "megaphone",
},
{
id: "accounting",
label: "Accounting Software",
short: "Accounting",
icon: "chart",
},
{
id: "orders",
label: "Order Management Software",
short: "Order management",
icon: "clipboard",
},
];
// ── SMB types + their software needs (sample from the mapping) ────────────────
// Real targeting reads the full smb_to_software_mapping; this is a grounded
// sample so the intersection responds to selections offline. Counts are mock.
type SmbTarget = {
gcid: string;
displayName: string;
softwareNeeds: string[];
businessCount: number;
weakPct: number;
claimed: number;
};
const SMB_TARGETS: SmbTarget[] = [
{
gcid: "gcid:dentist",
displayName: "Dentists",
businessCount: 312,
weakPct: 0.71,
claimed: 0,
softwareNeeds: [
"Dental Practice Management",
"EHR / EMR Software",
"Medical Billing Software",
"Patient Scheduling Software",
"Appointment Scheduling Software",
],
},
{
gcid: "gcid:plumber",
displayName: "Plumbers",
businessCount: 287,
weakPct: 0.86,
claimed: 0,
softwareNeeds: [
"Plumbing Software",
"Field Service Management (FSM)",
"Scheduling Software",
"Billing Software",
"Invoicing & Payments Software",
],
},
{
gcid: "gcid:hvac_contractor",
displayName: "HVAC contractors",
businessCount: 248,
weakPct: 0.83,
claimed: 1,
softwareNeeds: [
"HVAC Software",
"Field Service Management (FSM)",
"Accounting Software",
"Invoicing & Payments Software",
],
},
{
gcid: "gcid:hair_salon",
displayName: "Hair salons",
businessCount: 524,
weakPct: 0.74,
claimed: 6,
softwareNeeds: [
"Salon / Spa Management Software",
"Appointment Scheduling Software",
"Billing Software",
"CRM Software",
],
},
{
gcid: "gcid:beauty_salon",
displayName: "Beauty salons",
businessCount: 411,
weakPct: 0.76,
claimed: 3,
softwareNeeds: [
"Salon / Spa Management Software",
"Appointment Scheduling Software",
"CRM Software",
"Retail POS System",
"Accounting Software",
],
},
{
gcid: "gcid:auto_repair_shop",
displayName: "Auto repair shops",
businessCount: 198,
weakPct: 0.8,
claimed: 1,
softwareNeeds: [
"Auto Repair Shop Software",
"Invoicing & Payments Software",
"Appointment Scheduling Software",
"Inventory Management Software",
"CRM Software",
],
},
{
gcid: "gcid:gym",
displayName: "Gyms & fitness studios",
businessCount: 134,
weakPct: 0.58,
claimed: 2,
softwareNeeds: [
"Fitness & Gym Management Software",
"Membership Management Software",
"Appointment Scheduling Software",
"Billing Software",
"CRM Software",
],
},
{
gcid: "gcid:restaurant",
displayName: "Restaurants",
businessCount: 642,
weakPct: 0.69,
claimed: 5,
softwareNeeds: [
"Restaurant POS Software",
"Inventory Management Software",
"Employee Scheduling Software",
"Online Ordering Software",
],
},
{
gcid: "gcid:medical_spa",
displayName: "Med spas",
businessCount: 96,
weakPct: 0.62,
claimed: 0,
softwareNeeds: [
"Medical Spa Software",
"Appointment Scheduling Software",
"CRM Software",
"Retail POS System",
"Marketing Automation Software",
],
},
{
gcid: "gcid:electrician",
displayName: "Electricians",
businessCount: 233,
weakPct: 0.82,
claimed: 1,
softwareNeeds: [
"Electrical Contractor Software",
"Field Service Management (FSM)",
"Accounting Software",
"Invoicing & Payments Software",
],
},
{
gcid: "gcid:veterinarian",
displayName: "Veterinarians",
businessCount: 88,
weakPct: 0.64,
claimed: 0,
softwareNeeds: [
"Veterinary Management Software",
"Appointment Scheduling Software",
"Inventory Management Software",
"Medical Billing Software",
],
},
{
gcid: "gcid:coffee_shop",
displayName: "Coffee shops",
businessCount: 276,
weakPct: 0.67,
claimed: 4,
softwareNeeds: [
"Restaurant POS Software",
"Inventory Management Software",
"Employee Scheduling Software",
"Marketing Automation Software",
"Online Ordering Software",
],
},
{
gcid: "gcid:florist",
displayName: "Florists",
businessCount: 109,
weakPct: 0.78,
claimed: 0,
softwareNeeds: [
"Florist Software",
"Retail POS System",
"Inventory Management Software",
"Online Ordering Software",
"Order Management Software",
],
},
{
gcid: "gcid:landscaper",
displayName: "Landscapers",
businessCount: 264,
weakPct: 0.85,
claimed: 1,
softwareNeeds: [
"Landscape / Lawn Care Software",
"Field Service Management (FSM)",
"Appointment Scheduling Software",
"Billing Software",
"CRM Software",
],
},
{
gcid: "gcid:accounting_firm",
displayName: "Accounting firms",
businessCount: 187,
weakPct: 0.59,
claimed: 2,
softwareNeeds: [
"Accounting Practice Management",
"Accounting Software",
"CRM Software",
],
},
{
gcid: "gcid:bakery",
displayName: "Bakeries",
businessCount: 142,
weakPct: 0.72,
claimed: 0,
softwareNeeds: [
"Bakery Software",
"Inventory Management Software",
"Order Management Software",
"Employee Scheduling Software",
],
},
];
// ── AI expertise → tool categories ───────────────────────────────────────────
// POST /api/agency/analyze-expertise { text } -> { tools }. The real version is
// an LLM call; this is a keyword stub so the flow works offline. Maps free-text
// expertise to canonical tool-category labels that join to the SMB mapping.
const EXPERTISE_KEYWORDS: Array<{ re: RegExp; label: string }> = [
{
re: /report|dashboard|analytic|insight|\bbi\b/i,
label: "Reporting / Dashboard Software",
},
{
re: /crm|customer relationship|\bleads?\b|\bclients?\b/i,
label: "CRM Software",
},
{
re: /schedul|book|appointment|calendar/i,
label: "Appointment Scheduling Software",
},
{ re: /invoic|payment/i, label: "Invoicing & Payments Software" },
{ re: /billing/i, label: "Billing Software" },
{ re: /inventor|stock/i, label: "Inventory Management Software" },
{
re: /\bpos\b|point of sale|checkout|register/i,
label: "Retail POS System",
},
{ re: /online order|ordering|takeout/i, label: "Online Ordering Software" },
{ re: /reservation/i, label: "Reservations & Online Bookings" },
{
re: /field service|dispatch|\bfsm\b|work order/i,
label: "Field Service Management (FSM)",
},
{
re: /staff schedul|employee schedul|\brota\b|\bshifts?\b/i,
label: "Employee Scheduling Software",
},
{ re: /member/i, label: "Membership Management Software" },
{
re: /marketing|campaign|email automat|newsletter/i,
label: "Marketing Automation Software",
},
{ re: /account|bookkeep/i, label: "Accounting Software" },
{
re: /order management|fulfilment|fulfillment/i,
label: "Order Management Software",
},
];
export function extractTools(text: string): string[] {
const out: string[] = [];
for (const k of EXPERTISE_KEYWORDS) {
if (k.re.test(text) && !out.includes(k.label)) out.push(k.label);
}
return out;
}
/**
* POST /api/agency/targets { city, tools }
* Recommends SMB types whose softwareNeeds intersect the consultant's tools,
* scored by demand × gap × low Vibn saturation, biased toward how many of the
* consultant's tools each SMB needs.
*/
export function mockTargets(
city: CityRef,
tools: string[],
): TerritoryOpportunity[] {
const want = new Set(tools);
// Reporting / dashboards apply to virtually every business, so treat it as a
// universal need rather than listing it on every SMB type.
const UNIVERSAL = ["Reporting / Dashboard Software"];
return SMB_TARGETS.map((s, i) => {
const needs = [
...s.softwareNeeds,
...UNIVERSAL.filter((u) => !s.softwareNeeds.includes(u)),
];
const matchedTools = needs.filter((n) => want.has(n));
return { s, matchedTools, i };
})
.filter((x) => x.matchedTools.length > 0)
.map(({ s, matchedTools, i }) => {
const weak = Math.round(s.businessCount * s.weakPct);
const saturation = s.claimed / Math.max(1, s.businessCount / 20);
const demand = s.weakPct * (1 - Math.min(1, saturation));
const fit = Math.min(1, matchedTools.length / Math.max(1, tools.length));
const score = Math.max(
28,
Math.min(98, Math.round((demand * 0.7 + fit * 0.3) * 100)),
);
const status: TerritoryOpportunity["status"] =
s.claimed === 0 ? "open" : s.claimed <= 2 ? "contested" : "claimed";
return {
id: `${s.gcid}-${i}`,
gcid: s.gcid,
niche: s.displayName,
city: city.name,
businessCount: s.businessCount,
weakWebsiteCount: weak,
vibnClaimedCount: s.claimed,
opportunityScore: score,
status,
matchedTools,
};
})
.sort((a, b) => b.opportunityScore - a.opportunityScore);
}