sproutkit-code 1.1.0 → 1.2.1
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.
- package/dist/index.js +374 -20
- 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;
|
|
@@ -80736,7 +80742,22 @@ export default defineConfig({ plugins: [react()] });`;
|
|
|
80736
80742
|
encoding: "utf-8"
|
|
80737
80743
|
};
|
|
80738
80744
|
onProgress("Deploying booking site\u2026");
|
|
80739
|
-
const res = await postVercelDeployment({
|
|
80745
|
+
const res = await postVercelDeployment({
|
|
80746
|
+
name: projectName,
|
|
80747
|
+
files,
|
|
80748
|
+
// REQUIRED for a new Vercel project — omitting it fails every first
|
|
80749
|
+
// deploy with "The `projectSettings` object is required for new
|
|
80750
|
+
// projects". Unlike the site builds this is NOT a Vite app: it's static
|
|
80751
|
+
// HTML plus serverless functions under api/, so declare no framework and
|
|
80752
|
+
// no build step rather than copying framework:"vite" from the others.
|
|
80753
|
+
projectSettings: {
|
|
80754
|
+
framework: null,
|
|
80755
|
+
buildCommand: null,
|
|
80756
|
+
installCommand: null,
|
|
80757
|
+
outputDirectory: null
|
|
80758
|
+
},
|
|
80759
|
+
target: "production"
|
|
80760
|
+
}, vercelToken, onProgress);
|
|
80740
80761
|
await axios_1.default.patch(`https://api.vercel.com/v9/projects/${res.data.name}`, { ssoProtection: null }, { headers: { Authorization: `Bearer ${vercelToken}` } }).catch(() => null);
|
|
80741
80762
|
const publicUrl = await stableProjectUrl({
|
|
80742
80763
|
projectName,
|
|
@@ -80961,6 +80982,7 @@ var require_places = __commonJS({
|
|
|
80961
80982
|
exports2.scoreLead = scoreLead;
|
|
80962
80983
|
exports2.searchPlacesStream = searchPlacesStream;
|
|
80963
80984
|
exports2.searchPlaces = searchPlaces;
|
|
80985
|
+
exports2.findOneBusiness = findOneBusiness;
|
|
80964
80986
|
exports2.geocodeForward = geocodeForward;
|
|
80965
80987
|
exports2.geocodeReverse = geocodeReverse;
|
|
80966
80988
|
var axios_1 = __importDefault(require_axios());
|
|
@@ -81027,10 +81049,10 @@ var require_places = __commonJS({
|
|
|
81027
81049
|
}
|
|
81028
81050
|
return { type: args.type, places: all };
|
|
81029
81051
|
}
|
|
81030
|
-
function processRawPlaces(type, places, center, radiusMeters) {
|
|
81052
|
+
function processRawPlaces(type, places, center, radiusMeters, includeWithWebsite) {
|
|
81031
81053
|
const out = [];
|
|
81032
81054
|
for (const p of places) {
|
|
81033
|
-
if (p.websiteUri)
|
|
81055
|
+
if (p.websiteUri && !includeWithWebsite)
|
|
81034
81056
|
continue;
|
|
81035
81057
|
if (center && radiusMeters && p.location) {
|
|
81036
81058
|
const d = haversineMeters(center.lat, center.lng, p.location.latitude, p.location.longitude);
|
|
@@ -81071,7 +81093,7 @@ var require_places = __commonJS({
|
|
|
81071
81093
|
center: args.center,
|
|
81072
81094
|
radiusMeters: args.radiusMeters
|
|
81073
81095
|
});
|
|
81074
|
-
await onBatch(processRawPlaces(type, places, args.center, args.radiusMeters));
|
|
81096
|
+
await onBatch(processRawPlaces(type, places, args.center, args.radiusMeters, args.includeWithWebsite));
|
|
81075
81097
|
}));
|
|
81076
81098
|
}
|
|
81077
81099
|
async function searchPlaces(args) {
|
|
@@ -81139,6 +81161,23 @@ var require_places = __commonJS({
|
|
|
81139
81161
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
|
81140
81162
|
return 2 * R * Math.asin(Math.sqrt(a));
|
|
81141
81163
|
}
|
|
81164
|
+
async function findOneBusiness(args) {
|
|
81165
|
+
try {
|
|
81166
|
+
const r = await axios_1.default.post("https://places.googleapis.com/v1/places:searchText", { textQuery: args.query, maxResultCount: 1 }, {
|
|
81167
|
+
headers: {
|
|
81168
|
+
"X-Goog-Api-Key": args.apiKey,
|
|
81169
|
+
"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"
|
|
81170
|
+
}
|
|
81171
|
+
});
|
|
81172
|
+
const p = (r.data.places ?? [])[0];
|
|
81173
|
+
if (!p)
|
|
81174
|
+
return null;
|
|
81175
|
+
const results = processRawPlaces(p.primaryTypeDisplayName?.text || "local business", [p], void 0, void 0, true);
|
|
81176
|
+
return results[0] ?? null;
|
|
81177
|
+
} catch {
|
|
81178
|
+
return null;
|
|
81179
|
+
}
|
|
81180
|
+
}
|
|
81142
81181
|
async function geocodeForward(query, limit = 5) {
|
|
81143
81182
|
if (!query.trim())
|
|
81144
81183
|
return [];
|
|
@@ -81163,6 +81202,99 @@ var require_places = __commonJS({
|
|
|
81163
81202
|
}
|
|
81164
81203
|
});
|
|
81165
81204
|
|
|
81205
|
+
// ../desktop/dist/main/lib/booking-detect.js
|
|
81206
|
+
var require_booking_detect = __commonJS({
|
|
81207
|
+
"../desktop/dist/main/lib/booking-detect.js"(exports2) {
|
|
81208
|
+
"use strict";
|
|
81209
|
+
var __importDefault = exports2 && exports2.__importDefault || function(mod) {
|
|
81210
|
+
return mod && mod.__esModule ? mod : { "default": mod };
|
|
81211
|
+
};
|
|
81212
|
+
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
81213
|
+
exports2.checkBookingOnSite = checkBookingOnSite;
|
|
81214
|
+
exports2.isBookingProspect = isBookingProspect;
|
|
81215
|
+
var axios_1 = __importDefault(require_axios());
|
|
81216
|
+
var BOOKING_PLATFORMS = [
|
|
81217
|
+
{ re: /fresha\.com/i, name: "Fresha" },
|
|
81218
|
+
{ re: /treatwell|wahanda/i, name: "Treatwell" },
|
|
81219
|
+
{ re: /booksy\./i, name: "Booksy" },
|
|
81220
|
+
{ re: /opentable\./i, name: "OpenTable" },
|
|
81221
|
+
{ re: /calendly\.com/i, name: "Calendly" },
|
|
81222
|
+
{ re: /simplybook\.(me|it)/i, name: "SimplyBook" },
|
|
81223
|
+
{ re: /setmore\.com/i, name: "Setmore" },
|
|
81224
|
+
{ re: /squareup\.com\/(appointments|book)/i, name: "Square Appointments" },
|
|
81225
|
+
{ re: /acuityscheduling\.com|squarespacescheduling/i, name: "Acuity" },
|
|
81226
|
+
{ re: /resdiary\.com/i, name: "ResDiary" },
|
|
81227
|
+
{ re: /quandoo\./i, name: "Quandoo" },
|
|
81228
|
+
{ re: /thefork\.|lafourchette/i, name: "TheFork" },
|
|
81229
|
+
{ re: /dojo\.tech|dojobookings/i, name: "Dojo" },
|
|
81230
|
+
{ re: /bookwhen\.com/i, name: "Bookwhen" },
|
|
81231
|
+
{ re: /timely\.?app|gettimely\.com/i, name: "Timely" },
|
|
81232
|
+
{ re: /phorest/i, name: "Phorest" },
|
|
81233
|
+
{ re: /shedul\.com/i, name: "Shedul" },
|
|
81234
|
+
{ re: /mindbodyonline\.com/i, name: "Mindbody" },
|
|
81235
|
+
{ re: /ovatu\.com/i, name: "Ovatu" },
|
|
81236
|
+
{ re: /schedulicity\.com/i, name: "Schedulicity" },
|
|
81237
|
+
{ re: /appointedd\.com/i, name: "Appointedd" },
|
|
81238
|
+
{ re: /tablein\.com/i, name: "Tablein" },
|
|
81239
|
+
{ re: /eveve\.com/i, name: "Eveve" },
|
|
81240
|
+
{ re: /sevenrooms\.com/i, name: "SevenRooms" },
|
|
81241
|
+
{ re: /booking\.com\/hotel/i, name: "Booking.com" },
|
|
81242
|
+
{ re: /wix\.com\/bookings|wixapps.*booking/i, name: "Wix Bookings" },
|
|
81243
|
+
{ re: /woocommerce-bookings|wc-bookings/i, name: "WooCommerce Bookings" },
|
|
81244
|
+
{ re: /amelia(-booking)?|bookly/i, name: "WordPress booking plugin" }
|
|
81245
|
+
];
|
|
81246
|
+
var MESSAGING_ONLY = [
|
|
81247
|
+
{ re: /wa\.me\/|api\.whatsapp\.com|whatsapp:\/\//i, name: "WhatsApp" },
|
|
81248
|
+
{ re: /instagram\.com\/(?!p\/)/i, name: "Instagram DM" },
|
|
81249
|
+
{ re: /m\.me\/|messenger\.com/i, name: "Messenger" }
|
|
81250
|
+
];
|
|
81251
|
+
async function checkBookingOnSite(websiteUrl) {
|
|
81252
|
+
if (!websiteUrl || !/^https?:\/\//i.test(websiteUrl))
|
|
81253
|
+
return { verdict: "unknown" };
|
|
81254
|
+
let html = "";
|
|
81255
|
+
try {
|
|
81256
|
+
const res = await axios_1.default.get(websiteUrl, {
|
|
81257
|
+
timeout: 15e3,
|
|
81258
|
+
maxRedirects: 5,
|
|
81259
|
+
responseType: "text",
|
|
81260
|
+
// Some hosts serve a bot-block page to a bare client.
|
|
81261
|
+
headers: {
|
|
81262
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
|
|
81263
|
+
Accept: "text/html,application/xhtml+xml"
|
|
81264
|
+
},
|
|
81265
|
+
// Don't choke on huge pages; the signals appear early enough.
|
|
81266
|
+
maxContentLength: 3e6,
|
|
81267
|
+
validateStatus: (s) => s >= 200 && s < 400
|
|
81268
|
+
});
|
|
81269
|
+
html = typeof res.data === "string" ? res.data : String(res.data ?? "");
|
|
81270
|
+
} catch {
|
|
81271
|
+
return { verdict: "unknown" };
|
|
81272
|
+
}
|
|
81273
|
+
if (!html)
|
|
81274
|
+
return { verdict: "unknown" };
|
|
81275
|
+
const platform = BOOKING_PLATFORMS.find((p) => p.re.test(html));
|
|
81276
|
+
if (platform)
|
|
81277
|
+
return { verdict: "has_online_booking", detail: platform.name };
|
|
81278
|
+
const wantsBookings = /book\s*(a|an|now|online|table|appointment)|reservation|appointment|make a booking/i.test(html);
|
|
81279
|
+
const messaging = MESSAGING_ONLY.find((m) => {
|
|
81280
|
+
const hit = m.re.exec(html);
|
|
81281
|
+
if (!hit)
|
|
81282
|
+
return false;
|
|
81283
|
+
const from = Math.max(0, hit.index - 300);
|
|
81284
|
+
const window2 = html.slice(from, hit.index + 300);
|
|
81285
|
+
return /book|reserv|appointment|enquir|order/i.test(window2);
|
|
81286
|
+
});
|
|
81287
|
+
if (messaging) {
|
|
81288
|
+
return { verdict: "messaging_only", detail: messaging.name, wantsBookings };
|
|
81289
|
+
}
|
|
81290
|
+
return { verdict: "no_booking", wantsBookings };
|
|
81291
|
+
}
|
|
81292
|
+
function isBookingProspect(check2) {
|
|
81293
|
+
return check2.verdict === "messaging_only" || check2.verdict === "no_booking";
|
|
81294
|
+
}
|
|
81295
|
+
}
|
|
81296
|
+
});
|
|
81297
|
+
|
|
81166
81298
|
// ../desktop/dist/main/lib/lead-search.js
|
|
81167
81299
|
var require_lead_search = __commonJS({
|
|
81168
81300
|
"../desktop/dist/main/lib/lead-search.js"(exports2) {
|
|
@@ -81172,10 +81304,12 @@ var require_lead_search = __commonJS({
|
|
|
81172
81304
|
exports2.searchAndSaveLeads = searchAndSaveLeads;
|
|
81173
81305
|
var db_1 = require_db();
|
|
81174
81306
|
var places_1 = require_places();
|
|
81307
|
+
var booking_detect_1 = require_booking_detect();
|
|
81175
81308
|
async function searchAndSaveLeadsStream(args, onLead) {
|
|
81176
81309
|
const db2 = (0, db_1.getDb)();
|
|
81177
81310
|
const cap = args.maxResults ?? 50;
|
|
81178
81311
|
const seen = /* @__PURE__ */ new Set();
|
|
81312
|
+
const bookingMode = args.mode === "no_booking";
|
|
81179
81313
|
let saved = 0;
|
|
81180
81314
|
let found = 0;
|
|
81181
81315
|
await (0, places_1.searchPlacesStream)({
|
|
@@ -81183,8 +81317,10 @@ var require_lead_search = __commonJS({
|
|
|
81183
81317
|
city: args.city,
|
|
81184
81318
|
types: args.types,
|
|
81185
81319
|
center: args.center,
|
|
81186
|
-
radiusMeters: args.radiusMeters
|
|
81187
|
-
|
|
81320
|
+
radiusMeters: args.radiusMeters,
|
|
81321
|
+
// Booking mode needs the businesses that DO have a website.
|
|
81322
|
+
includeWithWebsite: bookingMode
|
|
81323
|
+
}, async (batch) => {
|
|
81188
81324
|
for (const r of batch) {
|
|
81189
81325
|
found++;
|
|
81190
81326
|
if (saved >= cap)
|
|
@@ -81195,9 +81331,25 @@ var require_lead_search = __commonJS({
|
|
|
81195
81331
|
const existing = db2.all("SELECT id FROM leads WHERE place_id = ?", [r.place_id]);
|
|
81196
81332
|
if (existing.length > 0)
|
|
81197
81333
|
continue;
|
|
81334
|
+
let leadKind = "no_website";
|
|
81335
|
+
let bookingStatus = null;
|
|
81336
|
+
let bookingPlatform = null;
|
|
81337
|
+
if (bookingMode) {
|
|
81338
|
+
if (!r.website)
|
|
81339
|
+
continue;
|
|
81340
|
+
args.onProgress?.(`Checking ${r.name}'s booking setup\u2026`);
|
|
81341
|
+
const check2 = await (0, booking_detect_1.checkBookingOnSite)(r.website);
|
|
81342
|
+
bookingStatus = check2.verdict;
|
|
81343
|
+
bookingPlatform = check2.detail ?? null;
|
|
81344
|
+
if (!(0, booking_detect_1.isBookingProspect)(check2))
|
|
81345
|
+
continue;
|
|
81346
|
+
leadKind = "no_booking";
|
|
81347
|
+
} else if (r.website) {
|
|
81348
|
+
continue;
|
|
81349
|
+
}
|
|
81198
81350
|
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')`, [
|
|
81351
|
+
(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)
|
|
81352
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered', ?, ?, ?)`, [
|
|
81201
81353
|
r.place_id,
|
|
81202
81354
|
r.name,
|
|
81203
81355
|
r.type,
|
|
@@ -81211,11 +81363,23 @@ var require_lead_search = __commonJS({
|
|
|
81211
81363
|
r.lng,
|
|
81212
81364
|
r.score,
|
|
81213
81365
|
r.price_level,
|
|
81214
|
-
r.business_status
|
|
81366
|
+
r.business_status,
|
|
81367
|
+
leadKind,
|
|
81368
|
+
bookingStatus,
|
|
81369
|
+
bookingPlatform
|
|
81215
81370
|
]);
|
|
81216
81371
|
const row = db2.get("SELECT id FROM leads WHERE place_id = ?", [r.place_id]);
|
|
81217
81372
|
saved++;
|
|
81218
|
-
onLead({
|
|
81373
|
+
onLead({
|
|
81374
|
+
id: row?.id ?? 0,
|
|
81375
|
+
place_id: r.place_id,
|
|
81376
|
+
name: r.name,
|
|
81377
|
+
type: r.type,
|
|
81378
|
+
city: args.city,
|
|
81379
|
+
website: r.website,
|
|
81380
|
+
bookingStatus: bookingStatus ?? void 0,
|
|
81381
|
+
bookingDetail: bookingPlatform ?? void 0
|
|
81382
|
+
});
|
|
81219
81383
|
}
|
|
81220
81384
|
});
|
|
81221
81385
|
return { found, saved };
|
|
@@ -93038,12 +93202,13 @@ var require_chat_tools = __commonJS({
|
|
|
93038
93202
|
var builder_1 = require_builder();
|
|
93039
93203
|
var lead_search_1 = require_lead_search();
|
|
93040
93204
|
var places_1 = require_places();
|
|
93205
|
+
var booking_detect_1 = require_booking_detect();
|
|
93041
93206
|
var email_1 = require_email();
|
|
93042
93207
|
var email_harvest_1 = require_email_harvest();
|
|
93043
93208
|
exports2.TOOLS = [
|
|
93044
93209
|
{
|
|
93045
93210
|
name: "find_leads",
|
|
93046
|
-
description: "Search Google Places for local businesses that
|
|
93211
|
+
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
93212
|
input_schema: {
|
|
93048
93213
|
type: "object",
|
|
93049
93214
|
properties: {
|
|
@@ -93063,6 +93228,11 @@ var require_chat_tools = __commonJS({
|
|
|
93063
93228
|
radiusMeters: {
|
|
93064
93229
|
type: "number",
|
|
93065
93230
|
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."
|
|
93231
|
+
},
|
|
93232
|
+
mode: {
|
|
93233
|
+
type: "string",
|
|
93234
|
+
enum: ["no_website", "no_booking"],
|
|
93235
|
+
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
93236
|
}
|
|
93067
93237
|
},
|
|
93068
93238
|
required: ["location", "categories"]
|
|
@@ -93147,6 +93317,38 @@ var require_chat_tools = __commonJS({
|
|
|
93147
93317
|
},
|
|
93148
93318
|
requiresConfirmation: true
|
|
93149
93319
|
},
|
|
93320
|
+
{
|
|
93321
|
+
name: "booking_site_for_business",
|
|
93322
|
+
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.",
|
|
93323
|
+
input_schema: {
|
|
93324
|
+
type: "object",
|
|
93325
|
+
properties: {
|
|
93326
|
+
business: {
|
|
93327
|
+
type: "string",
|
|
93328
|
+
description: "The business to look up \u2014 name plus town/area if known, e.g. 'Spice Garden takeaway Leeds'."
|
|
93329
|
+
},
|
|
93330
|
+
archetype: {
|
|
93331
|
+
type: "string",
|
|
93332
|
+
enum: ["slots", "reservation", "enquiry"],
|
|
93333
|
+
description: "Booking style: 'slots' = timed appointments, 'reservation' = table/party booking, 'enquiry' = a request form. Omit to auto-detect from the business type."
|
|
93334
|
+
},
|
|
93335
|
+
instructions: {
|
|
93336
|
+
type: "string",
|
|
93337
|
+
description: "Optional custom direction for the booking form (services, slot length, party size, copy)."
|
|
93338
|
+
},
|
|
93339
|
+
ownerEmail: {
|
|
93340
|
+
type: "string",
|
|
93341
|
+
description: "Optional owner email to notify on each booking."
|
|
93342
|
+
},
|
|
93343
|
+
force: {
|
|
93344
|
+
type: "boolean",
|
|
93345
|
+
description: "Build even if the business already appears to have online booking. Default false \u2014 normally we stop and say they already have it."
|
|
93346
|
+
}
|
|
93347
|
+
},
|
|
93348
|
+
required: ["business"]
|
|
93349
|
+
},
|
|
93350
|
+
requiresConfirmation: true
|
|
93351
|
+
},
|
|
93150
93352
|
{
|
|
93151
93353
|
name: "build_site",
|
|
93152
93354
|
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 +93541,14 @@ var require_chat_tools = __commonJS({
|
|
|
93339
93541
|
return `+44${p.slice(1)}`;
|
|
93340
93542
|
return p;
|
|
93341
93543
|
}
|
|
93544
|
+
function bookingOutreachTemplate(db2) {
|
|
93545
|
+
const row = db2.get("SELECT value FROM settings WHERE key = ?", ["outreach_template_booking"]);
|
|
93546
|
+
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}
|
|
93547
|
+
|
|
93548
|
+
Customers pick a time themselves, you get an email for each booking. \xA3199 one-time, no monthly fee.
|
|
93549
|
+
|
|
93550
|
+
Want it live on your own domain?`;
|
|
93551
|
+
}
|
|
93342
93552
|
function outreachTemplate(db2) {
|
|
93343
93553
|
const row = db2.get("SELECT value FROM settings WHERE key = ?", ["outreach_template"]);
|
|
93344
93554
|
return row?.value ?? `Hi, we built {name} a free demo website: {site_url}
|
|
@@ -93407,7 +93617,7 @@ Interested?`;
|
|
|
93407
93617
|
const status = input.status;
|
|
93408
93618
|
const city = input.city;
|
|
93409
93619
|
const limit = Math.min(Number(input.limit ?? 25), 100);
|
|
93410
|
-
let sql = "SELECT id, name, status, type, city, phone, site_url FROM leads";
|
|
93620
|
+
let sql = "SELECT id, name, status, type, city, phone, site_url, website, lead_kind, booking_status, booking_platform, booking_url FROM leads";
|
|
93411
93621
|
const conds = [];
|
|
93412
93622
|
const params = [];
|
|
93413
93623
|
if (status) {
|
|
@@ -93484,7 +93694,7 @@ Interested?`;
|
|
|
93484
93694
|
}
|
|
93485
93695
|
case "prepare_outreach": {
|
|
93486
93696
|
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", []);
|
|
93697
|
+
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
93698
|
const eligible = rows.filter((r) => r.site_url);
|
|
93489
93699
|
if (eligible.length === 0) {
|
|
93490
93700
|
return {
|
|
@@ -93496,12 +93706,14 @@ Interested?`;
|
|
|
93496
93706
|
const requested = String(input.channel ?? "").toLowerCase();
|
|
93497
93707
|
const channel = requested === "sms" || requested === "whatsapp" ? requested : channelRow?.value || "whatsapp";
|
|
93498
93708
|
const template = outreachTemplate(db2);
|
|
93709
|
+
const bookingTemplate = bookingOutreachTemplate(db2);
|
|
93499
93710
|
const smtp = (0, email_1.smtpFromSettings)((k) => getDecryptedSetting(k));
|
|
93500
93711
|
const emailSubject = getDecryptedSetting("outreach_email_subject") || "A free demo website for {name}";
|
|
93501
93712
|
const prepared = [];
|
|
93502
93713
|
let harvested = 0;
|
|
93503
93714
|
for (const r of eligible) {
|
|
93504
|
-
const
|
|
93715
|
+
const isBookingLead = r.lead_kind === "no_booking";
|
|
93716
|
+
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
93717
|
const e164 = toE164(r.phone ?? "");
|
|
93506
93718
|
if (!e164 && !r.email && smtp) {
|
|
93507
93719
|
ctx.emitProgress?.({
|
|
@@ -93631,6 +93843,84 @@ Interested?`;
|
|
|
93631
93843
|
});
|
|
93632
93844
|
return { ok: true, queued: ids.length, concurrency, snapshot };
|
|
93633
93845
|
}
|
|
93846
|
+
case "booking_site_for_business": {
|
|
93847
|
+
const query = String(input.business ?? "").trim();
|
|
93848
|
+
if (!query)
|
|
93849
|
+
return { ok: false, error: "Give me the business name (and town if you know it)." };
|
|
93850
|
+
const placesKey = getDecryptedSetting("places_key");
|
|
93851
|
+
if (!placesKey)
|
|
93852
|
+
throw new Error("Google Places API key not set");
|
|
93853
|
+
ctx.emitProgress?.({
|
|
93854
|
+
channel: "leads:searching",
|
|
93855
|
+
payload: { msg: `Looking up ${query}\u2026` }
|
|
93856
|
+
});
|
|
93857
|
+
const biz = await (0, places_1.findOneBusiness)({ apiKey: placesKey, query });
|
|
93858
|
+
if (!biz) {
|
|
93859
|
+
return { ok: false, error: `Couldn't find "${query}" on Google Places. Try adding the town, or check the spelling.` };
|
|
93860
|
+
}
|
|
93861
|
+
let check2 = { verdict: "unknown" };
|
|
93862
|
+
if (biz.website) {
|
|
93863
|
+
ctx.emitProgress?.({
|
|
93864
|
+
channel: "leads:searching",
|
|
93865
|
+
payload: { msg: `Reading ${biz.name}'s website\u2026` }
|
|
93866
|
+
});
|
|
93867
|
+
check2 = await (0, booking_detect_1.checkBookingOnSite)(biz.website);
|
|
93868
|
+
if (check2.verdict === "has_online_booking" && input.force !== true) {
|
|
93869
|
+
return {
|
|
93870
|
+
ok: false,
|
|
93871
|
+
error: `${biz.name} already takes bookings online via ${check2.detail}. Nothing to sell them \u2014 pass force=true to build anyway.`,
|
|
93872
|
+
business: biz.name,
|
|
93873
|
+
website: biz.website,
|
|
93874
|
+
bookingPlatform: check2.detail
|
|
93875
|
+
};
|
|
93876
|
+
}
|
|
93877
|
+
}
|
|
93878
|
+
const existing = db2.get("SELECT id FROM leads WHERE place_id = ?", [biz.place_id]);
|
|
93879
|
+
let leadId;
|
|
93880
|
+
if (existing) {
|
|
93881
|
+
leadId = existing.id;
|
|
93882
|
+
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]);
|
|
93883
|
+
} else {
|
|
93884
|
+
db2.run(`INSERT INTO leads
|
|
93885
|
+
(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)
|
|
93886
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered', 'no_booking', ?, ?)`, [
|
|
93887
|
+
biz.place_id,
|
|
93888
|
+
biz.name,
|
|
93889
|
+
biz.type,
|
|
93890
|
+
"",
|
|
93891
|
+
biz.address,
|
|
93892
|
+
biz.phone,
|
|
93893
|
+
biz.website,
|
|
93894
|
+
biz.rating,
|
|
93895
|
+
biz.review_count,
|
|
93896
|
+
biz.lat,
|
|
93897
|
+
biz.lng,
|
|
93898
|
+
biz.score,
|
|
93899
|
+
biz.price_level,
|
|
93900
|
+
biz.business_status,
|
|
93901
|
+
check2.verdict,
|
|
93902
|
+
check2.detail ?? null
|
|
93903
|
+
]);
|
|
93904
|
+
leadId = db2.get("SELECT id FROM leads WHERE place_id = ?", [biz.place_id]).id;
|
|
93905
|
+
}
|
|
93906
|
+
ctx.emitProgress?.({ channel: "leads:changed", payload: { reason: "booking_site_for_business" } });
|
|
93907
|
+
const result = await ctx.invokeIpc("bookings:provision-standalone", {
|
|
93908
|
+
leadId,
|
|
93909
|
+
instructions: input.instructions,
|
|
93910
|
+
archetypeOverride: input.archetype,
|
|
93911
|
+
ownerEmail: input.ownerEmail
|
|
93912
|
+
});
|
|
93913
|
+
db2.run("UPDATE leads SET booking_url = ? WHERE id = ?", [result.bookingUrl, leadId]);
|
|
93914
|
+
return {
|
|
93915
|
+
ok: true,
|
|
93916
|
+
id: leadId,
|
|
93917
|
+
business: biz.name,
|
|
93918
|
+
website: biz.website,
|
|
93919
|
+
currentBooking: check2.verdict === "messaging_only" ? check2.detail : check2.verdict,
|
|
93920
|
+
standalone: true,
|
|
93921
|
+
...result
|
|
93922
|
+
};
|
|
93923
|
+
}
|
|
93634
93924
|
case "add_booking_site": {
|
|
93635
93925
|
const id = Number(input.id);
|
|
93636
93926
|
const lead = db2.get("SELECT site_url FROM leads WHERE id = ?", [id]);
|
|
@@ -94674,7 +94964,7 @@ var require_package2 = __commonJS({
|
|
|
94674
94964
|
"package.json"(exports2, module2) {
|
|
94675
94965
|
module2.exports = {
|
|
94676
94966
|
name: "sproutkit-code",
|
|
94677
|
-
version: "1.1
|
|
94967
|
+
version: "1.2.1",
|
|
94678
94968
|
description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
|
|
94679
94969
|
bin: {
|
|
94680
94970
|
sproutkit: "./dist/index.js"
|
|
@@ -95669,7 +95959,8 @@ async function cmdFind(argv) {
|
|
|
95669
95959
|
const lead = payload.lead;
|
|
95670
95960
|
if (lead) {
|
|
95671
95961
|
streamed++;
|
|
95672
|
-
|
|
95962
|
+
const how = lead.bookingStatus === "messaging_only" ? c.amber(` \xB7 books via ${lead.bookingDetail ?? "messaging"}`) : lead.bookingStatus === "no_booking" ? c.amber(" \xB7 no online booking") : "";
|
|
95963
|
+
say(` ${c.green("+")} ${c.bold(lead.name)} ${c.dim(`#${lead.id} \xB7 ${lead.type}`)}${how}`);
|
|
95673
95964
|
}
|
|
95674
95965
|
}
|
|
95675
95966
|
};
|
|
@@ -95680,7 +95971,9 @@ async function cmdFind(argv) {
|
|
|
95680
95971
|
location,
|
|
95681
95972
|
categories: categories.length ? categories : [location],
|
|
95682
95973
|
maxResults: flags.max ? Number(flags.max) : void 0,
|
|
95683
|
-
radiusMeters: flags.radius ? Number(flags.radius) : void 0
|
|
95974
|
+
radiusMeters: flags.radius ? Number(flags.radius) : void 0,
|
|
95975
|
+
// --booking hunts businesses that HAVE a site but no online booking.
|
|
95976
|
+
mode: flags.booking ? "no_booking" : "no_website"
|
|
95684
95977
|
},
|
|
95685
95978
|
makeCtx({ json, onEvent })
|
|
95686
95979
|
);
|
|
@@ -95846,6 +96139,29 @@ async function cmdPipeline(argv) {
|
|
|
95846
96139
|
const queue = [...targets];
|
|
95847
96140
|
async function runOne(t) {
|
|
95848
96141
|
const setRow = (state, detail) => board?.update(rowFor(t, state, detail));
|
|
96142
|
+
if (t.lead_kind === "no_booking") {
|
|
96143
|
+
setRow("building", "building booking site\u2026");
|
|
96144
|
+
try {
|
|
96145
|
+
const booked = await runTool(
|
|
96146
|
+
"add_booking_site",
|
|
96147
|
+
{ id: t.id, standalone: true },
|
|
96148
|
+
makeCtx({ json: true })
|
|
96149
|
+
);
|
|
96150
|
+
if (booked && booked.ok === false) {
|
|
96151
|
+
setRow("failed", booked.error ?? "booking build failed");
|
|
96152
|
+
results.push({ id: t.id, name: t.name, ok: false, error: booked.error });
|
|
96153
|
+
return;
|
|
96154
|
+
}
|
|
96155
|
+
setRow("building", "preparing outreach\u2026");
|
|
96156
|
+
await runTool("prepare_outreach", { ids: [t.id] }, makeCtx({ json: true }));
|
|
96157
|
+
setRow("built", booked.bookingUrl ?? "booking site live");
|
|
96158
|
+
results.push({ id: t.id, name: t.name, ok: true, url: booked.bookingUrl });
|
|
96159
|
+
} catch (e) {
|
|
96160
|
+
setRow("failed", e.message.slice(0, 60));
|
|
96161
|
+
results.push({ id: t.id, name: t.name, ok: false, error: e.message });
|
|
96162
|
+
}
|
|
96163
|
+
return;
|
|
96164
|
+
}
|
|
95849
96165
|
setRow("building", "building site\u2026");
|
|
95850
96166
|
try {
|
|
95851
96167
|
const built = await runTool(
|
|
@@ -95974,9 +96290,42 @@ async function cmdCms(argv) {
|
|
|
95974
96290
|
async function cmdBook(argv) {
|
|
95975
96291
|
const { flags, rest } = parseArgs(argv);
|
|
95976
96292
|
const json = !!flags.json;
|
|
96293
|
+
const positional = rest.join(" ").trim();
|
|
95977
96294
|
const id = Number(flags.id ?? rest[0]);
|
|
96295
|
+
if (!flags.id && positional && Number.isNaN(Number(rest[0]))) {
|
|
96296
|
+
const status2 = json ? void 0 : new Status();
|
|
96297
|
+
if (status2) status2.start(`Building a booking site for ${positional}`);
|
|
96298
|
+
try {
|
|
96299
|
+
const r = await runTool(
|
|
96300
|
+
"booking_site_for_business",
|
|
96301
|
+
{
|
|
96302
|
+
business: positional,
|
|
96303
|
+
archetype: flags.archetype ? String(flags.archetype) : void 0,
|
|
96304
|
+
instructions: flags.instructions ? String(flags.instructions) : void 0,
|
|
96305
|
+
ownerEmail: flags.email ? String(flags.email) : void 0,
|
|
96306
|
+
force: !!flags.force
|
|
96307
|
+
},
|
|
96308
|
+
makeCtx({ json, status: status2 })
|
|
96309
|
+
);
|
|
96310
|
+
if (r && r.ok === false) {
|
|
96311
|
+
if (status2) status2.done(false, r.error ?? "Couldn't build it");
|
|
96312
|
+
if (json) emitJson(r);
|
|
96313
|
+
process.exitCode = 1;
|
|
96314
|
+
return;
|
|
96315
|
+
}
|
|
96316
|
+
if (status2) status2.done(true, `${r.business} \u2014 ${r.bookingUrl}`);
|
|
96317
|
+
autoSync();
|
|
96318
|
+
if (json) emitJson(r);
|
|
96319
|
+
else if (r.currentBooking) say(c.dim(` They currently take bookings via ${r.currentBooking}.`));
|
|
96320
|
+
} catch (e) {
|
|
96321
|
+
if (status2) status2.done(false, "Failed");
|
|
96322
|
+
json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
|
|
96323
|
+
process.exitCode = 1;
|
|
96324
|
+
}
|
|
96325
|
+
return;
|
|
96326
|
+
}
|
|
95978
96327
|
if (!id || Number.isNaN(id)) {
|
|
95979
|
-
const err = 'Usage: sproutkit book <leadId> [--standalone] [--
|
|
96328
|
+
const err = 'Usage: sproutkit book <leadId|"Business Name, Town"> [--standalone] [--archetype \u2026] [--email owner@x.com]';
|
|
95980
96329
|
json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
|
|
95981
96330
|
process.exitCode = 1;
|
|
95982
96331
|
return;
|
|
@@ -96113,11 +96462,13 @@ When a request is missing information the command needs, ask a SHORT, STRUCTURED
|
|
|
96113
96462
|
|
|
96114
96463
|
COMMANDS:
|
|
96115
96464
|
- sproutkit find "<area>" --categories "<type>,<type>" --json \u2014 find website-less leads
|
|
96465
|
+
- 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
96466
|
- sproutkit list --json [--status <s>] [--city <c>] \u2014 list leads (get ids)
|
|
96117
96467
|
- sproutkit build <id> --json [--instructions "..."] [--cms] [--multipage] \u2014 build a demo site (--multipage = real Home/Services/About/Contact pages, not one scroll)
|
|
96118
96468
|
- sproutkit build-all --json [--ids 3,5] [--concurrency 3] \u2014 build many at once
|
|
96119
96469
|
- sproutkit regenerate <id> --instructions "..." --json \u2014 rebuild an existing site with changes
|
|
96120
96470
|
- sproutkit cms <id> --json \u2014 add the editable CMS on top of the site we built them
|
|
96471
|
+
- 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
96472
|
- 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
96473
|
- sproutkit outreach --json [--ids 3,5] \u2014 prepare outreach links
|
|
96123
96474
|
- sproutkit pipeline x3 --json [--book] [--limit 10] \u2014 full pipeline (build\u2192booking\u2192outreach) for many clients concurrently
|
|
@@ -96301,6 +96652,7 @@ async function main() {
|
|
|
96301
96652
|
const USAGE = {
|
|
96302
96653
|
find: [
|
|
96303
96654
|
'sproutkit find "<area>" --categories "barber shop,cafe"',
|
|
96655
|
+
" --booking find businesses that HAVE a site but no online booking",
|
|
96304
96656
|
" --max <n> max leads to save (default 25)",
|
|
96305
96657
|
" --radius <m> search radius in metres (default 10000)",
|
|
96306
96658
|
" --json machine-readable output"
|
|
@@ -96326,7 +96678,9 @@ async function main() {
|
|
|
96326
96678
|
],
|
|
96327
96679
|
cms: ["sproutkit cms <leadId>", " adds the editable CMS to a site we already built"],
|
|
96328
96680
|
book: [
|
|
96329
|
-
|
|
96681
|
+
'sproutkit book <leadId | "Business Name, Town"> [options]',
|
|
96682
|
+
" naming a business that isn't a lead yet looks it up, reads their site,",
|
|
96683
|
+
" and builds them a standalone booking page in one shot",
|
|
96330
96684
|
" --standalone booking site on its OWN url (client already has a site)",
|
|
96331
96685
|
" --archetype <type> slots | reservation | enquiry",
|
|
96332
96686
|
' --instructions "\u2026" e.g. "45-min slots, add beard trim \xA312"',
|
|
@@ -96383,7 +96737,7 @@ async function main() {
|
|
|
96383
96737
|
say(` ${c.bold("sproutkit build-all")} build many at once (--ids, --concurrency 3)`);
|
|
96384
96738
|
say(` ${c.bold("sproutkit regenerate <id>")} rebuild a site (--instructions "\u2026")`);
|
|
96385
96739
|
say(` ${c.bold("sproutkit cms <id>")} add the editable CMS to a built site`);
|
|
96386
|
-
say(` ${c.bold("sproutkit book <id>")}
|
|
96740
|
+
say(` ${c.bold("sproutkit book <id|name>")} booking site (--standalone, or name any business)`);
|
|
96387
96741
|
say(` ${c.bold("sproutkit outreach")} prep outreach links (--ids 3,5)`);
|
|
96388
96742
|
say(` ${c.bold("sproutkit pipeline x3")} build+outreach many clients concurrently`);
|
|
96389
96743
|
say(` ${c.bold("sproutkit setup")} set or update keys, licence, backend`);
|