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) { -
+
+