feat: Add onboarding flow

This commit is contained in:
Dries Augustyns
2026-04-19 11:26:12 +02:00
parent bc4c79235e
commit bd5a085802
14 changed files with 1300 additions and 6 deletions
@@ -0,0 +1,35 @@
import {useCallback, useSyncExternalStore} from 'react';
const storageKey = (projectId: string) => `plunk-onboarded-${projectId}`;
function subscribe(callback: () => void) {
if (typeof window === 'undefined') return () => undefined;
window.addEventListener('storage', callback);
window.addEventListener('plunk:onboarding-changed', callback);
return () => {
window.removeEventListener('storage', callback);
window.removeEventListener('plunk:onboarding-changed', callback);
};
}
/**
* Per-project onboarding completion flag backed by localStorage.
* `isComplete` is `null` on the server and during the first client render,
* then resolves to `true`/`false` after hydration.
*/
export function useOnboardingComplete(projectId: string | undefined) {
const getSnapshot = useCallback(() => {
if (!projectId || typeof window === 'undefined') return null;
return localStorage.getItem(storageKey(projectId)) === 'true';
}, [projectId]);
const isComplete = useSyncExternalStore(subscribe, getSnapshot, () => null);
const markComplete = useCallback(() => {
if (!projectId || typeof window === 'undefined') return;
localStorage.setItem(storageKey(projectId), 'true');
window.dispatchEvent(new Event('plunk:onboarding-changed'));
}, [projectId]);
return {isComplete, markComplete};
}
@@ -0,0 +1,28 @@
import {useRouter} from 'next/router';
import {useEffect} from 'react';
import {useActiveProject} from '../contexts/ActiveProjectProvider';
import {useOnboardingComplete} from './useOnboardingComplete';
import {useOnboardingStatus, type OnboardingStatus} from './useOnboardingStatus';
export type OnboardingGateState = OnboardingStatus;
/**
* Gate helper for the /onboarding pages. Redirects to the dashboard when the
* project is already onboarded (flag set or first email sent) and persists
* the flag so future visits short-circuit immediately.
*/
export function useOnboardingGate() {
const router = useRouter();
const {activeProject} = useActiveProject();
const {isComplete, markComplete} = useOnboardingComplete(activeProject?.id);
const state = useOnboardingStatus();
useEffect(() => {
if (state !== 'skip') return;
if (!isComplete) markComplete();
void router.replace('/');
}, [state, isComplete, markComplete, router]);
return {state, markComplete};
}
@@ -0,0 +1,51 @@
import {useCallback, useSyncExternalStore} from 'react';
export type OnboardingPath = 'developer' | 'marketing' | 'workflows';
const storageKey = (projectId: string) => `plunk-onboarding-path-${projectId}`;
const CHANGE_EVENT = 'plunk:onboarding-path-changed';
function subscribe(callback: () => void) {
if (typeof window === 'undefined') return () => undefined;
window.addEventListener('storage', callback);
window.addEventListener(CHANGE_EVENT, callback);
return () => {
window.removeEventListener('storage', callback);
window.removeEventListener(CHANGE_EVENT, callback);
};
}
function isPath(value: string | null): value is OnboardingPath {
return value === 'developer' || value === 'marketing' || value === 'workflows';
}
/**
* Per-project record of the onboarding path the user chose.
* Drives the persistent onboarding banner so navigation doesn't lose the thread.
*/
export function useOnboardingPath(projectId: string | undefined) {
const getSnapshot = useCallback(() => {
if (!projectId || typeof window === 'undefined') return null;
const value = localStorage.getItem(storageKey(projectId));
return isPath(value) ? value : null;
}, [projectId]);
const path = useSyncExternalStore(subscribe, getSnapshot, () => null);
const setPath = useCallback(
(next: OnboardingPath) => {
if (!projectId || typeof window === 'undefined') return;
localStorage.setItem(storageKey(projectId), next);
window.dispatchEvent(new Event(CHANGE_EVENT));
},
[projectId],
);
const clearPath = useCallback(() => {
if (!projectId || typeof window === 'undefined') return;
localStorage.removeItem(storageKey(projectId));
window.dispatchEvent(new Event(CHANGE_EVENT));
}, [projectId]);
return {path, setPath, clearPath};
}
@@ -0,0 +1,26 @@
import {useMemo} from 'react';
import {useActiveProject} from '../contexts/ActiveProjectProvider';
import {useDashboardStats} from './useDashboardStats';
import {useOnboardingComplete} from './useOnboardingComplete';
export type OnboardingStatus = 'loading' | 'show' | 'skip';
/**
* Read-only onboarding status. "skip" means the user is already onboarded —
* either they dismissed/finished explicitly, or their project has already
* sent at least one email.
*/
export function useOnboardingStatus(): OnboardingStatus {
const {activeProject} = useActiveProject();
const {isComplete} = useOnboardingComplete(activeProject?.id);
const {totalEmailsSent, isLoading} = useDashboardStats();
return useMemo<OnboardingStatus>(() => {
if (isComplete === true) return 'skip';
if (isComplete === null) return 'loading';
if (isLoading) return 'loading';
if (totalEmailsSent > 0) return 'skip';
return 'show';
}, [isComplete, isLoading, totalEmailsSent]);
}