feat(onboarding): first-run spotlight tour — dimmed backdrop, sidebar highlights, relaunchable
A guided tour for new operators: a dimmed+blurred overlay with an enterprise description card (mono eyebrow "01 / 06", display title, body, progress bar, back/skip/next). A step with a target spotlights the matching sidebar nav item (a box-shadow "hole" lights it while everything else dims); welcome + finish are centred cards. On narrow viewports (sidebar hidden) it falls back to centred cards. - auto-opens once per account (users.onboarding_tour_completed_at is null → autostart); skip or finish calls Tour::markSeen() which stamps it, so it never auto-opens again. - relaunchable any time from Settings (a client-side 'onboarding:start' window event — no DB change). - @persist'd in the layout + a sessionStorage guard so a wire:navigate or fast reload right after dismissal can't re-open it before markSeen persists. - nav-item now merges its attribute bag (data-tour passes through without duplicating class/href). Browser-verified: auto-open → spotlight walks Dashboard/Servers/Terminal/Settings → skip stamps + closes → navigation doesn't reopen → Settings relaunch works; zero console errors. 4 feature tests; full suite 473 pass; Codex review CLEAN. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
cc4cc3db4c
commit
dc26dccaa3
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Onboarding;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* First-run onboarding tour. Renders a dimmed spotlight overlay (the Alpine island in the view)
|
||||
* that walks the operator through the panel. It auto-opens once — when the account has never
|
||||
* dismissed it — and can be relaunched any time from Settings (a client-side window event, no
|
||||
* server round-trip). `markSeen` stamps the account so it never auto-opens again.
|
||||
*/
|
||||
class Tour extends Component
|
||||
{
|
||||
/** True on the first panel load for an account that has never finished/skipped the tour. */
|
||||
public bool $autostart = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->autostart = Auth::user()?->onboarding_tour_completed_at === null;
|
||||
}
|
||||
|
||||
/** Persist that the tour has been finished or skipped, so it won't auto-open again. */
|
||||
public function markSeen(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
if ($user !== null && $user->onboarding_tour_completed_at === null) {
|
||||
$user->forceFill(['onboarding_tour_completed_at' => now()])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tour steps. A null `target` is a centred card (welcome / finish); a non-null `target`
|
||||
* matches a `data-tour="…"` attribute on a sidebar nav item and is spotlighted.
|
||||
*
|
||||
* @return array<int, array<string, string|null>>
|
||||
*/
|
||||
public function steps(): array
|
||||
{
|
||||
return [
|
||||
['target' => null, 'icon' => 'command', 'eyebrow' => __('onboarding.welcome_eyebrow'), 'title' => __('onboarding.welcome_title'), 'body' => __('onboarding.welcome_body')],
|
||||
['target' => 'dashboard', 'icon' => 'dashboard', 'eyebrow' => __('onboarding.dashboard_eyebrow'), 'title' => __('onboarding.dashboard_title'), 'body' => __('onboarding.dashboard_body')],
|
||||
['target' => 'servers', 'icon' => 'server', 'eyebrow' => __('onboarding.servers_eyebrow'), 'title' => __('onboarding.servers_title'), 'body' => __('onboarding.servers_body')],
|
||||
['target' => 'terminal', 'icon' => 'terminal', 'eyebrow' => __('onboarding.terminal_eyebrow'), 'title' => __('onboarding.terminal_title'), 'body' => __('onboarding.terminal_body')],
|
||||
['target' => 'settings', 'icon' => 'settings', 'eyebrow' => __('onboarding.settings_eyebrow'), 'title' => __('onboarding.settings_title'), 'body' => __('onboarding.settings_body')],
|
||||
['target' => null, 'icon' => 'shield', 'eyebrow' => __('onboarding.done_eyebrow'), 'title' => __('onboarding.done_title'), 'body' => __('onboarding.done_body')],
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.onboarding.tour', ['steps' => $this->steps()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ class User extends Authenticatable
|
|||
'two_factor_recovery_codes' => 'encrypted:array',
|
||||
'two_factor_confirmed_at' => 'datetime',
|
||||
'must_change_password' => 'boolean',
|
||||
'onboarding_tour_completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* When the operator first finishes or skips the onboarding tour we stamp this; a null value means the
|
||||
* tour has never been dismissed, so it auto-opens once on the first panel load. It can always be
|
||||
* relaunched from Settings (that does NOT clear the stamp — the relaunch is a client-side event).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->timestamp('onboarding_tour_completed_at')->nullable()->after('must_change_password');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('onboarding_tour_completed_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
// Onboarding tour (first-run spotlight walkthrough). Relaunchable from Settings.
|
||||
return [
|
||||
'aria' => 'Einführungs-Tour',
|
||||
|
||||
'welcome_eyebrow' => 'Willkommen',
|
||||
'welcome_title' => 'Willkommen bei Clusev',
|
||||
'welcome_body' => 'Eine Konsole für deine gesamte Server-Flotte — agentenlos über SSH. Diese kurze Tour zeigt dir die wichtigsten Bereiche. Du kannst sie jederzeit überspringen.',
|
||||
|
||||
'dashboard_eyebrow' => 'Übersicht',
|
||||
'dashboard_title' => 'Dashboard',
|
||||
'dashboard_body' => 'Live-Metriken deiner Flotte auf einen Blick — CPU, RAM und der Status jedes Servers in Echtzeit.',
|
||||
|
||||
'servers_eyebrow' => 'Flotte',
|
||||
'servers_title' => 'Server',
|
||||
'servers_body' => 'Server hinzufügen und verwalten. Der Zugang läuft agentenlos über SSH; die Zugangsdaten liegen verschlüsselt im Vault.',
|
||||
|
||||
'terminal_eyebrow' => 'Betrieb',
|
||||
'terminal_title' => 'Terminal',
|
||||
'terminal_body' => 'Ein vollwertiges SSH-Terminal pro Server — und für den Clusev-Host selbst. Mit Host-Key-Pinning gegen MITM.',
|
||||
|
||||
'settings_eyebrow' => 'Konto',
|
||||
'settings_title' => 'Einstellungen',
|
||||
'settings_body' => 'Profil, 2FA, Konten und Sicherheit. Diese Tour kannst du hier jederzeit erneut starten.',
|
||||
|
||||
'done_eyebrow' => 'Fertig',
|
||||
'done_title' => 'Alles bereit',
|
||||
'done_body' => 'Das war die Kurztour. Leg los — und starte sie bei Bedarf jederzeit in den Einstellungen neu.',
|
||||
|
||||
'skip' => 'Überspringen',
|
||||
'back' => 'Zurück',
|
||||
'next' => 'Weiter',
|
||||
'done' => 'Fertig',
|
||||
'relaunch' => 'Tour starten',
|
||||
];
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
// Onboarding tour (first-run spotlight walkthrough). Relaunchable from Settings.
|
||||
return [
|
||||
'aria' => 'Onboarding tour',
|
||||
|
||||
'welcome_eyebrow' => 'Welcome',
|
||||
'welcome_title' => 'Welcome to Clusev',
|
||||
'welcome_body' => 'One console for your entire server fleet — agentless over SSH. This short tour points out the key areas. You can skip it any time.',
|
||||
|
||||
'dashboard_eyebrow' => 'Overview',
|
||||
'dashboard_title' => 'Dashboard',
|
||||
'dashboard_body' => 'Your fleet\'s live metrics at a glance — CPU, RAM and each server\'s status in real time.',
|
||||
|
||||
'servers_eyebrow' => 'Fleet',
|
||||
'servers_title' => 'Servers',
|
||||
'servers_body' => 'Add and manage servers. Access is agentless over SSH; credentials are held encrypted in the vault.',
|
||||
|
||||
'terminal_eyebrow' => 'Operations',
|
||||
'terminal_title' => 'Terminal',
|
||||
'terminal_body' => 'A full SSH terminal per server — and for the Clusev host itself. With host-key pinning against MITM.',
|
||||
|
||||
'settings_eyebrow' => 'Account',
|
||||
'settings_title' => 'Settings',
|
||||
'settings_body' => 'Profile, 2FA, accounts and security. You can relaunch this tour from here any time.',
|
||||
|
||||
'done_eyebrow' => 'Done',
|
||||
'done_title' => 'You\'re all set',
|
||||
'done_body' => 'That\'s the quick tour. Dive in — and relaunch it any time from Settings if you need it.',
|
||||
|
||||
'skip' => 'Skip',
|
||||
'back' => 'Back',
|
||||
'next' => 'Next',
|
||||
'done' => 'Done',
|
||||
'relaunch' => 'Start tour',
|
||||
];
|
||||
|
|
@ -145,6 +145,71 @@ document.addEventListener('alpine:init', () => {
|
|||
window.LivewireUIModal = guarded;
|
||||
}
|
||||
|
||||
// ── Onboarding tour ─────────────────────────────────────────────────
|
||||
// First-run spotlight tour. `steps` come from the Livewire component; a step with a `target`
|
||||
// spotlights the sidebar nav item carrying data-tour="<target>", otherwise the card is centred.
|
||||
// Auto-opens once (autostart), relaunchable via the `onboarding:start` window event; finishing
|
||||
// or skipping calls $wire.markSeen() so it never auto-opens again.
|
||||
window.Alpine.data('onboardingTour', (steps = [], autostart = false) => ({
|
||||
steps,
|
||||
open: false,
|
||||
i: 0,
|
||||
rect: null, // spotlight rect (fixed-viewport coords) or null → centred card
|
||||
get step() { return this.steps[this.i] || {}; },
|
||||
get isLast() { return this.i === this.steps.length - 1; },
|
||||
|
||||
init() {
|
||||
this._start = () => this.launch();
|
||||
this._reflow = () => { if (this.open) this.place(); };
|
||||
window.addEventListener('onboarding:start', this._start);
|
||||
window.addEventListener('resize', this._reflow);
|
||||
window.addEventListener('scroll', this._reflow, true);
|
||||
// Auto-open once — but not if it was already dismissed this session (guards the window
|
||||
// between finish() and markSeen() persisting, e.g. a fast reload). @persist covers navigation.
|
||||
if (autostart && !this._dismissed()) this.$nextTick(() => this.launch());
|
||||
},
|
||||
_dismissed() { try { return sessionStorage.getItem('clusev.tour.dismissed') === '1'; } catch (_) { return false; } },
|
||||
destroy() {
|
||||
window.removeEventListener('onboarding:start', this._start);
|
||||
window.removeEventListener('resize', this._reflow);
|
||||
window.removeEventListener('scroll', this._reflow, true);
|
||||
},
|
||||
|
||||
launch() { this.i = 0; this.open = true; this.$nextTick(() => this.place()); },
|
||||
place() {
|
||||
const t = this.step.target;
|
||||
if (!t) { this.rect = null; return; }
|
||||
const el = document.querySelector(`[data-tour="${t}"]`);
|
||||
const r = el && el.getBoundingClientRect();
|
||||
// Hidden target (e.g. the mobile drawer is closed) → fall back to a centred card.
|
||||
this.rect = (r && r.width > 0 && r.height > 0) ? { top: r.top, left: r.left, width: r.width, height: r.height } : null;
|
||||
},
|
||||
next() { if (this.isLast) return this.finish(); this.i++; this.$nextTick(() => this.place()); },
|
||||
prev() { if (this.i > 0) { this.i--; this.$nextTick(() => this.place()); } },
|
||||
skip() { this.finish(); },
|
||||
finish() {
|
||||
this.open = false;
|
||||
this.rect = null;
|
||||
try { sessionStorage.setItem('clusev.tour.dismissed', '1'); } catch (_) {}
|
||||
try { this.$wire.markSeen(); } catch (_) {}
|
||||
},
|
||||
|
||||
spotStyle() {
|
||||
const r = this.rect; if (!r) return 'display:none';
|
||||
const p = 6;
|
||||
return `top:${r.top - p}px;left:${r.left - p}px;width:${r.width + p * 2}px;height:${r.height + p * 2}px`;
|
||||
},
|
||||
cardStyle() {
|
||||
const r = this.rect; if (!r) return '';
|
||||
const gap = 18, w = 380, h = 250;
|
||||
// Prefer to the right of the target (the sidebar sits on the left); flip left if it overflows.
|
||||
let left = r.left + r.width + gap;
|
||||
if (left + w > window.innerWidth - 12) left = Math.max(12, r.left - w - gap);
|
||||
const top = Math.max(12, Math.min(r.top, window.innerHeight - h - 12));
|
||||
return `top:${top}px;left:${left}px`;
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Modal trigger ───────────────────────────────────────────────────
|
||||
// Reused by <x-modal-trigger>. Gives EVERY modal-opening control an immediate
|
||||
// spinner (pending flips synchronously on click — no round-trip needed) that clears
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
@props(['icon', 'href' => '#', 'active' => false, 'badge' => null])
|
||||
<a href="{{ $href }}"
|
||||
@class([
|
||||
<a {{ $attributes->class([
|
||||
'group flex min-h-11 items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors',
|
||||
'border-accent/25 bg-accent/10 text-ink shadow-[inset_2px_0_0_var(--color-accent)]' => $active,
|
||||
'border-transparent text-ink-2 hover:bg-raised hover:text-ink' => ! $active,
|
||||
])
|
||||
])->merge(['href' => $href]) }}
|
||||
@if ($active) aria-current="page" @endif>
|
||||
<x-icon :name="$icon" @class(['h-[18px] w-[18px] shrink-0', 'text-accent' => $active]) />
|
||||
<span class="truncate">{{ $slot }}</span>
|
||||
|
|
|
|||
|
|
@ -28,16 +28,16 @@
|
|||
{{-- Nav --}}
|
||||
<nav class="flex-1 space-y-1 overflow-y-auto p-3">
|
||||
<p class="px-3 pb-1 pt-2 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_fleet') }}</p>
|
||||
<x-nav-item icon="dashboard" href="/" :active="request()->is('/')">{{ __('shell.nav_dashboard') }}</x-nav-item>
|
||||
<x-nav-item icon="server" href="/servers" :active="request()->is('servers*')">{{ __('shell.nav_servers') }}</x-nav-item>
|
||||
<x-nav-item icon="dashboard" href="/" :active="request()->is('/')" data-tour="dashboard">{{ __('shell.nav_dashboard') }}</x-nav-item>
|
||||
<x-nav-item icon="server" href="/servers" :active="request()->is('servers*')" data-tour="servers">{{ __('shell.nav_servers') }}</x-nav-item>
|
||||
<x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item>
|
||||
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item>
|
||||
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item>
|
||||
<x-nav-item icon="lock" href="/wireguard" :active="request()->is('wireguard*')">{{ __('shell.nav_wireguard') }}</x-nav-item>
|
||||
<x-nav-item icon="terminal" href="/terminal" :active="request()->is('terminal*')">{{ __('shell.nav_terminal') }}</x-nav-item>
|
||||
<x-nav-item icon="terminal" href="/terminal" :active="request()->is('terminal*')" data-tour="terminal">{{ __('shell.nav_terminal') }}</x-nav-item>
|
||||
|
||||
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>
|
||||
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">{{ __('shell.nav_settings') }}</x-nav-item>
|
||||
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')" data-tour="settings">{{ __('shell.nav_settings') }}</x-nav-item>
|
||||
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item>
|
||||
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')" :badge="$updateAvailable ? '1' : null">{{ __('shell.nav_versions') }}</x-nav-item>
|
||||
@if (config('clusev.release_controls'))
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@
|
|||
</div>
|
||||
@include('partials.toaster')
|
||||
|
||||
{{-- First-run onboarding tour (dimmed spotlight overlay; auto-opens once, relaunchable from Settings).
|
||||
@persist so it does NOT re-mount on wire:navigate — otherwise mount() would re-read the flag and
|
||||
could re-open the tour in the window before markSeen() persists. --}}
|
||||
@persist('onboarding-tour')
|
||||
<livewire:onboarding.tour />
|
||||
@endpersist
|
||||
|
||||
{{-- Command palette + keyboard shortcuts (persistent; one global keydown listener) --}}
|
||||
<x-command-palette />
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
<div x-data="onboardingTour(@js($steps), @js($autostart))">
|
||||
{{-- The whole overlay only exists while the tour is open. --}}
|
||||
<div x-show="open" x-cloak class="fixed inset-0 z-[80]" role="dialog" aria-modal="true" aria-label="{{ __('onboarding.aria') }}">
|
||||
{{-- Click-catcher: blocks the app underneath (no accidental skip — dismissal is explicit). --}}
|
||||
<div class="absolute inset-0"></div>
|
||||
|
||||
{{-- Centred steps (welcome / finish, or when the target isn't visible) get a full dim + blur. --}}
|
||||
<div x-show="! rect" x-transition.opacity class="absolute inset-0 bg-void/80 backdrop-blur-sm"></div>
|
||||
|
||||
{{-- Spotlight: a transparent box whose huge box-shadow dims everything around the target. --}}
|
||||
<div x-show="rect" x-cloak :style="spotStyle()"
|
||||
class="pointer-events-none absolute rounded-lg ring-2 ring-accent/70 shadow-[0_0_0_9999px_color-mix(in_srgb,var(--color-void)_84%,transparent)] transition-all duration-300 ease-out"></div>
|
||||
|
||||
{{-- The description card. --}}
|
||||
<div x-cloak
|
||||
:class="rect ? '' : 'left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'"
|
||||
:style="rect ? cardStyle() : ''"
|
||||
class="pointer-events-auto absolute w-[min(92vw,380px)] overflow-hidden rounded-xl border border-line bg-surface shadow-panel"
|
||||
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-1" x-transition:enter-end="opacity-100 translate-y-0">
|
||||
{{-- Accent hairline top edge. --}}
|
||||
<div class="h-0.5 w-full bg-gradient-to-r from-accent via-accent/40 to-transparent"></div>
|
||||
|
||||
<div class="p-5">
|
||||
<button type="button" @click="skip()" aria-label="{{ __('onboarding.skip') }}"
|
||||
class="absolute right-3 top-3 grid h-7 w-7 place-items-center rounded-md text-ink-4 transition-colors hover:bg-raised hover:text-ink-2">
|
||||
<x-icon name="x" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-lg border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-4px_var(--color-accent)]">
|
||||
<x-icon name="command" class="h-4 w-4" x-show="step.icon==='command'" />
|
||||
<x-icon name="dashboard" class="h-4 w-4" x-show="step.icon==='dashboard'" x-cloak />
|
||||
<x-icon name="server" class="h-4 w-4" x-show="step.icon==='server'" x-cloak />
|
||||
<x-icon name="terminal" class="h-4 w-4" x-show="step.icon==='terminal'" x-cloak />
|
||||
<x-icon name="settings" class="h-4 w-4" x-show="step.icon==='settings'" x-cloak />
|
||||
<x-icon name="shield" class="h-4 w-4" x-show="step.icon==='shield'" x-cloak />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1 pr-6">
|
||||
<p class="font-mono text-[10px] uppercase tracking-[0.2em] text-accent-text">
|
||||
<span x-text="'0' + (i + 1)"></span><span class="text-ink-4"> / 0<span x-text="steps.length"></span></span>
|
||||
<span class="text-ink-4"> · </span><span x-text="step.eyebrow"></span>
|
||||
</p>
|
||||
<h2 class="mt-1 font-display text-lg font-semibold leading-tight text-ink" x-text="step.title"></h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-sm leading-relaxed text-ink-2" x-text="step.body"></p>
|
||||
|
||||
{{-- Progress bar. --}}
|
||||
<div class="mt-4 h-1 w-full overflow-hidden rounded-full bg-inset">
|
||||
<div class="h-full rounded-full bg-accent transition-all duration-300" :style="'width:' + Math.round(((i + 1) / steps.length) * 100) + '%'"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-2">
|
||||
<button type="button" @click="skip()" class="font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">{{ __('onboarding.skip') }}</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-btn variant="secondary" size="sm" x-show="i > 0" x-cloak @click="prev()">
|
||||
<x-icon name="chevron-left" class="h-3.5 w-3.5" />{{ __('onboarding.back') }}
|
||||
</x-btn>
|
||||
<x-btn variant="primary" size="sm" @click="next()">
|
||||
<span x-show="! isLast">{{ __('onboarding.next') }}</span>
|
||||
<span x-show="isLast" x-cloak>{{ __('onboarding.done') }}</span>
|
||||
<x-icon name="chevron-right" class="h-3.5 w-3.5" x-show="! isLast" />
|
||||
</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -25,6 +25,10 @@
|
|||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $u->email }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" x-data @click="window.dispatchEvent(new CustomEvent('onboarding:start'))"
|
||||
class="inline-flex min-h-9 items-center gap-1.5 rounded-md border border-line px-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/40 hover:text-accent-text">
|
||||
<x-icon name="help-circle" class="h-3.5 w-3.5" />{{ __('onboarding.relaunch') }}
|
||||
</button>
|
||||
<x-badge tone="neutral">{{ __('settings.role_admin') }}</x-badge>
|
||||
<x-two-factor-badge :enabled="$twoFactorEnabled" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Onboarding\Tour;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OnboardingTourTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_autostart_is_true_only_when_the_tour_was_never_dismissed(): void
|
||||
{
|
||||
Livewire::actingAs(User::factory()->create(['onboarding_tour_completed_at' => null]))
|
||||
->test(Tour::class)->assertSet('autostart', true);
|
||||
|
||||
Livewire::actingAs(User::factory()->create(['onboarding_tour_completed_at' => now()]))
|
||||
->test(Tour::class)->assertSet('autostart', false);
|
||||
}
|
||||
|
||||
public function test_mark_seen_stamps_the_account_once(): void
|
||||
{
|
||||
$user = User::factory()->create(['onboarding_tour_completed_at' => null]);
|
||||
|
||||
Livewire::actingAs($user)->test(Tour::class)->call('markSeen');
|
||||
|
||||
$this->assertNotNull($user->fresh()->onboarding_tour_completed_at);
|
||||
}
|
||||
|
||||
public function test_mark_seen_does_not_overwrite_an_existing_stamp(): void
|
||||
{
|
||||
$stamp = now()->subDays(3);
|
||||
$user = User::factory()->create(['onboarding_tour_completed_at' => $stamp]);
|
||||
|
||||
Livewire::actingAs($user)->test(Tour::class)->call('markSeen');
|
||||
|
||||
$this->assertEquals($stamp->timestamp, $user->fresh()->onboarding_tour_completed_at->timestamp);
|
||||
}
|
||||
|
||||
public function test_the_tour_renders_all_six_steps_on_the_panel(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create(['must_change_password' => false]));
|
||||
|
||||
$this->get('/')->assertOk()->assertSee(__('onboarding.welcome_title'))->assertSee(__('onboarding.done_title'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue