From 68dd1b3e05001ee0afd0663dbad6f5c607b6ab86 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Jul 2026 18:06:47 -0500 Subject: [PATCH] Tell people what to do next, from what the account actually contains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user reported not knowing how to get content onto a screen. There was already onboarding — a modal wizard — but it is gated on a localStorage flag: skip it once and it never comes back, and it never knew whether you succeeded at anything. Someone who closed it was left with no thread to pull, which is exactly what was described. A second tour would repeat that mistake. Tours are dismissed and forgotten, and they describe the product rather than the account. This is a checklist on the dashboard that reads real state, so it cannot claim you have done something you have not, it is still there tomorrow, and it names the one thing to do next rather than everything the product can do. The steps are the shortest true path to a screen showing something: connect a screen, add content, put it in a playlist, send it to the screen. Only the last one cannot be satisfied by creating an object and walking away — a screen has to actually be pointed at something — so an account full of playlists with nothing playing is correctly reported as unfinished, which is the failure that was reported. Steps stay in dependency order, so nobody is sent to a page they cannot use yet. It disappears on its own once the first screen is live and can be hidden before then, so it never nags someone who already knows the product. Once hidden or finished it costs no extra request at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- frontend/js/components/getting-started.js | 142 ++++++++++++++++++ frontend/js/i18n/en.js | 19 +++ frontend/js/views/dashboard.js | 21 ++- server/test/getting-started-checklist.test.js | 101 +++++++++++++ 4 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 frontend/js/components/getting-started.js create mode 100644 server/test/getting-started-checklist.test.js diff --git a/frontend/js/components/getting-started.js b/frontend/js/components/getting-started.js new file mode 100644 index 0000000..bce140e --- /dev/null +++ b/frontend/js/components/getting-started.js @@ -0,0 +1,142 @@ +// "What do I do next?" — answered from what the account ACTUALLY contains. +// +// There was already an onboarding wizard, but it is a one-time modal gated on a localStorage +// flag: skip it once and it never returns, and it never knew whether you succeeded at anything. +// Someone who closed it and then wondered how to get content on a screen had nothing left to go +// on, which is exactly the confusion that got reported. +// +// A second tour would repeat that mistake. Tours are dismissed and forgotten, and they describe +// the product rather than the account. This reads real state instead, so it cannot claim you have +// done something you have not, it is still there tomorrow, and it disappears by itself once the +// first screen is actually live — no nagging anyone who already knows the product. +// +// The steps are the shortest true path to a screen showing something: get a screen connected, +// get media in, arrange it, put it on the screen. + +import { t } from '../i18n.js'; + +// Pure: given what the account holds, which steps are done and which is next. Separated from the +// DOM so the logic that decides "you are finished" is testable — a checklist that congratulates +// you too early is worse than none. +export function computeSteps({ devices = [], content = [], playlists = [] } = {}) { + const hasDevice = devices.length > 0; + const hasContent = content.length > 0; + const hasPlaylist = playlists.length > 0; + // "On screen" is the only step that cannot be faked by creating an object and walking away: + // some screen has to actually be pointed at something. + const isAssigned = devices.some((d) => d.playlist_id || d.default_content_id || d.layout_id); + + const steps = [ + { + key: 'device', + done: hasDevice, + title: t('gs.device.title'), + desc: t('gs.device.desc'), + cta: t('gs.device.cta'), + href: '#/', + action: 'add-device', + }, + { + key: 'content', + done: hasContent, + title: t('gs.content.title'), + desc: t('gs.content.desc'), + cta: t('gs.content.cta'), + href: '#/content', + }, + { + key: 'playlist', + done: hasPlaylist, + title: t('gs.playlist.title'), + desc: t('gs.playlist.desc'), + cta: t('gs.playlist.cta'), + href: '#/playlists', + }, + { + key: 'assign', + done: isAssigned, + title: t('gs.assign.title'), + desc: t('gs.assign.desc'), + cta: t('gs.assign.cta'), + href: '#/', + }, + ]; + + // The NEXT step is the first unfinished one — in order, because each genuinely depends on the + // one before it. Highlighting anything else would send someone to a screen they cannot use yet. + const nextIndex = steps.findIndex((s) => !s.done); + return { + steps, + nextIndex, + complete: nextIndex === -1, + doneCount: steps.filter((s) => s.done).length, + }; +} + +const DISMISS_KEY = 'rd_gs_dismissed'; +export const isDismissed = () => localStorage.getItem(DISMISS_KEY) === '1'; +export const dismiss = () => localStorage.setItem(DISMISS_KEY, '1'); +export const undismiss = () => localStorage.removeItem(DISMISS_KEY); + +// Show it while there is still something to do and the user has not put it away. Deliberately +// NOT gated on "is this a new account" — someone who has had the product a month and still has no +// content is exactly who needs it. +export function shouldShow(state) { + return !state.complete && !isDismissed(); +} + +export function render(host, state, { onAction } = {}) { + if (!host) return; + if (!shouldShow(state)) { host.innerHTML = ''; host.style.display = 'none'; return; } + host.style.display = ''; + + const { steps, nextIndex, doneCount } = state; + host.innerHTML = ` +
+
+
+
${t('gs.title')}
+
${t('gs.progress').replace('{done}', doneCount).replace('{total}', steps.length)}
+
+ +
+
+
+
+
+ ${steps.map((s, i) => { + const isNext = i === nextIndex; + return ` +
+
+ ${s.done ? '✓' : i + 1} +
+
+
${s.title}
+ ${!s.done ? `
${s.desc}
` : ''} +
+ ${!s.done && isNext ? `` : ''} +
`; + }).join('')} +
+
`; + + host.querySelector('#gsDismiss')?.addEventListener('click', () => { + dismiss(); + host.innerHTML = ''; + host.style.display = 'none'; + }); + host.querySelectorAll('[data-gs-step]').forEach((btn) => { + btn.addEventListener('click', () => { + const step = steps.find((s) => s.key === btn.dataset.gsStep); + if (!step) return; + // An in-page action (open the pairing dialog) beats navigating somewhere and leaving the + // user to find the button again. + if (step.action && onAction && onAction(step.action)) return; + window.location.hash = step.href; + }); + }); +} diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 0067e81..45b48a3 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -1,6 +1,25 @@ // English translations. This file is the source of truth for keys — // every other locale should mirror its keys (or fall back to en). export default { + // Getting-started checklist (components/getting-started.js). Driven by real account state, + // not a one-time flag, so it can tell someone what is actually left to do. + 'gs.title': 'Get your first screen live', + 'gs.progress': '{done} of {total} done', + 'gs.dismiss': 'Hide', + 'gs.device.title': 'Connect a screen', + 'gs.device.desc': 'Open the player on your display, then enter the code it shows.', + 'gs.device.cta': 'Add screen', + 'gs.content.title': 'Add some content', + 'gs.content.desc': 'Upload images or video, or add a web page or widget.', + 'gs.content.cta': 'Add content', + 'gs.playlist.title': 'Put content in a playlist', + 'gs.playlist.desc': 'A playlist is the running order your screen loops through.', + 'gs.playlist.cta': 'New playlist', + 'gs.assign.title': 'Send it to the screen', + 'gs.assign.desc': 'Open the screen and assign the playlist — it starts playing straight away.', + 'gs.assign.cta': 'Assign', + 'gs.reopen': 'Show getting-started checklist', + // #zone-orphan dashboard warnings 'device.pl_item.orphan_zone': 'Zone from a different layout — reassign', 'device.pl_item.orphan_zone_tip': "This item's zone isn't part of the device's current layout. It still plays (recovered into the largest zone), but reassign it to a zone in this layout.", diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index 2eb42e1..1fc8601 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -3,6 +3,7 @@ import { on, off, requestScreenshot } from '../socket.js'; import { showToast } from '../components/toast.js'; import { esc, livenessBadge } from '../utils.js'; import { t, tn } from '../i18n.js'; +import * as gettingStarted from '../components/getting-started.js'; import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js'; const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown']; @@ -273,7 +274,8 @@ export function render(container) { -
+
+