sproutkit-code 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +358 -19
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -195,6 +195,12 @@ var require_db = __commonJS({
195
195
  db2.exec("ALTER TABLE leads ADD COLUMN email TEXT");
196
196
  } catch {
197
197
  }
198
+ for (const col of ["lead_kind TEXT", "booking_status TEXT", "booking_platform TEXT"]) {
199
+ try {
200
+ db2.exec(`ALTER TABLE leads ADD COLUMN ${col}`);
201
+ } catch {
202
+ }
203
+ }
198
204
  }
199
205
  function getDb2() {
200
206
  return db2;
@@ -80961,6 +80967,7 @@ var require_places = __commonJS({
80961
80967
  exports2.scoreLead = scoreLead;
80962
80968
  exports2.searchPlacesStream = searchPlacesStream;
80963
80969
  exports2.searchPlaces = searchPlaces;
80970
+ exports2.findOneBusiness = findOneBusiness;
80964
80971
  exports2.geocodeForward = geocodeForward;
80965
80972
  exports2.geocodeReverse = geocodeReverse;
80966
80973
  var axios_1 = __importDefault(require_axios());
@@ -81027,10 +81034,10 @@ var require_places = __commonJS({
81027
81034
  }
81028
81035
  return { type: args.type, places: all };
81029
81036
  }
81030
- function processRawPlaces(type, places, center, radiusMeters) {
81037
+ function processRawPlaces(type, places, center, radiusMeters, includeWithWebsite) {
81031
81038
  const out = [];
81032
81039
  for (const p of places) {
81033
- if (p.websiteUri)
81040
+ if (p.websiteUri && !includeWithWebsite)
81034
81041
  continue;
81035
81042
  if (center && radiusMeters && p.location) {
81036
81043
  const d = haversineMeters(center.lat, center.lng, p.location.latitude, p.location.longitude);
@@ -81071,7 +81078,7 @@ var require_places = __commonJS({
81071
81078
  center: args.center,
81072
81079
  radiusMeters: args.radiusMeters
81073
81080
  });
81074
- await onBatch(processRawPlaces(type, places, args.center, args.radiusMeters));
81081
+ await onBatch(processRawPlaces(type, places, args.center, args.radiusMeters, args.includeWithWebsite));
81075
81082
  }));
81076
81083
  }
81077
81084
  async function searchPlaces(args) {
@@ -81139,6 +81146,23 @@ var require_places = __commonJS({
81139
81146
  const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
81140
81147
  return 2 * R * Math.asin(Math.sqrt(a));
81141
81148
  }
81149
+ async function findOneBusiness(args) {
81150
+ try {
81151
+ const r = await axios_1.default.post("https://places.googleapis.com/v1/places:searchText", { textQuery: args.query, maxResultCount: 1 }, {
81152
+ headers: {
81153
+ "X-Goog-Api-Key": args.apiKey,
81154
+ "X-Goog-FieldMask": "places.id,places.displayName,places.formattedAddress,places.nationalPhoneNumber,places.websiteUri,places.rating,places.userRatingCount,places.location,places.priceLevel,places.businessStatus,places.regularOpeningHours,places.primaryTypeDisplayName"
81155
+ }
81156
+ });
81157
+ const p = (r.data.places ?? [])[0];
81158
+ if (!p)
81159
+ return null;
81160
+ const results = processRawPlaces(p.primaryTypeDisplayName?.text || "local business", [p], void 0, void 0, true);
81161
+ return results[0] ?? null;
81162
+ } catch {
81163
+ return null;
81164
+ }
81165
+ }
81142
81166
  async function geocodeForward(query, limit = 5) {
81143
81167
  if (!query.trim())
81144
81168
  return [];
@@ -81163,6 +81187,99 @@ var require_places = __commonJS({
81163
81187
  }
81164
81188
  });
81165
81189
 
81190
+ // ../desktop/dist/main/lib/booking-detect.js
81191
+ var require_booking_detect = __commonJS({
81192
+ "../desktop/dist/main/lib/booking-detect.js"(exports2) {
81193
+ "use strict";
81194
+ var __importDefault = exports2 && exports2.__importDefault || function(mod) {
81195
+ return mod && mod.__esModule ? mod : { "default": mod };
81196
+ };
81197
+ Object.defineProperty(exports2, "__esModule", { value: true });
81198
+ exports2.checkBookingOnSite = checkBookingOnSite;
81199
+ exports2.isBookingProspect = isBookingProspect;
81200
+ var axios_1 = __importDefault(require_axios());
81201
+ var BOOKING_PLATFORMS = [
81202
+ { re: /fresha\.com/i, name: "Fresha" },
81203
+ { re: /treatwell|wahanda/i, name: "Treatwell" },
81204
+ { re: /booksy\./i, name: "Booksy" },
81205
+ { re: /opentable\./i, name: "OpenTable" },
81206
+ { re: /calendly\.com/i, name: "Calendly" },
81207
+ { re: /simplybook\.(me|it)/i, name: "SimplyBook" },
81208
+ { re: /setmore\.com/i, name: "Setmore" },
81209
+ { re: /squareup\.com\/(appointments|book)/i, name: "Square Appointments" },
81210
+ { re: /acuityscheduling\.com|squarespacescheduling/i, name: "Acuity" },
81211
+ { re: /resdiary\.com/i, name: "ResDiary" },
81212
+ { re: /quandoo\./i, name: "Quandoo" },
81213
+ { re: /thefork\.|lafourchette/i, name: "TheFork" },
81214
+ { re: /dojo\.tech|dojobookings/i, name: "Dojo" },
81215
+ { re: /bookwhen\.com/i, name: "Bookwhen" },
81216
+ { re: /timely\.?app|gettimely\.com/i, name: "Timely" },
81217
+ { re: /phorest/i, name: "Phorest" },
81218
+ { re: /shedul\.com/i, name: "Shedul" },
81219
+ { re: /mindbodyonline\.com/i, name: "Mindbody" },
81220
+ { re: /ovatu\.com/i, name: "Ovatu" },
81221
+ { re: /schedulicity\.com/i, name: "Schedulicity" },
81222
+ { re: /appointedd\.com/i, name: "Appointedd" },
81223
+ { re: /tablein\.com/i, name: "Tablein" },
81224
+ { re: /eveve\.com/i, name: "Eveve" },
81225
+ { re: /sevenrooms\.com/i, name: "SevenRooms" },
81226
+ { re: /booking\.com\/hotel/i, name: "Booking.com" },
81227
+ { re: /wix\.com\/bookings|wixapps.*booking/i, name: "Wix Bookings" },
81228
+ { re: /woocommerce-bookings|wc-bookings/i, name: "WooCommerce Bookings" },
81229
+ { re: /amelia(-booking)?|bookly/i, name: "WordPress booking plugin" }
81230
+ ];
81231
+ var MESSAGING_ONLY = [
81232
+ { re: /wa\.me\/|api\.whatsapp\.com|whatsapp:\/\//i, name: "WhatsApp" },
81233
+ { re: /instagram\.com\/(?!p\/)/i, name: "Instagram DM" },
81234
+ { re: /m\.me\/|messenger\.com/i, name: "Messenger" }
81235
+ ];
81236
+ async function checkBookingOnSite(websiteUrl) {
81237
+ if (!websiteUrl || !/^https?:\/\//i.test(websiteUrl))
81238
+ return { verdict: "unknown" };
81239
+ let html = "";
81240
+ try {
81241
+ const res = await axios_1.default.get(websiteUrl, {
81242
+ timeout: 15e3,
81243
+ maxRedirects: 5,
81244
+ responseType: "text",
81245
+ // Some hosts serve a bot-block page to a bare client.
81246
+ headers: {
81247
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
81248
+ Accept: "text/html,application/xhtml+xml"
81249
+ },
81250
+ // Don't choke on huge pages; the signals appear early enough.
81251
+ maxContentLength: 3e6,
81252
+ validateStatus: (s) => s >= 200 && s < 400
81253
+ });
81254
+ html = typeof res.data === "string" ? res.data : String(res.data ?? "");
81255
+ } catch {
81256
+ return { verdict: "unknown" };
81257
+ }
81258
+ if (!html)
81259
+ return { verdict: "unknown" };
81260
+ const platform = BOOKING_PLATFORMS.find((p) => p.re.test(html));
81261
+ if (platform)
81262
+ return { verdict: "has_online_booking", detail: platform.name };
81263
+ const wantsBookings = /book\s*(a|an|now|online|table|appointment)|reservation|appointment|make a booking/i.test(html);
81264
+ const messaging = MESSAGING_ONLY.find((m) => {
81265
+ const hit = m.re.exec(html);
81266
+ if (!hit)
81267
+ return false;
81268
+ const from = Math.max(0, hit.index - 300);
81269
+ const window2 = html.slice(from, hit.index + 300);
81270
+ return /book|reserv|appointment|enquir|order/i.test(window2);
81271
+ });
81272
+ if (messaging) {
81273
+ return { verdict: "messaging_only", detail: messaging.name, wantsBookings };
81274
+ }
81275
+ return { verdict: "no_booking", wantsBookings };
81276
+ }
81277
+ function isBookingProspect(check2) {
81278
+ return check2.verdict === "messaging_only" || check2.verdict === "no_booking";
81279
+ }
81280
+ }
81281
+ });
81282
+
81166
81283
  // ../desktop/dist/main/lib/lead-search.js
81167
81284
  var require_lead_search = __commonJS({
81168
81285
  "../desktop/dist/main/lib/lead-search.js"(exports2) {
@@ -81172,10 +81289,12 @@ var require_lead_search = __commonJS({
81172
81289
  exports2.searchAndSaveLeads = searchAndSaveLeads;
81173
81290
  var db_1 = require_db();
81174
81291
  var places_1 = require_places();
81292
+ var booking_detect_1 = require_booking_detect();
81175
81293
  async function searchAndSaveLeadsStream(args, onLead) {
81176
81294
  const db2 = (0, db_1.getDb)();
81177
81295
  const cap = args.maxResults ?? 50;
81178
81296
  const seen = /* @__PURE__ */ new Set();
81297
+ const bookingMode = args.mode === "no_booking";
81179
81298
  let saved = 0;
81180
81299
  let found = 0;
81181
81300
  await (0, places_1.searchPlacesStream)({
@@ -81183,8 +81302,10 @@ var require_lead_search = __commonJS({
81183
81302
  city: args.city,
81184
81303
  types: args.types,
81185
81304
  center: args.center,
81186
- radiusMeters: args.radiusMeters
81187
- }, (batch) => {
81305
+ radiusMeters: args.radiusMeters,
81306
+ // Booking mode needs the businesses that DO have a website.
81307
+ includeWithWebsite: bookingMode
81308
+ }, async (batch) => {
81188
81309
  for (const r of batch) {
81189
81310
  found++;
81190
81311
  if (saved >= cap)
@@ -81195,9 +81316,25 @@ var require_lead_search = __commonJS({
81195
81316
  const existing = db2.all("SELECT id FROM leads WHERE place_id = ?", [r.place_id]);
81196
81317
  if (existing.length > 0)
81197
81318
  continue;
81319
+ let leadKind = "no_website";
81320
+ let bookingStatus = null;
81321
+ let bookingPlatform = null;
81322
+ if (bookingMode) {
81323
+ if (!r.website)
81324
+ continue;
81325
+ args.onProgress?.(`Checking ${r.name}'s booking setup\u2026`);
81326
+ const check2 = await (0, booking_detect_1.checkBookingOnSite)(r.website);
81327
+ bookingStatus = check2.verdict;
81328
+ bookingPlatform = check2.detail ?? null;
81329
+ if (!(0, booking_detect_1.isBookingProspect)(check2))
81330
+ continue;
81331
+ leadKind = "no_booking";
81332
+ } else if (r.website) {
81333
+ continue;
81334
+ }
81198
81335
  db2.run(`INSERT INTO leads
81199
- (place_id, name, type, city, address, phone, website, rating, review_count, lat, lng, score, price_level, business_status, status)
81200
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered')`, [
81336
+ (place_id, name, type, city, address, phone, website, rating, review_count, lat, lng, score, price_level, business_status, status, lead_kind, booking_status, booking_platform)
81337
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered', ?, ?, ?)`, [
81201
81338
  r.place_id,
81202
81339
  r.name,
81203
81340
  r.type,
@@ -81211,11 +81348,23 @@ var require_lead_search = __commonJS({
81211
81348
  r.lng,
81212
81349
  r.score,
81213
81350
  r.price_level,
81214
- r.business_status
81351
+ r.business_status,
81352
+ leadKind,
81353
+ bookingStatus,
81354
+ bookingPlatform
81215
81355
  ]);
81216
81356
  const row = db2.get("SELECT id FROM leads WHERE place_id = ?", [r.place_id]);
81217
81357
  saved++;
81218
- onLead({ id: row?.id ?? 0, place_id: r.place_id, name: r.name, type: r.type, city: args.city });
81358
+ onLead({
81359
+ id: row?.id ?? 0,
81360
+ place_id: r.place_id,
81361
+ name: r.name,
81362
+ type: r.type,
81363
+ city: args.city,
81364
+ website: r.website,
81365
+ bookingStatus: bookingStatus ?? void 0,
81366
+ bookingDetail: bookingPlatform ?? void 0
81367
+ });
81219
81368
  }
81220
81369
  });
81221
81370
  return { found, saved };
@@ -93038,12 +93187,13 @@ var require_chat_tools = __commonJS({
93038
93187
  var builder_1 = require_builder();
93039
93188
  var lead_search_1 = require_lead_search();
93040
93189
  var places_1 = require_places();
93190
+ var booking_detect_1 = require_booking_detect();
93041
93191
  var email_1 = require_email();
93042
93192
  var email_harvest_1 = require_email_harvest();
93043
93193
  exports2.TOOLS = [
93044
93194
  {
93045
93195
  name: "find_leads",
93046
- description: "Search Google Places for local businesses that have NO website yet, and save them as new leads. This is the lead-finding step \u2014 use it whenever the user wants to find/discover/search for new prospects. Give it a place (town, postcode, area or address) and one or more business categories. Costs ~\xA30.025 per category searched against the user's Google Places key. Businesses that already have a website are filtered out automatically, and duplicates are skipped.",
93196
+ description: "Search Google Places for local business leads and save them. Two modes: mode='no_website' (default) finds businesses with NO website \u2014 sell them a demo site; mode='no_booking' finds businesses that HAVE a website but take bookings only via WhatsApp/phone (it fetches each site and checks for Fresha/Treatwell/OpenTable/Calendly etc) \u2014 sell those a standalone booking page. This is the lead-finding step \u2014 use it whenever the user wants to find/discover/search for new prospects. Give it a place (town, postcode, area or address) and one or more business categories. Costs ~\xA30.025 per category searched against the user's Google Places key. Businesses that already have a website are filtered out automatically, and duplicates are skipped.",
93047
93197
  input_schema: {
93048
93198
  type: "object",
93049
93199
  properties: {
@@ -93063,6 +93213,11 @@ var require_chat_tools = __commonJS({
93063
93213
  radiusMeters: {
93064
93214
  type: "number",
93065
93215
  description: "Search radius around the location in metres (default 10000 = 10km). Use a larger radius for big cities where central businesses mostly have websites; smaller (e.g. 3000) for a tight local search."
93216
+ },
93217
+ mode: {
93218
+ type: "string",
93219
+ enum: ["no_website", "no_booking"],
93220
+ description: "What kind of opportunity to hunt. 'no_website' (default) = businesses with no website at all \u2014 sell them a demo site. 'no_booking' = businesses that DO have a website but take bookings by WhatsApp/phone only \u2014 sell them a standalone booking page (sproutkit book <id> --standalone). Use 'no_booking' when the user asks for businesses without online booking, or wants booking-site customers."
93066
93221
  }
93067
93222
  },
93068
93223
  required: ["location", "categories"]
@@ -93147,6 +93302,38 @@ var require_chat_tools = __commonJS({
93147
93302
  },
93148
93303
  requiresConfirmation: true
93149
93304
  },
93305
+ {
93306
+ name: "booking_site_for_business",
93307
+ description: "ONE-SHOT: build a standalone booking site for a business the user NAMES, even if it isn't a lead yet. Give it the business name (include the town/area if known, e.g. 'Kings Barbers, Leeds'). It looks the business up on Google Places, reads their existing website to understand the business and check whether they already take bookings online, saves them as a lead, and deploys a booking page on its own URL for them to link from their site. Use this for requests like 'build a booking site for The Oak Barbers' when that business isn't in the pipeline. Costs a Places lookup + a Vercel deploy.",
93308
+ input_schema: {
93309
+ type: "object",
93310
+ properties: {
93311
+ business: {
93312
+ type: "string",
93313
+ description: "The business to look up \u2014 name plus town/area if known, e.g. 'Spice Garden takeaway Leeds'."
93314
+ },
93315
+ archetype: {
93316
+ type: "string",
93317
+ enum: ["slots", "reservation", "enquiry"],
93318
+ description: "Booking style: 'slots' = timed appointments, 'reservation' = table/party booking, 'enquiry' = a request form. Omit to auto-detect from the business type."
93319
+ },
93320
+ instructions: {
93321
+ type: "string",
93322
+ description: "Optional custom direction for the booking form (services, slot length, party size, copy)."
93323
+ },
93324
+ ownerEmail: {
93325
+ type: "string",
93326
+ description: "Optional owner email to notify on each booking."
93327
+ },
93328
+ force: {
93329
+ type: "boolean",
93330
+ description: "Build even if the business already appears to have online booking. Default false \u2014 normally we stop and say they already have it."
93331
+ }
93332
+ },
93333
+ required: ["business"]
93334
+ },
93335
+ requiresConfirmation: true
93336
+ },
93150
93337
  {
93151
93338
  name: "build_site",
93152
93339
  description: "Generate and deploy a demo website for a lead that doesn't have one yet. This calls Claude (uses the user's Anthropic key), generates real React code with Google photos & reviews, and deploys to Vercel. Takes 30-60 seconds. Burns ~\xA30.02 in Anthropic credits + 1 Vercel deployment. Set includeCms=true to also install a self-serve /admin editor on the customer's own domain (Klaudius-style hand-off). Use regenerate_site if the lead already has a website.",
@@ -93339,6 +93526,14 @@ var require_chat_tools = __commonJS({
93339
93526
  return `+44${p.slice(1)}`;
93340
93527
  return p;
93341
93528
  }
93529
+ function bookingOutreachTemplate(db2) {
93530
+ const row = db2.get("SELECT value FROM settings WHERE key = ?", ["outreach_template_booking"]);
93531
+ return row?.value ?? `Hi {name}, I saw you take bookings by {booking_how} \u2014 I've set up an online booking page you can link from your site: {site_url}
93532
+
93533
+ Customers pick a time themselves, you get an email for each booking. \xA3199 one-time, no monthly fee.
93534
+
93535
+ Want it live on your own domain?`;
93536
+ }
93342
93537
  function outreachTemplate(db2) {
93343
93538
  const row = db2.get("SELECT value FROM settings WHERE key = ?", ["outreach_template"]);
93344
93539
  return row?.value ?? `Hi, we built {name} a free demo website: {site_url}
@@ -93407,7 +93602,7 @@ Interested?`;
93407
93602
  const status = input.status;
93408
93603
  const city = input.city;
93409
93604
  const limit = Math.min(Number(input.limit ?? 25), 100);
93410
- let sql = "SELECT id, name, status, type, city, phone, site_url FROM leads";
93605
+ let sql = "SELECT id, name, status, type, city, phone, site_url, website, lead_kind, booking_status, booking_platform, booking_url FROM leads";
93411
93606
  const conds = [];
93412
93607
  const params = [];
93413
93608
  if (status) {
@@ -93484,7 +93679,7 @@ Interested?`;
93484
93679
  }
93485
93680
  case "prepare_outreach": {
93486
93681
  const explicit = input.ids;
93487
- const rows = explicit && explicit.length > 0 ? db2.all(`SELECT id, name, phone, email, address, city, site_url FROM leads WHERE id IN (${explicit.map(() => "?").join(",")})`, explicit) : db2.all("SELECT id, name, phone, email, address, city, site_url FROM leads WHERE status = 'site_built' AND site_url IS NOT NULL AND site_url != '' ORDER BY score DESC NULLS LAST, id DESC", []);
93682
+ const rows = explicit && explicit.length > 0 ? db2.all(`SELECT id, name, phone, email, address, city, site_url, lead_kind, booking_platform, booking_url FROM leads WHERE id IN (${explicit.map(() => "?").join(",")})`, explicit) : db2.all("SELECT id, name, phone, email, address, city, site_url, lead_kind, booking_platform, booking_url FROM leads WHERE status = 'site_built' AND site_url IS NOT NULL AND site_url != '' ORDER BY score DESC NULLS LAST, id DESC", []);
93488
93683
  const eligible = rows.filter((r) => r.site_url);
93489
93684
  if (eligible.length === 0) {
93490
93685
  return {
@@ -93496,12 +93691,14 @@ Interested?`;
93496
93691
  const requested = String(input.channel ?? "").toLowerCase();
93497
93692
  const channel = requested === "sms" || requested === "whatsapp" ? requested : channelRow?.value || "whatsapp";
93498
93693
  const template = outreachTemplate(db2);
93694
+ const bookingTemplate = bookingOutreachTemplate(db2);
93499
93695
  const smtp = (0, email_1.smtpFromSettings)((k) => getDecryptedSetting(k));
93500
93696
  const emailSubject = getDecryptedSetting("outreach_email_subject") || "A free demo website for {name}";
93501
93697
  const prepared = [];
93502
93698
  let harvested = 0;
93503
93699
  for (const r of eligible) {
93504
- const message = template.replace(/\{name\}/g, r.name).replace(/\{site_url\}/g, r.site_url ?? "");
93700
+ const isBookingLead = r.lead_kind === "no_booking";
93701
+ const message = (isBookingLead ? bookingTemplate : template).replace(/\{name\}/g, r.name).replace(/\{site_url\}/g, (isBookingLead ? r.booking_url : r.site_url) ?? r.site_url ?? "").replace(/\{booking_how\}/g, r.booking_platform || "WhatsApp/phone");
93505
93702
  const e164 = toE164(r.phone ?? "");
93506
93703
  if (!e164 && !r.email && smtp) {
93507
93704
  ctx.emitProgress?.({
@@ -93631,6 +93828,84 @@ Interested?`;
93631
93828
  });
93632
93829
  return { ok: true, queued: ids.length, concurrency, snapshot };
93633
93830
  }
93831
+ case "booking_site_for_business": {
93832
+ const query = String(input.business ?? "").trim();
93833
+ if (!query)
93834
+ return { ok: false, error: "Give me the business name (and town if you know it)." };
93835
+ const placesKey = getDecryptedSetting("places_key");
93836
+ if (!placesKey)
93837
+ throw new Error("Google Places API key not set");
93838
+ ctx.emitProgress?.({
93839
+ channel: "leads:searching",
93840
+ payload: { msg: `Looking up ${query}\u2026` }
93841
+ });
93842
+ const biz = await (0, places_1.findOneBusiness)({ apiKey: placesKey, query });
93843
+ if (!biz) {
93844
+ return { ok: false, error: `Couldn't find "${query}" on Google Places. Try adding the town, or check the spelling.` };
93845
+ }
93846
+ let check2 = { verdict: "unknown" };
93847
+ if (biz.website) {
93848
+ ctx.emitProgress?.({
93849
+ channel: "leads:searching",
93850
+ payload: { msg: `Reading ${biz.name}'s website\u2026` }
93851
+ });
93852
+ check2 = await (0, booking_detect_1.checkBookingOnSite)(biz.website);
93853
+ if (check2.verdict === "has_online_booking" && input.force !== true) {
93854
+ return {
93855
+ ok: false,
93856
+ error: `${biz.name} already takes bookings online via ${check2.detail}. Nothing to sell them \u2014 pass force=true to build anyway.`,
93857
+ business: biz.name,
93858
+ website: biz.website,
93859
+ bookingPlatform: check2.detail
93860
+ };
93861
+ }
93862
+ }
93863
+ const existing = db2.get("SELECT id FROM leads WHERE place_id = ?", [biz.place_id]);
93864
+ let leadId;
93865
+ if (existing) {
93866
+ leadId = existing.id;
93867
+ db2.run("UPDATE leads SET lead_kind = 'no_booking', booking_status = ?, booking_platform = ?, website = COALESCE(website, ?) WHERE id = ?", [check2.verdict, check2.detail ?? null, biz.website, leadId]);
93868
+ } else {
93869
+ db2.run(`INSERT INTO leads
93870
+ (place_id, name, type, city, address, phone, website, rating, review_count, lat, lng, score, price_level, business_status, status, lead_kind, booking_status, booking_platform)
93871
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered', 'no_booking', ?, ?)`, [
93872
+ biz.place_id,
93873
+ biz.name,
93874
+ biz.type,
93875
+ "",
93876
+ biz.address,
93877
+ biz.phone,
93878
+ biz.website,
93879
+ biz.rating,
93880
+ biz.review_count,
93881
+ biz.lat,
93882
+ biz.lng,
93883
+ biz.score,
93884
+ biz.price_level,
93885
+ biz.business_status,
93886
+ check2.verdict,
93887
+ check2.detail ?? null
93888
+ ]);
93889
+ leadId = db2.get("SELECT id FROM leads WHERE place_id = ?", [biz.place_id]).id;
93890
+ }
93891
+ ctx.emitProgress?.({ channel: "leads:changed", payload: { reason: "booking_site_for_business" } });
93892
+ const result = await ctx.invokeIpc("bookings:provision-standalone", {
93893
+ leadId,
93894
+ instructions: input.instructions,
93895
+ archetypeOverride: input.archetype,
93896
+ ownerEmail: input.ownerEmail
93897
+ });
93898
+ db2.run("UPDATE leads SET booking_url = ? WHERE id = ?", [result.bookingUrl, leadId]);
93899
+ return {
93900
+ ok: true,
93901
+ id: leadId,
93902
+ business: biz.name,
93903
+ website: biz.website,
93904
+ currentBooking: check2.verdict === "messaging_only" ? check2.detail : check2.verdict,
93905
+ standalone: true,
93906
+ ...result
93907
+ };
93908
+ }
93634
93909
  case "add_booking_site": {
93635
93910
  const id = Number(input.id);
93636
93911
  const lead = db2.get("SELECT site_url FROM leads WHERE id = ?", [id]);
@@ -94674,7 +94949,7 @@ var require_package2 = __commonJS({
94674
94949
  "package.json"(exports2, module2) {
94675
94950
  module2.exports = {
94676
94951
  name: "sproutkit-code",
94677
- version: "1.1.0",
94952
+ version: "1.2.0",
94678
94953
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
94679
94954
  bin: {
94680
94955
  sproutkit: "./dist/index.js"
@@ -95669,7 +95944,8 @@ async function cmdFind(argv) {
95669
95944
  const lead = payload.lead;
95670
95945
  if (lead) {
95671
95946
  streamed++;
95672
- say(` ${c.green("+")} ${c.bold(lead.name)} ${c.dim(`#${lead.id} \xB7 ${lead.type}`)}`);
95947
+ const how = lead.bookingStatus === "messaging_only" ? c.amber(` \xB7 books via ${lead.bookingDetail ?? "messaging"}`) : lead.bookingStatus === "no_booking" ? c.amber(" \xB7 no online booking") : "";
95948
+ say(` ${c.green("+")} ${c.bold(lead.name)} ${c.dim(`#${lead.id} \xB7 ${lead.type}`)}${how}`);
95673
95949
  }
95674
95950
  }
95675
95951
  };
@@ -95680,7 +95956,9 @@ async function cmdFind(argv) {
95680
95956
  location,
95681
95957
  categories: categories.length ? categories : [location],
95682
95958
  maxResults: flags.max ? Number(flags.max) : void 0,
95683
- radiusMeters: flags.radius ? Number(flags.radius) : void 0
95959
+ radiusMeters: flags.radius ? Number(flags.radius) : void 0,
95960
+ // --booking hunts businesses that HAVE a site but no online booking.
95961
+ mode: flags.booking ? "no_booking" : "no_website"
95684
95962
  },
95685
95963
  makeCtx({ json, onEvent })
95686
95964
  );
@@ -95846,6 +96124,29 @@ async function cmdPipeline(argv) {
95846
96124
  const queue = [...targets];
95847
96125
  async function runOne(t) {
95848
96126
  const setRow = (state, detail) => board?.update(rowFor(t, state, detail));
96127
+ if (t.lead_kind === "no_booking") {
96128
+ setRow("building", "building booking site\u2026");
96129
+ try {
96130
+ const booked = await runTool(
96131
+ "add_booking_site",
96132
+ { id: t.id, standalone: true },
96133
+ makeCtx({ json: true })
96134
+ );
96135
+ if (booked && booked.ok === false) {
96136
+ setRow("failed", booked.error ?? "booking build failed");
96137
+ results.push({ id: t.id, name: t.name, ok: false, error: booked.error });
96138
+ return;
96139
+ }
96140
+ setRow("building", "preparing outreach\u2026");
96141
+ await runTool("prepare_outreach", { ids: [t.id] }, makeCtx({ json: true }));
96142
+ setRow("built", booked.bookingUrl ?? "booking site live");
96143
+ results.push({ id: t.id, name: t.name, ok: true, url: booked.bookingUrl });
96144
+ } catch (e) {
96145
+ setRow("failed", e.message.slice(0, 60));
96146
+ results.push({ id: t.id, name: t.name, ok: false, error: e.message });
96147
+ }
96148
+ return;
96149
+ }
95849
96150
  setRow("building", "building site\u2026");
95850
96151
  try {
95851
96152
  const built = await runTool(
@@ -95974,9 +96275,42 @@ async function cmdCms(argv) {
95974
96275
  async function cmdBook(argv) {
95975
96276
  const { flags, rest } = parseArgs(argv);
95976
96277
  const json = !!flags.json;
96278
+ const positional = rest.join(" ").trim();
95977
96279
  const id = Number(flags.id ?? rest[0]);
96280
+ if (!flags.id && positional && Number.isNaN(Number(rest[0]))) {
96281
+ const status2 = json ? void 0 : new Status();
96282
+ if (status2) status2.start(`Building a booking site for ${positional}`);
96283
+ try {
96284
+ const r = await runTool(
96285
+ "booking_site_for_business",
96286
+ {
96287
+ business: positional,
96288
+ archetype: flags.archetype ? String(flags.archetype) : void 0,
96289
+ instructions: flags.instructions ? String(flags.instructions) : void 0,
96290
+ ownerEmail: flags.email ? String(flags.email) : void 0,
96291
+ force: !!flags.force
96292
+ },
96293
+ makeCtx({ json, status: status2 })
96294
+ );
96295
+ if (r && r.ok === false) {
96296
+ if (status2) status2.done(false, r.error ?? "Couldn't build it");
96297
+ if (json) emitJson(r);
96298
+ process.exitCode = 1;
96299
+ return;
96300
+ }
96301
+ if (status2) status2.done(true, `${r.business} \u2014 ${r.bookingUrl}`);
96302
+ autoSync();
96303
+ if (json) emitJson(r);
96304
+ else if (r.currentBooking) say(c.dim(` They currently take bookings via ${r.currentBooking}.`));
96305
+ } catch (e) {
96306
+ if (status2) status2.done(false, "Failed");
96307
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
96308
+ process.exitCode = 1;
96309
+ }
96310
+ return;
96311
+ }
95978
96312
  if (!id || Number.isNaN(id)) {
95979
- const err = 'Usage: sproutkit book <leadId> [--standalone] [--instructions "..."] [--email owner@x.com]';
96313
+ const err = 'Usage: sproutkit book <leadId|"Business Name, Town"> [--standalone] [--archetype \u2026] [--email owner@x.com]';
95980
96314
  json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
95981
96315
  process.exitCode = 1;
95982
96316
  return;
@@ -96113,11 +96447,13 @@ When a request is missing information the command needs, ask a SHORT, STRUCTURED
96113
96447
 
96114
96448
  COMMANDS:
96115
96449
  - sproutkit find "<area>" --categories "<type>,<type>" --json \u2014 find website-less leads
96450
+ - sproutkit find "<area>" --categories "\u2026" --booking --json \u2014 find businesses that HAVE a site but take bookings via WhatsApp/phone only (booking-page prospects)
96116
96451
  - sproutkit list --json [--status <s>] [--city <c>] \u2014 list leads (get ids)
96117
96452
  - sproutkit build <id> --json [--instructions "..."] [--cms] [--multipage] \u2014 build a demo site (--multipage = real Home/Services/About/Contact pages, not one scroll)
96118
96453
  - sproutkit build-all --json [--ids 3,5] [--concurrency 3] \u2014 build many at once
96119
96454
  - sproutkit regenerate <id> --instructions "..." --json \u2014 rebuild an existing site with changes
96120
96455
  - sproutkit cms <id> --json \u2014 add the editable CMS on top of the site we built them
96456
+ - sproutkit book "<Business Name, Town>" --json \u2014 ONE-SHOT: look up any business (even not a lead), read their site, build them a standalone booking page
96121
96457
  - sproutkit book <id> --json [--standalone] [--instructions "..."] [--archetype slots|reservation|enquiry] [--email x@y.com] \u2014 booking site (--standalone = its own URL for clients who already have a website)
96122
96458
  - sproutkit outreach --json [--ids 3,5] \u2014 prepare outreach links
96123
96459
  - sproutkit pipeline x3 --json [--book] [--limit 10] \u2014 full pipeline (build\u2192booking\u2192outreach) for many clients concurrently
@@ -96301,6 +96637,7 @@ async function main() {
96301
96637
  const USAGE = {
96302
96638
  find: [
96303
96639
  'sproutkit find "<area>" --categories "barber shop,cafe"',
96640
+ " --booking find businesses that HAVE a site but no online booking",
96304
96641
  " --max <n> max leads to save (default 25)",
96305
96642
  " --radius <m> search radius in metres (default 10000)",
96306
96643
  " --json machine-readable output"
@@ -96326,7 +96663,9 @@ async function main() {
96326
96663
  ],
96327
96664
  cms: ["sproutkit cms <leadId>", " adds the editable CMS to a site we already built"],
96328
96665
  book: [
96329
- "sproutkit book <leadId> [options]",
96666
+ 'sproutkit book <leadId | "Business Name, Town"> [options]',
96667
+ " naming a business that isn't a lead yet looks it up, reads their site,",
96668
+ " and builds them a standalone booking page in one shot",
96330
96669
  " --standalone booking site on its OWN url (client already has a site)",
96331
96670
  " --archetype <type> slots | reservation | enquiry",
96332
96671
  ' --instructions "\u2026" e.g. "45-min slots, add beard trim \xA312"',
@@ -96383,7 +96722,7 @@ async function main() {
96383
96722
  say(` ${c.bold("sproutkit build-all")} build many at once (--ids, --concurrency 3)`);
96384
96723
  say(` ${c.bold("sproutkit regenerate <id>")} rebuild a site (--instructions "\u2026")`);
96385
96724
  say(` ${c.bold("sproutkit cms <id>")} add the editable CMS to a built site`);
96386
- say(` ${c.bold("sproutkit book <id>")} booking site (--standalone for own-URL)`);
96725
+ say(` ${c.bold("sproutkit book <id|name>")} booking site (--standalone, or name any business)`);
96387
96726
  say(` ${c.bold("sproutkit outreach")} prep outreach links (--ids 3,5)`);
96388
96727
  say(` ${c.bold("sproutkit pipeline x3")} build+outreach many clients concurrently`);
96389
96728
  say(` ${c.bold("sproutkit setup")} set or update keys, licence, backend`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutkit-code",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Sproutkit in your terminal — find leads, build sites, run outreach, all via chat.",
5
5
  "bin": {
6
6
  "sproutkit": "./dist/index.js"