Skip to content

Commit 5f04556

Browse files
committed
feat: onboarding tour after account creation with real-time availability check
1 parent 1d9766b commit 5f04556

4 files changed

Lines changed: 267 additions & 0 deletions

File tree

config/migrate.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const tables = {
2626
{ name: 'email_change_code', def: 'VARCHAR(10) DEFAULT NULL' },
2727
{ name: 'email_change_expires', def: 'DATETIME DEFAULT NULL' },
2828
{ name: 'user_agent', def: 'VARCHAR(512) DEFAULT NULL' },
29+
{ name: 'onboarding_done', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
2930
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
3031
],
3132
},

public/css/style.css

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4433,3 +4433,84 @@ tbody tr:hover {
44334433
background: rgba(239, 68, 68, 0.1);
44344434
}
44354435

4436+
/* ===== ONBOARDING TOUR ===== */
4437+
.onboarding-overlay {
4438+
position: fixed;
4439+
inset: 0;
4440+
z-index: 9999;
4441+
display: flex;
4442+
align-items: center;
4443+
justify-content: center;
4444+
}
4445+
4446+
.onboarding-backdrop {
4447+
position: absolute;
4448+
inset: 0;
4449+
background: rgba(0, 0, 0, 0.7);
4450+
transition: opacity 0.25s ease;
4451+
}
4452+
4453+
.onboarding-card {
4454+
position: relative;
4455+
z-index: 10001;
4456+
background: var(--bg-card);
4457+
border: 1px solid var(--border);
4458+
border-radius: var(--radius-xl);
4459+
padding: 40px 36px;
4460+
max-width: 480px;
4461+
width: 90%;
4462+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
4463+
text-align: center;
4464+
}
4465+
4466+
.onboarding-card h2 {
4467+
font-size: 1.4rem;
4468+
font-weight: 700;
4469+
margin-bottom: 12px;
4470+
}
4471+
4472+
.onboarding-card p {
4473+
font-size: 0.92rem;
4474+
color: var(--text-secondary);
4475+
line-height: 1.65;
4476+
margin-bottom: 28px;
4477+
}
4478+
4479+
.onboarding-card .onboarding-actions {
4480+
display: flex;
4481+
gap: 12px;
4482+
justify-content: center;
4483+
}
4484+
4485+
.onboarding-card .onboarding-actions .btn {
4486+
min-width: 110px;
4487+
}
4488+
4489+
.onboarding-highlight {
4490+
position: relative !important;
4491+
z-index: 10000 !important;
4492+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.7) !important;
4493+
border-radius: var(--radius-sm);
4494+
}
4495+
4496+
.onboarding-dots {
4497+
display: flex;
4498+
gap: 6px;
4499+
justify-content: center;
4500+
margin-bottom: 20px;
4501+
}
4502+
4503+
.onboarding-dot {
4504+
width: 8px;
4505+
height: 8px;
4506+
border-radius: 50%;
4507+
background: var(--border);
4508+
transition: all var(--transition);
4509+
}
4510+
4511+
.onboarding-dot.active {
4512+
background: var(--accent-1);
4513+
width: 24px;
4514+
border-radius: 4px;
4515+
}
4516+

