597 lines
15 KiB
TypeScript
597 lines
15 KiB
TypeScript
// 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);
|
||
}
|