vibespot 0.4.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.
@@ -0,0 +1,554 @@
1
+ /**
2
+ * Upload panel — slide-up log for live hs upload output
3
+ */
4
+
5
+ let uploadState = "idle";
6
+ let uploadAttempt = 0;
7
+ let lastUploadErrors = [];
8
+ let lastUploadPortalId = "";
9
+ let lastUploadDataCenter = "na1";
10
+ let lastUploadThemeName = "";
11
+ const MAX_UPLOAD_ATTEMPTS = 3;
12
+
13
+ async function startUpload() {
14
+ if (uploadState === "uploading" || uploadState === "ai_fixing") return;
15
+
16
+ const uploadBtn = document.getElementById("btn-upload");
17
+ if (uploadBtn) {
18
+ uploadBtn.innerHTML = '<span class="upload-spinner"></span>';
19
+ uploadBtn.disabled = true;
20
+ }
21
+
22
+ function resetUploadBtn() {
23
+ if (uploadBtn) { uploadBtn.textContent = "Deploy"; uploadBtn.disabled = false; }
24
+ }
25
+
26
+ // Fetch portal info and check HubSpot CLI readiness
27
+ try {
28
+ const res = await fetch("/api/settings/status");
29
+ const data = await res.json();
30
+ let hs = data.environment?.tools?.hubspot;
31
+
32
+ // HubSpot CLI not installed — guide the user through install
33
+ if (!hs || !hs.found) {
34
+ const installed = await showHubSpotSetupDialog("install");
35
+ if (!installed) { resetUploadBtn(); return; }
36
+ // Re-check after install
37
+ const recheck = await fetch("/api/settings/status").then((r) => r.json());
38
+ hs = recheck.environment?.tools?.hubspot;
39
+ }
40
+
41
+ // HubSpot CLI installed but not authenticated — guide through auth
42
+ if (hs && hs.found && !hs.authenticated) {
43
+ const authed = await showHubSpotSetupDialog("auth");
44
+ if (!authed) { resetUploadBtn(); return; }
45
+ // Re-check after auth
46
+ const recheck = await fetch("/api/settings/status").then((r) => r.json());
47
+ hs = recheck.environment?.tools?.hubspot;
48
+ }
49
+
50
+ // Confirm portal before deploying
51
+ if (hs && hs.authenticated && hs.portalName) {
52
+ const confirmed = await confirmUpload(hs.portalName, hs.portalId);
53
+ if (!confirmed) { resetUploadBtn(); return; }
54
+ }
55
+ } catch {
56
+ // If we can't detect, proceed and let the upload fail with a meaningful error
57
+ }
58
+
59
+ doUpload();
60
+ }
61
+
62
+ /**
63
+ * Show a dialog to guide the user through HubSpot CLI install or auth.
64
+ * @param {"install" | "auth"} mode
65
+ * @returns {Promise<boolean>} true if the step completed successfully
66
+ */
67
+ function showHubSpotSetupDialog(mode) {
68
+ return new Promise((resolve) => {
69
+ const overlay = document.createElement("div");
70
+ overlay.className = "confirm-overlay";
71
+
72
+ if (mode === "install") {
73
+ overlay.innerHTML = `
74
+ <div class="confirm-dialog" style="width:420px">
75
+ <div class="confirm-dialog__title">Install HubSpot CLI</div>
76
+ <p class="confirm-dialog__detail">The HubSpot CLI is needed to deploy your theme. This will run <code>npm install -g @hubspot/cli</code>.</p>
77
+ <div class="confirm-dialog__actions">
78
+ <button class="btn btn--secondary" data-action="cancel">Cancel</button>
79
+ <button class="btn btn--primary" data-action="install">Install</button>
80
+ </div>
81
+ <div class="hs-setup__status hidden" id="hs-install-status">
82
+ <span class="upload-spinner"></span> Installing...
83
+ </div>
84
+ </div>
85
+ `;
86
+ document.body.appendChild(overlay);
87
+
88
+ const close = (val) => { overlay.remove(); resolve(val); };
89
+ overlay.querySelector('[data-action="cancel"]').addEventListener("click", () => close(false));
90
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(false); });
91
+
92
+ overlay.querySelector('[data-action="install"]').addEventListener("click", async () => {
93
+ const installBtn = overlay.querySelector('[data-action="install"]');
94
+ installBtn.disabled = true;
95
+ installBtn.textContent = "Installing...";
96
+ const statusEl = document.getElementById("hs-install-status");
97
+ if (statusEl) statusEl.classList.remove("hidden");
98
+
99
+ try {
100
+ const res = await fetch("/api/settings/install", {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify({ tool: "hubspot" }),
104
+ });
105
+ const data = await res.json();
106
+ if (data.jobId) {
107
+ // Poll until done
108
+ for (let i = 0; i < 60; i++) {
109
+ await new Promise((r) => setTimeout(r, 2000));
110
+ const jr = await fetch("/api/settings/job/" + data.jobId).then((r) => r.json());
111
+ if (jr.status === "completed") { close(true); return; }
112
+ if (jr.status === "failed") { close(false); return; }
113
+ }
114
+ }
115
+ close(false);
116
+ } catch {
117
+ close(false);
118
+ }
119
+ });
120
+
121
+ } else {
122
+ // Auth mode — PAK flow
123
+ overlay.innerHTML = `
124
+ <div class="confirm-dialog" style="width:460px">
125
+ <div class="confirm-dialog__title">Connect your HubSpot account</div>
126
+ <p class="confirm-dialog__detail">Create a Personal Access Key to connect vibeSpot to your HubSpot portal.</p>
127
+ <ol class="hs-setup__steps">
128
+ <li>Open <a href="https://app.hubspot.com/portal-recommend/l?slug=personal-access-key" target="_blank" rel="noopener">HubSpot Settings</a></li>
129
+ <li>Click "Create personal access key" or copy an existing one</li>
130
+ <li>Paste the key below</li>
131
+ </ol>
132
+ <input type="password" class="confirm-dialog__input" id="hs-pak-input" placeholder="pat-na1-..." />
133
+ <div class="confirm-dialog__actions">
134
+ <button class="btn btn--secondary" data-action="cancel">Cancel</button>
135
+ <button class="btn btn--primary" data-action="save">Connect</button>
136
+ </div>
137
+ </div>
138
+ `;
139
+ document.body.appendChild(overlay);
140
+
141
+ const close = (val) => { overlay.remove(); resolve(val); };
142
+ overlay.querySelector('[data-action="cancel"]').addEventListener("click", () => close(false));
143
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(false); });
144
+
145
+ const pakInput = document.getElementById("hs-pak-input");
146
+ const saveBtn = overlay.querySelector('[data-action="save"]');
147
+
148
+ const doSave = async () => {
149
+ const key = pakInput.value.trim();
150
+ if (!key) return;
151
+ saveBtn.disabled = true;
152
+ saveBtn.textContent = "Connecting...";
153
+ try {
154
+ const res = await fetch("/api/settings/hs-auth", {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/json" },
157
+ body: JSON.stringify({ personalAccessKey: key }),
158
+ });
159
+ const data = await res.json();
160
+ if (data.error) {
161
+ await vibeAlert(data.error, "Error");
162
+ saveBtn.disabled = false;
163
+ saveBtn.textContent = "Connect";
164
+ return;
165
+ }
166
+ close(true);
167
+ } catch (err) {
168
+ await vibeAlert("Failed to connect: " + err.message, "Error");
169
+ saveBtn.disabled = false;
170
+ saveBtn.textContent = "Connect";
171
+ }
172
+ };
173
+
174
+ saveBtn.addEventListener("click", doSave);
175
+ pakInput.addEventListener("keydown", (e) => { if (e.key === "Enter") doSave(); });
176
+ setTimeout(() => pakInput.focus(), 50);
177
+ }
178
+ });
179
+ }
180
+
181
+ function doUpload() {
182
+ uploadAttempt = 0;
183
+ const panel = document.getElementById("upload-panel");
184
+ const log = document.getElementById("upload-log");
185
+ log.textContent = "";
186
+ panel.classList.remove("hidden");
187
+ panel.className = "upload-panel";
188
+
189
+ setUploadState("writing");
190
+
191
+ // Send via WebSocket
192
+ if (typeof ws !== "undefined" && ws.readyState === WebSocket.OPEN) {
193
+ ws.send(JSON.stringify({ type: "start_upload" }));
194
+ } else {
195
+ appendUploadLog("Error: Not connected to server\n");
196
+ setUploadState("failed");
197
+ }
198
+ }
199
+
200
+ function confirmUpload(portalName, portalId) {
201
+ return new Promise((resolve) => {
202
+ const overlay = document.createElement("div");
203
+ overlay.className = "confirm-overlay";
204
+ overlay.innerHTML = `
205
+ <div class="confirm-dialog">
206
+ <div class="confirm-dialog__title">Deploy to HubSpot?</div>
207
+ <p class="confirm-dialog__detail">
208
+ Uploading to <strong>${esc(portalName)}</strong>${portalId ? ` (${esc(portalId)})` : ""}
209
+ </p>
210
+ <div class="confirm-dialog__actions">
211
+ <button class="btn btn--secondary" id="confirm-upload-cancel">Cancel</button>
212
+ <button class="btn btn--primary" id="confirm-upload-go">Deploy</button>
213
+ </div>
214
+ </div>
215
+ `;
216
+ document.body.appendChild(overlay);
217
+
218
+ document.getElementById("confirm-upload-cancel").addEventListener("click", () => {
219
+ overlay.remove();
220
+ resolve(false);
221
+ });
222
+ overlay.addEventListener("click", (e) => {
223
+ if (e.target === overlay) { overlay.remove(); resolve(false); }
224
+ });
225
+ document.getElementById("confirm-upload-go").addEventListener("click", () => {
226
+ overlay.remove();
227
+ resolve(true);
228
+ });
229
+ });
230
+ }
231
+
232
+ function setUploadState(state, data) {
233
+ uploadState = state;
234
+ const panel = document.getElementById("upload-panel");
235
+ const statusEl = document.getElementById("upload-status-text");
236
+ const actions = document.getElementById("upload-actions");
237
+ const uploadBtn = document.getElementById("btn-upload");
238
+
239
+ // Reset panel state classes
240
+ panel.className = "upload-panel";
241
+ actions.innerHTML = "";
242
+
243
+ switch (state) {
244
+ case "idle":
245
+ panel.classList.add("hidden");
246
+ if (uploadBtn) {
247
+ uploadBtn.textContent = "Deploy";
248
+ uploadBtn.disabled = false;
249
+ }
250
+ break;
251
+
252
+ case "writing":
253
+ statusEl.textContent = "Writing files to disk...";
254
+ if (uploadBtn) {
255
+ uploadBtn.innerHTML = '<span class="upload-spinner"></span> Preparing...';
256
+ uploadBtn.disabled = true;
257
+ }
258
+ break;
259
+
260
+ case "autofix":
261
+ statusEl.textContent = "Applying auto-fixes...";
262
+ break;
263
+
264
+ case "uploading":
265
+ panel.classList.add("upload-panel--uploading");
266
+ statusEl.textContent = "Uploading to HubSpot" + (uploadAttempt > 1 ? ` (attempt ${uploadAttempt})` : "") + "...";
267
+ if (uploadBtn) {
268
+ uploadBtn.innerHTML = '<span class="upload-spinner"></span> Uploading...';
269
+ uploadBtn.disabled = true;
270
+ }
271
+ break;
272
+
273
+ case "success":
274
+ panel.classList.add("upload-panel--success");
275
+ statusEl.innerHTML = '<span class="upload-status-icon">&#10003;</span> Upload complete!';
276
+ if (uploadBtn) {
277
+ uploadBtn.textContent = "Deploy";
278
+ uploadBtn.disabled = false;
279
+ }
280
+ const dismissBtn = document.createElement("button");
281
+ dismissBtn.className = "upload-action-btn";
282
+ dismissBtn.textContent = "Dismiss";
283
+ dismissBtn.addEventListener("click", () => setUploadState("idle"));
284
+ actions.appendChild(dismissBtn);
285
+
286
+ // Show the Create Page button
287
+ if (lastUploadPortalId) {
288
+ const createBtn = document.createElement("button");
289
+ createBtn.className = "upload-action-btn upload-action-btn--primary";
290
+ createBtn.textContent = "Create Page in HubSpot";
291
+ createBtn.addEventListener("click", () => {
292
+ window.open(buildHubSpotPagesUrl(lastUploadPortalId, lastUploadDataCenter), "_blank");
293
+ });
294
+ actions.insertBefore(createBtn, dismissBtn);
295
+ }
296
+ break;
297
+
298
+ case "failed":
299
+ panel.classList.add("upload-panel--error");
300
+ statusEl.innerHTML = '<span class="upload-status-icon">&#10007;</span> Upload failed';
301
+ if (uploadBtn) {
302
+ uploadBtn.textContent = "Deploy";
303
+ uploadBtn.disabled = false;
304
+ }
305
+
306
+ const retryBtn = document.createElement("button");
307
+ retryBtn.className = "upload-action-btn";
308
+ retryBtn.textContent = "Retry";
309
+ retryBtn.addEventListener("click", () => startUpload());
310
+ actions.appendChild(retryBtn);
311
+
312
+ const fixBtn = document.createElement("button");
313
+ fixBtn.className = "upload-action-btn upload-action-btn--primary";
314
+ fixBtn.textContent = "Fix with AI";
315
+ fixBtn.addEventListener("click", () => fixUploadWithAI());
316
+ actions.appendChild(fixBtn);
317
+
318
+ const dismissFailBtn = document.createElement("button");
319
+ dismissFailBtn.className = "upload-action-btn";
320
+ dismissFailBtn.textContent = "Dismiss";
321
+ dismissFailBtn.addEventListener("click", () => setUploadState("idle"));
322
+ actions.appendChild(dismissFailBtn);
323
+ break;
324
+
325
+ case "ai_fixing":
326
+ panel.classList.add("upload-panel--fixing");
327
+ statusEl.innerHTML = '<span class="upload-spinner"></span> AI is fixing errors...';
328
+ if (uploadBtn) {
329
+ uploadBtn.innerHTML = '<span class="upload-spinner"></span> Fixing...';
330
+ uploadBtn.disabled = true;
331
+ }
332
+ break;
333
+
334
+ case "fix_done":
335
+ panel.classList.add("upload-panel--fixing");
336
+ statusEl.innerHTML = '<span class="upload-status-icon">&#10003;</span> AI fixes applied';
337
+ if (uploadBtn) {
338
+ uploadBtn.textContent = "Deploy";
339
+ uploadBtn.disabled = false;
340
+ }
341
+
342
+ const redeployBtn = document.createElement("button");
343
+ redeployBtn.className = "upload-action-btn upload-action-btn--primary";
344
+ redeployBtn.textContent = "Re-deploy";
345
+ redeployBtn.addEventListener("click", () => startUpload());
346
+ actions.appendChild(redeployBtn);
347
+
348
+ const dismissFixBtn = document.createElement("button");
349
+ dismissFixBtn.className = "upload-action-btn";
350
+ dismissFixBtn.textContent = "Dismiss";
351
+ dismissFixBtn.addEventListener("click", () => setUploadState("idle"));
352
+ actions.appendChild(dismissFixBtn);
353
+ break;
354
+ }
355
+ }
356
+
357
+ function appendUploadLog(text) {
358
+ const log = document.getElementById("upload-log");
359
+ if (!log) return;
360
+ log.textContent += text;
361
+ log.scrollTop = log.scrollHeight;
362
+ }
363
+
364
+ function fixUploadWithAI() {
365
+ const log = document.getElementById("upload-log");
366
+ const errorContext = log ? log.textContent.slice(-2000) : "";
367
+
368
+ // Show a verbose summary of what's happening
369
+ appendUploadLog("\n\n========================================\n");
370
+ appendUploadLog(" AI FIX — Analyzing upload errors\n");
371
+ appendUploadLog("========================================\n\n");
372
+
373
+ if (lastUploadErrors.length > 0) {
374
+ appendUploadLog("Errors detected:\n");
375
+ lastUploadErrors.forEach((e, i) => {
376
+ appendUploadLog(` ${i + 1}. ${e.message}\n`);
377
+ appendUploadLog(` File: ${e.file}\n`);
378
+ if (e.fixable) {
379
+ appendUploadLog(" Status: Auto-fixable — AI will patch this\n");
380
+ } else {
381
+ appendUploadLog(" Status: Requires AI analysis\n");
382
+ }
383
+ });
384
+ appendUploadLog("\n");
385
+ }
386
+
387
+ appendUploadLog("Sending errors to AI for diagnosis and repair...\n\n");
388
+
389
+ setUploadState("ai_fixing");
390
+
391
+ if (typeof ws !== "undefined" && ws.readyState === WebSocket.OPEN) {
392
+ ws.send(JSON.stringify({ type: "upload_fix_with_ai", errorContext }));
393
+ }
394
+ }
395
+
396
+ function handleUploadWsMessage(msg) {
397
+ switch (msg.type) {
398
+ case "upload_status":
399
+ if (msg.phase === "autofix" && msg.fixes?.length > 0) {
400
+ setUploadState("autofix");
401
+ appendUploadLog("Auto-fixes applied:\n");
402
+ msg.fixes.forEach((f) => appendUploadLog(" \u2713 " + f + "\n"));
403
+ appendUploadLog("\n");
404
+ }
405
+ break;
406
+
407
+ case "upload_started":
408
+ uploadAttempt++;
409
+ setUploadState("uploading");
410
+ break;
411
+
412
+ case "upload_output":
413
+ appendUploadLog(msg.chunk);
414
+ break;
415
+
416
+ case "upload_complete":
417
+ setUploadState("success");
418
+ // Update status bar
419
+ const statusText = document.getElementById("status-text");
420
+ if (statusText) statusText.textContent = "Upload complete!";
421
+ // Show celebration popup
422
+ showDeploySuccessPopup(msg.portalId, msg.dataCenter || "na1", msg.themeName);
423
+ break;
424
+
425
+ case "upload_failed":
426
+ lastUploadErrors = msg.errors || [];
427
+ setUploadState("failed", { errors: msg.errors });
428
+ appendUploadLog("\n--- Upload failed ---\n\n");
429
+ if (msg.errors && msg.errors.length > 0) {
430
+ appendUploadLog(`${msg.errors.length} error(s) found:\n`);
431
+ msg.errors.forEach((e, i) => {
432
+ appendUploadLog(` ${i + 1}. \u2717 ${e.message}\n`);
433
+ appendUploadLog(` File: ${e.file}\n`);
434
+ });
435
+ } else {
436
+ appendUploadLog(" Check the log above for details.\n");
437
+ }
438
+ appendUploadLog("\nClick \"Fix with AI\" to automatically diagnose and repair these issues.\n");
439
+ break;
440
+
441
+ case "upload_fix_started":
442
+ setUploadState("ai_fixing");
443
+ break;
444
+
445
+ case "upload_fix_stream":
446
+ // AI fix explanation streamed into the upload panel
447
+ appendUploadLog(msg.content || "");
448
+ break;
449
+
450
+ case "upload_fix_complete":
451
+ appendUploadLog("\n\n========================================\n");
452
+ appendUploadLog(" Fix complete\n");
453
+ appendUploadLog("========================================\n");
454
+ lastUploadErrors = [];
455
+ setUploadState("fix_done");
456
+ break;
457
+ }
458
+ }
459
+
460
+ function buildHubSpotPagesUrl(portalId, dataCenter) {
461
+ const host = dataCenter === "eu1" ? "app-eu1.hubspot.com" : "app.hubspot.com";
462
+ return `https://${host}/page-ui/${portalId}/management/pages/landing`;
463
+ }
464
+
465
+ function showDeploySuccessPopup(portalId, dataCenter, themeName) {
466
+ lastUploadPortalId = portalId || "";
467
+ lastUploadDataCenter = dataCenter || "na1";
468
+ lastUploadThemeName = themeName || "";
469
+
470
+ const name = themeName || "your theme";
471
+ const lpLabel = `${name} Landing Page`;
472
+ const pagesUrl = portalId ? buildHubSpotPagesUrl(portalId, dataCenter) : "";
473
+
474
+ // Spawn confetti
475
+ spawnConfetti();
476
+
477
+ const overlay = document.createElement("div");
478
+ overlay.className = "confirm-overlay";
479
+ overlay.innerHTML = `
480
+ <div class="deploy-success">
481
+ <div class="deploy-success__icon">&#127881;</div>
482
+ <h2 class="deploy-success__title">Theme deployed!</h2>
483
+ <p class="deploy-success__subtitle">"${esc(name)}" is now live on HubSpot.</p>
484
+ <div class="deploy-success__steps">
485
+ <div class="deploy-success__step"><span class="deploy-success__num">1</span> Go to <strong>Content &rarr; Landing Pages</strong></div>
486
+ <div class="deploy-success__step"><span class="deploy-success__num">2</span> Click <strong>"Create" &rarr; "Landing page"</strong></div>
487
+ <div class="deploy-success__step"><span class="deploy-success__num">3</span> Select the <strong>"${esc(lpLabel)}"</strong> template</div>
488
+ </div>
489
+ <div class="deploy-success__actions">
490
+ ${pagesUrl ? `<a href="${pagesUrl}" target="_blank" class="btn btn--primary deploy-success__link">Create Page in HubSpot &rarr;</a>` : ""}
491
+ <button class="btn btn--secondary" id="deploy-success-dismiss">Close</button>
492
+ </div>
493
+ </div>
494
+ `;
495
+ document.body.appendChild(overlay);
496
+
497
+ document.getElementById("deploy-success-dismiss").addEventListener("click", () => overlay.remove());
498
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
499
+ }
500
+
501
+ function esc(str) {
502
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
503
+ }
504
+
505
+ function spawnConfetti() {
506
+ const colors = ["#e8613a", "#f2825f", "#4ade80", "#f59e0b", "#818cf8", "#fb7185"];
507
+ const container = document.createElement("div");
508
+ container.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:10000;overflow:hidden";
509
+ document.body.appendChild(container);
510
+
511
+ for (let i = 0; i < 60; i++) {
512
+ const piece = document.createElement("div");
513
+ const color = colors[Math.floor(Math.random() * colors.length)];
514
+ const left = Math.random() * 100;
515
+ const delay = Math.random() * 0.6;
516
+ const size = 6 + Math.random() * 6;
517
+ const drift = (Math.random() - 0.5) * 120;
518
+ piece.style.cssText = `
519
+ position:absolute;top:-10px;left:${left}%;
520
+ width:${size}px;height:${size * 0.6}px;
521
+ background:${color};border-radius:2px;
522
+ animation:confettiFall ${1.8 + Math.random() * 1.2}s ease-out ${delay}s forwards;
523
+ --drift:${drift}px;
524
+ `;
525
+ container.appendChild(piece);
526
+ }
527
+
528
+ // Inject keyframes once
529
+ if (!document.getElementById("confetti-style")) {
530
+ const style = document.createElement("style");
531
+ style.id = "confetti-style";
532
+ style.textContent = `
533
+ @keyframes confettiFall {
534
+ 0% { transform: translateY(0) translateX(0) rotate(0deg); opacity: 1; }
535
+ 100% { transform: translateY(100vh) translateX(var(--drift)) rotate(720deg); opacity: 0; }
536
+ }
537
+ `;
538
+ document.head.appendChild(style);
539
+ }
540
+
541
+ setTimeout(() => container.remove(), 4000);
542
+ }
543
+
544
+ // Toggle panel collapse
545
+ document.addEventListener("DOMContentLoaded", () => {
546
+ const toggle = document.getElementById("upload-panel-toggle");
547
+ if (toggle) {
548
+ toggle.addEventListener("click", () => {
549
+ const log = document.getElementById("upload-log");
550
+ if (log) log.classList.toggle("collapsed");
551
+ toggle.classList.toggle("flipped");
552
+ });
553
+ }
554
+ });