feat: Add onboarding flow
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user