Skip to content

Commit cab3bc4

Browse files
committed
feat(tour): introduce 3-step onboarding tour overlay
A one-shot, app-lifetime onboarding tour that points new users at the design-mode toggle, the AI sidebar tab, and the New Project button. Step 1 auto-demos design mode (toggle on, hold 2s, toggle off) so the visible UI change does the explaining; the tooltip text stays stable. Subsequent steps are user-driven via the tooltip's Next/Dismiss buttons — clicking the actual targeted button never advances the tour, giving the user time to read each prompt. Gating: the tour waits for LoginService.proTrialStartDialogDismissed so its overlay never competes with the on-boot pro trial dialog. Falls back to a 60s timeout for runs where that dialog isn't shown. Persists completion via PhStore key "phoenixOnboardingTourState" (version field) so it never re-runs. Funnel metrics under (GUIDE, "tour", *): start, step1, step2, step3, dismiss.
1 parent 932f0bf commit cab3bc4

6 files changed

Lines changed: 556 additions & 2 deletions

File tree

src/extensionsIntegrated/Phoenix/guided-tour.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ define(function (require, exports, module) {
2020
Dialogs = require("widgets/Dialogs"),
2121
Mustache = require("thirdparty/mustache/mustache"),
2222
SurveyTemplate = require("text!./html/survey-template.html"),
23+
PhoenixTour = require("./phoenix-tour"),
2324
NOTIFICATION_BACKOFF = 10000,
2425
GUIDED_TOUR_LOCAL_STORAGE_KEY = "guidedTourActions";
2526

@@ -279,5 +280,6 @@ define(function (require, exports, module) {
279280
tourStarted = true;
280281
_showBeautifyNotification();
281282
_showSurveys();
283+
PhoenixTour.startTour();
282284
};
283285
});
Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
/*
2+
* GNU AGPL-3.0 License
3+
*
4+
* Copyright (c) 2021 - present core.ai . All rights reserved.
5+
*
6+
* This program is free software: you can redistribute it and/or modify it
7+
* under the terms of the GNU Affero General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful, but WITHOUT
12+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
14+
* for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
18+
*
19+
*/
20+
21+
/*global PhStore */
22+
23+
/**
24+
* One-shot, app-lifetime onboarding tour that introduces the design-mode
25+
* toggle and the AI sidebar tab. Distinct from the NotificationUI-based
26+
* guided tour: it owns its own overlay (concentric pulse rings + tooltip)
27+
* and drives a short demo of design mode before pointing the user at the
28+
* AI tab.
29+
*/
30+
define(function (require, exports, module) {
31+
32+
const Strings = require("strings"),
33+
StringUtils = require("utils/StringUtils"),
34+
Metrics = require("utils/Metrics"),
35+
CentralControlBar = require("view/CentralControlBar");
36+
37+
// Capture the kernel trust ring at module-load time — it's deleted from
38+
// `window` shortly after boot. Treated as optional: community-edition
39+
// builds without the pro trial flow won't expose `loginService` and the
40+
// tour will simply proceed without waiting.
41+
const _LoginService = (window.KernalModeTrust && window.KernalModeTrust.loginService) || null;
42+
43+
const TOUR_STORAGE_KEY = "phoenixOnboardingTourState";
44+
const CURRENT_TOUR_VERSION = 1;
45+
46+
const STEP_START_DELAY_MS = 2500;
47+
const STEP1_INVITE_MS = 1800;
48+
const STEP1_DESIGN_MODE_HOLD_MS = 2000;
49+
// Hard cap on how long we'll wait for the pro trial start dialog to be
50+
// dismissed before starting the tour. The dialog is shown on every fresh
51+
// first-run boot (where this tour also runs), so under normal conditions
52+
// the wait is bounded by the user dismissing it. The cap protects edge
53+
// cases where the dialog isn't shown at all (e.g. user already has a
54+
// subscription / a prior expired trial).
55+
const TRIAL_DIALOG_WAIT_TIMEOUT_MS = 60000;
56+
57+
function _loadState() {
58+
const raw = PhStore.getItem(TOUR_STORAGE_KEY);
59+
if (!raw) {
60+
return { version: 0 };
61+
}
62+
try {
63+
return JSON.parse(raw);
64+
} catch (e) {
65+
return { version: 0 };
66+
}
67+
}
68+
69+
function _saveState(state) {
70+
PhStore.setItem(TOUR_STORAGE_KEY, JSON.stringify(state));
71+
}
72+
73+
let _state = _loadState();
74+
let _ranThisSession = false;
75+
76+
let $overlay = null;
77+
let _rafId = null;
78+
let _timers = [];
79+
80+
function _markComplete() {
81+
_state.version = CURRENT_TOUR_VERSION;
82+
_saveState(_state);
83+
}
84+
85+
function _clearTimers() {
86+
for (let i = 0; i < _timers.length; i++) {
87+
clearTimeout(_timers[i]);
88+
}
89+
_timers = [];
90+
if (_rafId) {
91+
cancelAnimationFrame(_rafId);
92+
_rafId = null;
93+
}
94+
}
95+
96+
function _teardown() {
97+
_clearTimers();
98+
if ($overlay) {
99+
$overlay.remove();
100+
$overlay = null;
101+
}
102+
}
103+
104+
const TOTAL_STEPS = 3;
105+
106+
function _ensureOverlay() {
107+
if ($overlay) {
108+
return;
109+
}
110+
$overlay = $(
111+
'<div class="phoenix-tour-overlay" data-tip-placement="right">' +
112+
'<div class="phoenix-tour-ring"></div>' +
113+
'<div class="phoenix-tour-ring phoenix-tour-ring-2"></div>' +
114+
'<div class="phoenix-tour-tooltip">' +
115+
'<div class="phoenix-tour-step"></div>' +
116+
'<div class="phoenix-tour-text"></div>' +
117+
'<div class="phoenix-tour-actions"></div>' +
118+
'</div>' +
119+
'</div>'
120+
);
121+
$overlay.appendTo(document.body);
122+
}
123+
124+
function _setText(text) {
125+
if ($overlay) {
126+
$overlay.find(".phoenix-tour-text").text(text);
127+
}
128+
}
129+
130+
function _setStep(stepNum) {
131+
if ($overlay) {
132+
$overlay.find(".phoenix-tour-step")
133+
.text(StringUtils.format(Strings.PHOENIX_TOUR_STEP_OF, stepNum, TOTAL_STEPS));
134+
}
135+
}
136+
137+
/**
138+
* Replace tooltip action buttons. Pass an empty array to hide the row.
139+
* @param {Array<{label: string, kind: string, onClick: Function}>} buttons
140+
*/
141+
function _setActions(buttons) {
142+
if (!$overlay) {
143+
return;
144+
}
145+
const $actions = $overlay.find(".phoenix-tour-actions").empty();
146+
if (!buttons || !buttons.length) {
147+
$actions.removeClass("has-buttons");
148+
return;
149+
}
150+
$actions.addClass("has-buttons");
151+
buttons.forEach(function (b) {
152+
const kind = b.kind || "primary";
153+
const $btn = $('<button type="button" class="phoenix-tour-btn"></button>')
154+
.addClass("phoenix-tour-btn-" + kind)
155+
.text(b.label);
156+
$btn.on("click", function (e) {
157+
e.preventDefault();
158+
e.stopPropagation();
159+
b.onClick();
160+
});
161+
$actions.append($btn);
162+
});
163+
}
164+
165+
function _trackTarget($target, placement) {
166+
function update() {
167+
if (!$overlay || !$target.length || !$target[0].isConnected) {
168+
_rafId = null;
169+
return;
170+
}
171+
const r = $target[0].getBoundingClientRect();
172+
if (r.width === 0 && r.height === 0) {
173+
_rafId = requestAnimationFrame(update);
174+
return;
175+
}
176+
const cx = r.left + r.width / 2;
177+
const cy = r.top + r.height / 2;
178+
const el = $overlay[0];
179+
el.style.left = cx + "px";
180+
el.style.top = cy + "px";
181+
_rafId = requestAnimationFrame(update);
182+
}
183+
if (_rafId) {
184+
cancelAnimationFrame(_rafId);
185+
}
186+
$overlay.attr("data-tip-placement", placement || "right");
187+
update();
188+
}
189+
190+
function _runStep1() {
191+
const $btn = $("#ccbCollapseEditorBtn");
192+
if (!$btn.length) {
193+
_markComplete();
194+
_teardown();
195+
return;
196+
}
197+
_ensureOverlay();
198+
_trackTarget($btn, "right");
199+
_setStep(1);
200+
Metrics.countEvent(Metrics.EVENT_TYPE.GUIDE, "tour", "step1");
201+
// Single, stable message for the entire step. The visible toggle of
202+
// design mode does the explaining; rotating text under a 2-second
203+
// demo is too quick to read.
204+
_setText(Strings.PHOENIX_TOUR_DESIGN_MODE);
205+
_setActions([]); // hidden during the auto-demo
206+
$overlay.addClass("phoenix-tour-visible");
207+
208+
// Auto-demo: enter design mode, hold, exit, then show "Next".
209+
_timers.push(setTimeout(function () {
210+
$btn.addClass("phoenix-tour-pressed");
211+
_timers.push(setTimeout(function () {
212+
$btn.removeClass("phoenix-tour-pressed");
213+
}, 220));
214+
CentralControlBar.setEditorCollapsed(true);
215+
216+
_timers.push(setTimeout(function () {
217+
$btn.addClass("phoenix-tour-pressed");
218+
_timers.push(setTimeout(function () {
219+
$btn.removeClass("phoenix-tour-pressed");
220+
}, 220));
221+
CentralControlBar.setEditorCollapsed(false);
222+
_setActions([
223+
{
224+
label: Strings.PHOENIX_TOUR_NEXT_BTN,
225+
kind: "primary",
226+
onClick: function () {
227+
_runStep2();
228+
}
229+
}
230+
]);
231+
}, STEP1_DESIGN_MODE_HOLD_MS));
232+
}, STEP1_INVITE_MS));
233+
}
234+
235+
function _runStep2() {
236+
const $tab = $('.sidebar-tab[data-tab-id="ai"]');
237+
if (!$tab.length) {
238+
// No AI tab in this build — skip ahead to the next step.
239+
_runStep3();
240+
return;
241+
}
242+
_ensureOverlay();
243+
_trackTarget($tab, "right");
244+
_setStep(2);
245+
Metrics.countEvent(Metrics.EVENT_TYPE.GUIDE, "tour", "step2");
246+
_setText(Strings.PHOENIX_TOUR_AI_PANEL);
247+
_setActions([
248+
{
249+
label: Strings.PHOENIX_TOUR_NEXT_BTN,
250+
kind: "primary",
251+
onClick: function () {
252+
_runStep3();
253+
}
254+
}
255+
]);
256+
// Intentionally do NOT advance on a real click of the target — the
257+
// user needs time to read the prompt; only the Next button advances.
258+
}
259+
260+
function _runStep3() {
261+
const $newBtn = $("#newProject");
262+
if (!$newBtn.length) {
263+
// No new-project button — tour is effectively done.
264+
_markComplete();
265+
_teardown();
266+
return;
267+
}
268+
_ensureOverlay();
269+
_trackTarget($newBtn, "right");
270+
_setStep(3);
271+
Metrics.countEvent(Metrics.EVENT_TYPE.GUIDE, "tour", "step3");
272+
_setText(Strings.PHOENIX_TOUR_NEW_PROJECT);
273+
_setActions([
274+
{
275+
label: Strings.PHOENIX_TOUR_DISMISS_BTN,
276+
kind: "secondary",
277+
onClick: function () {
278+
Metrics.countEvent(Metrics.EVENT_TYPE.GUIDE, "tour", "dismiss");
279+
_markComplete();
280+
_teardown();
281+
}
282+
}
283+
]);
284+
// Intentionally do NOT end on a real click of the target — only the
285+
// Dismiss button ends the tour.
286+
}
287+
288+
function _shouldRun() {
289+
if (_ranThisSession) {
290+
return false;
291+
}
292+
if (_state.version >= CURRENT_TOUR_VERSION) {
293+
return false;
294+
}
295+
if (Phoenix.isTestWindow || Phoenix.isSpecRunnerWindow) {
296+
return false;
297+
}
298+
if (CentralControlBar.isEditorCollapsed && CentralControlBar.isEditorCollapsed()) {
299+
// User has already discovered design mode in some other way.
300+
return false;
301+
}
302+
if (!$("#ccbCollapseEditorBtn").length) {
303+
return false;
304+
}
305+
return true;
306+
}
307+
308+
/**
309+
* Resolves once the pro trial start dialog has been dismissed, or after
310+
* TRIAL_DIALOG_WAIT_TIMEOUT_MS as a fallback for builds/runs where the
311+
* dialog isn't shown.
312+
*/
313+
function _waitForTrialStartDialogDismissed() {
314+
return new Promise(function (resolve) {
315+
const dismissed = _LoginService && _LoginService.proTrialStartDialogDismissed;
316+
if (!dismissed) {
317+
// No pro trial flow exposed — proceed immediately.
318+
resolve();
319+
return;
320+
}
321+
let settled = false;
322+
const fallback = setTimeout(function () {
323+
if (settled) {
324+
return;
325+
}
326+
settled = true;
327+
resolve();
328+
}, TRIAL_DIALOG_WAIT_TIMEOUT_MS);
329+
// jQuery deferred or native promise — both implement .then
330+
Promise.resolve(dismissed).then(function () {
331+
if (settled) {
332+
return;
333+
}
334+
settled = true;
335+
clearTimeout(fallback);
336+
resolve();
337+
});
338+
});
339+
}
340+
341+
function startTour() {
342+
if (!_shouldRun()) {
343+
return;
344+
}
345+
_ranThisSession = true;
346+
Metrics.countEvent(Metrics.EVENT_TYPE.GUIDE, "tour", "start");
347+
348+
_waitForTrialStartDialogDismissed().then(function () {
349+
// Re-check primary preconditions after the wait — the user may
350+
// have already discovered design mode while a trial dialog was
351+
// up, or the button may have been torn down.
352+
if (!$("#ccbCollapseEditorBtn").length) {
353+
_markComplete();
354+
_teardown();
355+
return;
356+
}
357+
if (CentralControlBar.isEditorCollapsed && CentralControlBar.isEditorCollapsed()) {
358+
_markComplete();
359+
_teardown();
360+
return;
361+
}
362+
_timers.push(setTimeout(function () {
363+
if (!$("#ccbCollapseEditorBtn").length) {
364+
_markComplete();
365+
_teardown();
366+
return;
367+
}
368+
_runStep1();
369+
}, STEP_START_DELAY_MS));
370+
});
371+
}
372+
373+
exports.startTour = startTour;
374+
});

0 commit comments

Comments
 (0)