public/js/app.js

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,6 +1139,7 @@ async function renderVerifyEmail(token) {
11391139
localStorage.setItem('zh_user', JSON.stringify(data.user));
11401140
history.replaceState({ page: 'overview' }, '', '/');
11411141
renderDashboard();
1142+
checkAndStartOnboarding();
11421143
showToast('Email verified successfully!', 'success');
11431144
} catch (err) {
11441145
app.innerHTML = html`
@@ -1481,6 +1482,10 @@ async function renderDashboard() {
14811482
<i data-lucide="settings" style="width:16px;height:16px"></i>
14821483
Settings
14831484
</a>
1485+
<a class="sidebar-user-dropdown-item" id="user-dropdown-tour">
1486+
<i data-lucide="map" style="width:16px;height:16px"></i>
1487+
Take a Tour
1488+
</a>
14841489
<a class="sidebar-user-dropdown-item" id="user-dropdown-logout">
14851490
<i data-lucide="log-out" style="width:16px;height:16px"></i>
14861491
Logout
@@ -1552,6 +1557,12 @@ async function renderDashboard() {
15521557
navigateTo('account/info');
15531558
});
15541559

1560+
$('#user-dropdown-tour').addEventListener('click', (e) => {
1561+
e.preventDefault();
1562+
closeUserDropdown();
1563+
startOnboarding();
1564+
});
1565+
15551566
$('#user-dropdown-logout').addEventListener('click', async (e) => {
15561567
e.preventDefault();
15571568
closeUserDropdown();
@@ -3997,6 +4008,159 @@ async function handleExportData() {
39974008
}
39984009
}
39994010

4011+
// ===== ONBOARDING TOUR =====
4012+
const ONBOARDING_STEPS = [
4013+
{
4014+
title: 'Welcome to ZeroHost!',
4015+
description: 'Would you like a quick tour of your dashboard? It only takes a minute.',
4016+
highlight: null,
4017+
buttons: ['Skip', 'Start'],
4018+
},
4019+
{
4020+
title: 'Dashboard Overview',
4021+
description: `This is your Dashboard — the central hub. Here you get an overview of all your servers, their status, and resource usage at a glance.`,
4022+
highlight: '#sidebar-nav a.nav-item[data-page="overview"]',
4023+
buttons: ['Skip', 'Next'],
4024+
},
4025+
{
4026+
title: 'Create a Server',
4027+
description: 'Use this button to deploy a new server. You can choose from different game types and configurations.',
4028+
highlight: '#sidebar-nav a.nav-item[data-page="create"]',
4029+
buttons: ['Skip', 'Next'],
4030+
},
4031+
{
4032+
title: 'My Servers',
4033+
description: 'All your servers live here. Manage them, check their status, rename them, and more.',
4034+
highlight: '#nav-servers-toggle',
4035+
buttons: ['Skip', 'Next'],
4036+
},
4037+
{
4038+
title: 'Two things to remember',
4039+
description: `<strong>Dashboard</strong> — Where you create servers, change names, view status, and manage your account.<br><br><strong>Hydrodactyl Panel</strong> — Where you start/stop your server, manage files, use the console, and control everything.`,
4040+
highlight: null,
4041+
buttons: ['Skip', 'Got it!'],
4042+
},
4043+
];
4044+
4045+
let onboardingActive = false;
4046+
let onboardingStep = 0;
4047+
let onboardingHighlightEl = null;
4048+
4049+
function clearOnboardingHighlight() {
4050+
if (onboardingHighlightEl) {
4051+
onboardingHighlightEl.classList.remove('onboarding-highlight');
4052+
onboardingHighlightEl = null;
4053+
}
4054+
}
4055+
4056+
function applyOnboardingHighlight(selector) {
4057+
clearOnboardingHighlight();
4058+
if (!selector) return;
4059+
const el = document.querySelector(selector);
4060+
if (el) {
4061+
el.classList.add('onboarding-highlight');
4062+
onboardingHighlightEl = el;
4063+
}
4064+
}
4065+
4066+
function renderOnboardingStep(stepIndex) {
4067+
const card = $('#onboarding-card');
4068+
if (!card) return;
4069+
4070+
const step = ONBOARDING_STEPS[stepIndex];
4071+
const isLast = stepIndex === ONBOARDING_STEPS.length - 1;
4072+
const isFirst = stepIndex === 0;
4073+
4074+
let dotsHtml = '<div class="onboarding-dots">';
4075+
ONBOARDING_STEPS.forEach((_, i) => {
4076+
dotsHtml += `<span class="onboarding-dot ${i === stepIndex ? 'active' : ''}"></span>`;
4077+
});
4078+
dotsHtml += '</div>';
4079+
4080+
const leftBtn = step.buttons[0]; // Skip
4081+
const rightBtn = step.buttons[1]; // Start / Next / Got it!
4082+
4083+
card.innerHTML = html`
4084+
${dotsHtml}
4085+
<h2>${escapeHtml(step.title)}</h2>
4086+
<p>${step.description}</p>
4087+
<div class="onboarding-actions">
4088+
<button class="btn btn-ghost" id="onboarding-skip">${leftBtn}</button>
4089+
<button class="btn btn-primary" id="onboarding-next">${rightBtn}</button>
4090+
</div>
4091+
`;
4092+
4093+
applyOnboardingHighlight(step.highlight);
4094+
initIcons();
4095+
}
4096+
4097+
async function startOnboarding() {
4098+
if (onboardingActive) return;
4099+
onboardingActive = true;
4100+
onboardingStep = 0;
4101+
4102+
const existing = $('#onboarding-overlay');
4103+
if (existing) existing.remove();
4104+
4105+
const overlay = document.createElement('div');
4106+
overlay.id = 'onboarding-overlay';
4107+
overlay.className = 'onboarding-overlay';
4108+
overlay.innerHTML = html`
4109+
<div class="onboarding-backdrop" id="onboarding-backdrop"></div>
4110+
<div class="onboarding-card" id="onboarding-card"></div>
4111+
`;
4112+
document.body.appendChild(overlay);
4113+
4114+
renderOnboardingStep(0);
4115+
4116+
$('#onboarding-skip').addEventListener('click', stopOnboarding);
4117+
$('#onboarding-next').addEventListener('click', () => {
4118+
if (onboardingStep === 0) {
4119+
onboardingStep = 1;
4120+
renderOnboardingStep(onboardingStep);
4121+
$('#onboarding-skip').addEventListener('click', stopOnboarding);
4122+
$('#onboarding-next').addEventListener('click', handleOnboardingNext);
4123+
} else {
4124+
handleOnboardingNext();
4125+
}
4126+
});
4127+
}
4128+
4129+
function handleOnboardingNext() {
4130+
if (onboardingStep < ONBOARDING_STEPS.length - 1) {
4131+
onboardingStep++;
4132+
renderOnboardingStep(onboardingStep);
4133+
$('#onboarding-skip').addEventListener('click', stopOnboarding);
4134+
$('#onboarding-next').addEventListener('click', handleOnboardingNext);
4135+
} else if (onboardingStep === ONBOARDING_STEPS.length - 1) {
4136+
stopOnboarding(true);
4137+
}
4138+
}
4139+
4140+
async function stopOnboarding(done) {
4141+
if (!onboardingActive) return;
4142+
onboardingActive = false;
4143+
clearOnboardingHighlight();
4144+
const overlay = $('#onboarding-overlay');
4145+
if (overlay) overlay.remove();
4146+
4147+
if (done && state.token) {
4148+
try {
4149+
await api('/auth/complete-onboarding', { method: 'POST' });
4150+
} catch {}
4151+
}
4152+
}
4153+
4154+
async function checkAndStartOnboarding() {
4155+
if (!state.token) return;
4156+
try {
4157+
const data = await api('/auth/onboarding-status');
4158+
if (!data.done) {
4159+
startOnboarding();
4160+
}
4161+
} catch {}
4162+
}
4163+
40004164
// ===== INIT =====
40014165
function init() {
40024166
const path = window.location.pathname;
@@ -4033,6 +4197,7 @@ function init() {
40334197
if (state.token) {
40344198
history.replaceState({ page: 'overview' }, '', '/');
40354199
renderDashboard();
4200+
if (basePage !== 'signup') checkAndStartOnboarding();
40364201
} else if (basePage === 'login') {
40374202
renderLoginPage();
40384203
} else {
@@ -4041,6 +4206,7 @@ function init() {
40414206
} else if (state.token) {
40424207
api('/servers/overview').then(() => {
40434208
renderDashboard();
4209+
checkAndStartOnboarding();
40444210
fetchUnreadCount();
40454211
setInterval(fetchUnreadCount, 30000);
40464212
}).catch(() => {

routes/auth.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,25 @@ router.post('/delete-account', authenticateToken, sensitiveLimiter, async (req,
786786
}
787787
});
788788

789+
router.get('/onboarding-status', authenticateToken, async (req, res) => {
790+
try {
791+
const users = await query('SELECT onboarding_done FROM users WHERE id = ?', [req.user.userId]);
792+
if (users.length === 0) return res.status(404).json({ error: 'User not found' });
793+
res.json({ done: !!users[0].onboarding_done });
794+
} catch {
795+
res.status(500).json({ error: 'Failed to check onboarding status' });
796+
}
797+
});
798+
799+
router.post('/complete-onboarding', authenticateToken, async (req, res) => {
800+
try {
801+
await query('UPDATE users SET onboarding_done = 1 WHERE id = ?', [req.user.userId]);
802+
res.json({ done: true });
803+
} catch {
804+
res.status(500).json({ error: 'Failed to update onboarding status' });
805+
}
806+
});
807+
789808
router.post('/logout', authenticateToken, async (req, res) => {
790809
try {
791810
// Bump token_version to invalidate all existing sessions

0 commit comments

Comments
 (0)