feat: Add onboarding flow
This commit is contained in:
@@ -2,6 +2,7 @@ import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
import {useUser} from '../lib/hooks/useUser';
|
||||
import {WIKI_URI} from '../lib/constants';
|
||||
import {network} from '../lib/network';
|
||||
import {OnboardingBanner} from './onboarding/OnboardingBanner';
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
@@ -352,7 +353,10 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">{children}</div>
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
||||
<OnboardingBanner />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {Check, Copy} from 'lucide-react';
|
||||
import {useState} from 'react';
|
||||
|
||||
export interface CodeSnippet {
|
||||
id: string;
|
||||
label: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface CodeTabsProps {
|
||||
snippets: CodeSnippet[];
|
||||
defaultTab?: string;
|
||||
}
|
||||
|
||||
export function CodeTabs({snippets, defaultTab}: CodeTabsProps) {
|
||||
const [active, setActive] = useState(defaultTab ?? snippets[0]?.id ?? '');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const activeSnippet = snippets.find(s => s.id === active) ?? snippets[0];
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!activeSnippet) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(activeSnippet.code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
// clipboard unavailable — silent
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={active} onValueChange={setActive} className="w-full">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-neutral-200 px-3 py-2">
|
||||
<TabsList className="bg-transparent p-0 h-auto gap-1">
|
||||
{snippets.map(s => (
|
||||
<TabsTrigger
|
||||
key={s.id}
|
||||
value={s.id}
|
||||
className="rounded-md px-2.5 py-1 text-xs font-medium text-neutral-500 data-[state=active]:bg-neutral-100 data-[state=active]:text-neutral-900 data-[state=active]:shadow-none"
|
||||
>
|
||||
{s.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopy()}
|
||||
className="relative flex h-7 items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2 text-xs font-medium text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 transition-colors overflow-hidden"
|
||||
aria-label={`Copy ${activeSnippet?.label ?? ''} snippet`}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{copied ? (
|
||||
<motion.span
|
||||
key="copied"
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -6}}
|
||||
transition={{duration: 0.15}}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-green-600">Copied</span>
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="idle"
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -6}}
|
||||
transition={{duration: 0.15}}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
<span>Copy</span>
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{snippets.map(s => (
|
||||
<TabsContent key={s.id} value={s.id} className="mt-0">
|
||||
<pre className="m-0 overflow-x-auto bg-neutral-50 px-4 py-4 text-xs leading-relaxed text-neutral-900 font-mono">
|
||||
<code>{s.code}</code>
|
||||
</pre>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {ArrowRight, Check, Code2, FileText, Mail, ShieldCheck, Users, Workflow, X, Zap} from 'lucide-react';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {useSWRConfig} from 'swr';
|
||||
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {useOnboardingComplete} from '../../lib/hooks/useOnboardingComplete';
|
||||
import {type OnboardingPath, useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
|
||||
import {useOnboardingStatus} from '../../lib/hooks/useOnboardingStatus';
|
||||
import {useProjectSetupState} from '../../lib/hooks/useProjectSetupState';
|
||||
|
||||
// Keys the banner derives state from. Revalidated on every route change so
|
||||
// progress reflects the latest state as soon as the user moves between pages.
|
||||
const LIVE_KEY_FRAGMENTS = ['/setup-state', '/activity/stats', '/contacts?limit=1', '/campaigns?page=1&limit=1'];
|
||||
const BANNER_POLL_MS = 5_000;
|
||||
|
||||
interface BannerStep {
|
||||
id: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
href: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
function buildMarketingSteps(setupState: ReturnType<typeof useProjectSetupState>['setupState']): BannerStep[] {
|
||||
if (!setupState) return [];
|
||||
return [
|
||||
{
|
||||
id: 'domain',
|
||||
icon: ShieldCheck,
|
||||
title: 'Verify your domain',
|
||||
href: '/settings?tab=domains',
|
||||
done: setupState.hasVerifiedDomain,
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
icon: Users,
|
||||
title: 'Add contacts',
|
||||
href: '/contacts',
|
||||
done: setupState.contactCount > 0,
|
||||
},
|
||||
{
|
||||
id: 'campaign',
|
||||
icon: Mail,
|
||||
title: 'Draft your first campaign',
|
||||
href: '/campaigns/create',
|
||||
done: setupState.lastCampaignSentAt !== null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildDeveloperSteps(setupState: ReturnType<typeof useProjectSetupState>['setupState']): BannerStep[] {
|
||||
if (!setupState) return [];
|
||||
return [
|
||||
{
|
||||
id: 'domain',
|
||||
icon: ShieldCheck,
|
||||
title: 'Verify your domain',
|
||||
href: '/settings?tab=domains',
|
||||
done: setupState.hasVerifiedDomain,
|
||||
},
|
||||
{
|
||||
id: 'snippets',
|
||||
icon: Code2,
|
||||
title: 'Send your first email',
|
||||
href: '/onboarding/developer',
|
||||
done: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildWorkflowSteps(setupState: ReturnType<typeof useProjectSetupState>['setupState']): BannerStep[] {
|
||||
if (!setupState) return [];
|
||||
return [
|
||||
{
|
||||
id: 'domain',
|
||||
icon: ShieldCheck,
|
||||
title: 'Verify your domain',
|
||||
href: '/settings?tab=domains',
|
||||
done: setupState.hasVerifiedDomain,
|
||||
},
|
||||
{
|
||||
id: 'event',
|
||||
icon: Zap,
|
||||
title: 'Fire your first event',
|
||||
href: '/onboarding/workflows',
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
id: 'template',
|
||||
icon: FileText,
|
||||
title: 'Create a template',
|
||||
href: '/templates/create',
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
id: 'workflow',
|
||||
icon: Workflow,
|
||||
title: 'Build a workflow',
|
||||
href: '/workflows',
|
||||
done: setupState.hasEnabledWorkflow,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function stepsForPath(
|
||||
path: OnboardingPath,
|
||||
setupState: ReturnType<typeof useProjectSetupState>['setupState'],
|
||||
): BannerStep[] {
|
||||
if (path === 'developer') return buildDeveloperSteps(setupState);
|
||||
if (path === 'workflows') return buildWorkflowSteps(setupState);
|
||||
return buildMarketingSteps(setupState);
|
||||
}
|
||||
|
||||
export function OnboardingBanner() {
|
||||
const router = useRouter();
|
||||
const {activeProject} = useActiveProject();
|
||||
const status = useOnboardingStatus();
|
||||
const {path, clearPath} = useOnboardingPath(activeProject?.id);
|
||||
const {markComplete} = useOnboardingComplete(activeProject?.id);
|
||||
|
||||
const onOnboardingRoute = router.pathname.startsWith('/onboarding');
|
||||
const bannerCandidate = !onOnboardingRoute && status === 'show' && Boolean(path);
|
||||
|
||||
const {setupState} = useProjectSetupState(activeProject?.id);
|
||||
|
||||
const {mutate} = useSWRConfig();
|
||||
useEffect(() => {
|
||||
if (!bannerCandidate) return;
|
||||
|
||||
const revalidate = () => {
|
||||
void mutate(
|
||||
key => typeof key === 'string' && LIVE_KEY_FRAGMENTS.some(fragment => key.includes(fragment)),
|
||||
);
|
||||
};
|
||||
|
||||
const interval = window.setInterval(revalidate, BANNER_POLL_MS);
|
||||
router.events.on('routeChangeComplete', revalidate);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
router.events.off('routeChangeComplete', revalidate);
|
||||
};
|
||||
}, [bannerCandidate, mutate, router.events]);
|
||||
|
||||
const steps = useMemo<BannerStep[]>(() => {
|
||||
if (!path) return [];
|
||||
return stepsForPath(path, setupState);
|
||||
}, [path, setupState]);
|
||||
|
||||
const visible = bannerCandidate && steps.length > 0;
|
||||
|
||||
const doneCount = steps.filter(s => s.done).length;
|
||||
const currentStepId = steps.find(s => !s.done)?.id;
|
||||
|
||||
const handleDismiss = () => {
|
||||
markComplete();
|
||||
clearPath();
|
||||
};
|
||||
|
||||
const handleStepClick = (href: string) => {
|
||||
void router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -6}}
|
||||
transition={{duration: 0.25, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="mb-6 rounded-xl border border-neutral-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-4 sm:flex-col sm:items-start sm:justify-center">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-sm font-semibold text-neutral-900">Finish your setup</p>
|
||||
<p className="text-xs text-neutral-500 tabular-nums">
|
||||
{doneCount} of {steps.length} {doneCount === 1 ? 'step' : 'steps'} done
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 transition-colors sm:hidden"
|
||||
aria-label="Dismiss setup guide"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2 sm:justify-center lg:justify-end">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
const isCurrent = step.id === currentStepId;
|
||||
const base =
|
||||
'group relative inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2';
|
||||
const variant = step.done
|
||||
? 'border-green-200 bg-green-50 text-green-900 hover:border-green-300'
|
||||
: isCurrent
|
||||
? 'border-neutral-900 bg-neutral-900 text-white hover:bg-neutral-800'
|
||||
: 'border-neutral-200 bg-white text-neutral-700 hover:border-neutral-300 hover:text-neutral-900';
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
type="button"
|
||||
onClick={() => handleStepClick(step.href)}
|
||||
className={`${base} ${variant}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums ${
|
||||
step.done
|
||||
? 'bg-green-600 text-white'
|
||||
: isCurrent
|
||||
? 'bg-white text-neutral-900'
|
||||
: 'bg-neutral-100 text-neutral-500'
|
||||
}`}
|
||||
>
|
||||
{step.done ? <Check className="h-2.5 w-2.5" /> : index + 1}
|
||||
</span>
|
||||
<Icon className="h-3.5 w-3.5 opacity-80" />
|
||||
<span>{step.title}</span>
|
||||
{isCurrent && (
|
||||
<ArrowRight className="h-3 w-3 opacity-80 transition-transform group-hover:translate-x-0.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 transition-colors sm:flex"
|
||||
aria-label="Dismiss setup guide"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {motion} from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {type CSSProperties, type ReactNode, useCallback} from 'react';
|
||||
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {useOnboardingComplete} from '../../lib/hooks/useOnboardingComplete';
|
||||
|
||||
interface OnboardingLayoutProps {
|
||||
step: 1 | 2;
|
||||
totalSteps?: 1 | 2;
|
||||
/** Tailwind max-width class for the content column. Defaults to max-w-2xl. */
|
||||
maxWidthClass?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const backgroundStyle: CSSProperties = {
|
||||
backgroundColor: '#fafafa',
|
||||
backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)',
|
||||
backgroundSize: '20px 20px',
|
||||
};
|
||||
|
||||
export function OnboardingLayout({step, totalSteps = 2, maxWidthClass = 'max-w-2xl', children}: OnboardingLayoutProps) {
|
||||
const router = useRouter();
|
||||
const {activeProject} = useActiveProject();
|
||||
const {markComplete} = useOnboardingComplete(activeProject?.id);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
markComplete();
|
||||
void router.push('/');
|
||||
}, [markComplete, router]);
|
||||
|
||||
const dots = Array.from({length: totalSteps}, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-y-auto" style={backgroundStyle}>
|
||||
<header className="flex items-center justify-between px-6 py-5 sm:px-10">
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<div className="h-8 w-8 rounded-lg bg-white shadow-sm border border-neutral-200 flex items-center justify-center p-1">
|
||||
<Image src="/assets/logo.svg" alt="" aria-hidden width={24} height={24} />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight text-neutral-900">Plunk</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-neutral-500">
|
||||
<span className="tabular-nums">
|
||||
Step {step} of {totalSteps}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 ml-1">
|
||||
{dots.map(d => (
|
||||
<span
|
||||
key={d}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-colors ${
|
||||
d <= step ? 'bg-neutral-900' : 'bg-neutral-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="text-sm text-neutral-500 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
Skip setup
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex justify-center px-4 pb-16 pt-4 sm:pb-24 sm:pt-8">
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.35, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={`w-full ${maxWidthClass}`}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import {QuickStart} from '../components/QuickStart';
|
||||
import {SecurityWarningBanner} from '../components/SecurityWarningBanner';
|
||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
|
||||
import {useOnboardingPath} from '../lib/hooks/useOnboardingPath';
|
||||
import {useOnboardingStatus} from '../lib/hooks/useOnboardingStatus';
|
||||
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
||||
import {useProjectSecurity} from '../lib/hooks/useProjectSecurity';
|
||||
import {useConfig} from '../lib/hooks/useConfig';
|
||||
@@ -32,6 +34,9 @@ export default function Index() {
|
||||
const {securityMetrics} = useProjectSecurity(activeProject?.id);
|
||||
const {data: config} = useConfig();
|
||||
const {data: user} = useUser();
|
||||
const onboardingStatus = useOnboardingStatus();
|
||||
const {path: onboardingPath} = useOnboardingPath(activeProject?.id);
|
||||
const bannerActive = onboardingStatus === 'show' && Boolean(onboardingPath);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [resendMessage, setResendMessage] = useState<string>('');
|
||||
|
||||
@@ -200,9 +205,9 @@ export default function Index() {
|
||||
</div>
|
||||
|
||||
{/* Quick Actions & API Keys */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Quick Start */}
|
||||
<QuickStart setupState={setupState} isLoading={isLoadingSetupState} />
|
||||
<div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2'}`}>
|
||||
{/* Quick Start — hidden when the persistent onboarding banner is guiding the user */}
|
||||
{!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />}
|
||||
|
||||
{/* API Keys */}
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import {Button} from '@plunk/ui';
|
||||
import {motion} from 'framer-motion';
|
||||
import {ArrowLeft, ArrowRight, KeyRound, Mail, ShieldCheck} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
|
||||
import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
|
||||
import {CodeTabs, type CodeSnippet} from '../../components/onboarding/CodeTabs';
|
||||
import {OnboardingLayout} from '../../components/onboarding/OnboardingLayout';
|
||||
import {API_URI} from '../../lib/constants';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {useOnboardingGate} from '../../lib/hooks/useOnboardingGate';
|
||||
import {useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
|
||||
|
||||
interface SetupStep {
|
||||
id: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
cta?: string;
|
||||
href?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const steps: SetupStep[] = [
|
||||
{
|
||||
id: 'domain',
|
||||
icon: ShieldCheck,
|
||||
title: 'Verify your sender domain',
|
||||
description: 'Sending requires a verified domain. Two DNS records and you\'re set.',
|
||||
cta: 'Verify domain',
|
||||
href: '/settings?tab=domains',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
icon: KeyRound,
|
||||
title: 'Copy your secret key',
|
||||
description: 'Add it to your server environment. Never expose it in client-side code.',
|
||||
},
|
||||
{
|
||||
id: 'send',
|
||||
icon: Mail,
|
||||
title: 'Send your first email',
|
||||
description: 'One API call. Pick your language and drop it into your app.',
|
||||
},
|
||||
];
|
||||
|
||||
function buildSnippets(apiUrl: string, secret: string): CodeSnippet[] {
|
||||
const body = {
|
||||
to: '[email protected]',
|
||||
subject: 'Hello from Plunk',
|
||||
body: '<p>Your first email is live.</p>',
|
||||
from: '[email protected]',
|
||||
};
|
||||
const curl = `curl -X POST ${apiUrl}/v1/send \\
|
||||
-H "Authorization: Bearer ${secret}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}'`;
|
||||
|
||||
const node = `await fetch("${apiUrl}/v1/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer ${secret}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}),
|
||||
});`;
|
||||
|
||||
const python = `import requests
|
||||
|
||||
requests.post(
|
||||
"${apiUrl}/v1/send",
|
||||
headers={"Authorization": "Bearer ${secret}"},
|
||||
json=${JSON.stringify(body, null, 4).replace(/\n/g, '\n ')},
|
||||
)`;
|
||||
|
||||
const php = `$ch = curl_init("${apiUrl}/v1/send");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Bearer ${secret}",
|
||||
"Content-Type: application/json",
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode(${JSON.stringify(body, null, 4).replace(/\n/g, '\n ')}),
|
||||
]);
|
||||
curl_exec($ch);`;
|
||||
|
||||
return [
|
||||
{id: 'curl', label: 'cURL', code: curl},
|
||||
{id: 'node', label: 'Node.js', code: node},
|
||||
{id: 'python', label: 'Python', code: python},
|
||||
{id: 'php', label: 'PHP', code: php},
|
||||
];
|
||||
}
|
||||
|
||||
export default function OnboardingDeveloper() {
|
||||
const router = useRouter();
|
||||
const {activeProject} = useActiveProject();
|
||||
const {state} = useOnboardingGate();
|
||||
const {path, setPath} = useOnboardingPath(activeProject?.id);
|
||||
|
||||
const snippets = useMemo(
|
||||
() => buildSnippets(API_URI, activeProject?.secret ?? 'sk_your_secret_key'),
|
||||
[activeProject?.secret],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProject && !path) setPath('developer');
|
||||
}, [activeProject, path, setPath]);
|
||||
|
||||
if (state !== 'show') return null;
|
||||
|
||||
const handleContinue = () => {
|
||||
void router.push('/');
|
||||
};
|
||||
|
||||
const handleStepClick = (href: string) => {
|
||||
void router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Send your first email" />
|
||||
<OnboardingLayout step={2}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900">
|
||||
Send your first email.
|
||||
</h1>
|
||||
<p className="text-base text-neutral-600 max-w-xl leading-relaxed">
|
||||
Three steps to your first send. Start with your domain so emails actually reach the inbox.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.3, delay: 0.05 + index * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="flex flex-col gap-4 rounded-xl border border-neutral-200 bg-white p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50 text-xs font-semibold tabular-nums text-neutral-900">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-900">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">{step.title}</h2>
|
||||
{step.required && (
|
||||
<span className="rounded bg-neutral-900 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{step.cta && step.href && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="sm:self-center sm:flex-shrink-0"
|
||||
onClick={() => handleStepClick(step.href!)}
|
||||
>
|
||||
{step.cta}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step.id === 'key' && (
|
||||
<div className="border-t border-neutral-100 pt-4">
|
||||
{activeProject ? (
|
||||
<ApiKeyDisplay
|
||||
label="Secret key"
|
||||
value={activeProject.secret}
|
||||
description="Keep this server-side. Treat it like a password."
|
||||
isSecret
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 animate-pulse rounded-lg bg-neutral-100" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.id === 'send' && (
|
||||
<div className="overflow-hidden rounded-lg border border-neutral-200">
|
||||
<CodeTabs snippets={snippets} />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-neutral-500 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button onClick={handleContinue}>
|
||||
Continue to dashboard
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {motion} from 'framer-motion';
|
||||
import {ArrowRight, Code2, Megaphone, Workflow} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
import {OnboardingLayout} from '../../components/onboarding/OnboardingLayout';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {useOnboardingGate} from '../../lib/hooks/useOnboardingGate';
|
||||
import {type OnboardingPath, useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
|
||||
|
||||
interface PathOption {
|
||||
id: OnboardingPath;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
meta: string;
|
||||
}
|
||||
|
||||
const paths: PathOption[] = [
|
||||
{
|
||||
id: 'developer',
|
||||
icon: Code2,
|
||||
title: 'Send transactional emails',
|
||||
description: 'Wire Plunk into your app for password resets, receipts, and notifications.',
|
||||
meta: '~1 minute',
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
icon: Megaphone,
|
||||
title: 'Run email campaigns',
|
||||
description: 'Import contacts, draft campaigns, and broadcast to your audience.',
|
||||
meta: '~5 minutes',
|
||||
},
|
||||
{
|
||||
id: 'workflows',
|
||||
icon: Workflow,
|
||||
title: 'Automate with workflows',
|
||||
description: 'Trigger emails from events in your app — onboarding, retention, receipts.',
|
||||
meta: '~5 minutes',
|
||||
},
|
||||
];
|
||||
|
||||
export default function OnboardingWelcome() {
|
||||
const router = useRouter();
|
||||
const {state} = useOnboardingGate();
|
||||
const {activeProject} = useActiveProject();
|
||||
const {setPath} = useOnboardingPath(activeProject?.id);
|
||||
|
||||
if (state !== 'show') return null;
|
||||
|
||||
const handleSelect = (id: PathOption['id']) => {
|
||||
setPath(id);
|
||||
void router.push(`/onboarding/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Welcome to Plunk" />
|
||||
<OnboardingLayout step={1} maxWidthClass="max-w-5xl">
|
||||
<div className="flex flex-col gap-8">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900">
|
||||
Welcome to Plunk.
|
||||
</h1>
|
||||
<p className="text-base text-neutral-600 max-w-lg leading-relaxed">
|
||||
Two minutes of setup and you'll be sending. How do you plan to use Plunk?
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{paths.map((path, index) => {
|
||||
const Icon = path.icon;
|
||||
return (
|
||||
<motion.button
|
||||
key={path.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(path.id)}
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.35, delay: 0.08 + index * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
whileHover={{y: -2}}
|
||||
whileTap={{scale: 0.995}}
|
||||
className="group flex flex-col gap-5 rounded-xl border border-neutral-200 bg-white p-6 text-left shadow-sm transition-colors hover:border-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-900">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-xs font-medium text-neutral-400 tabular-nums">{path.meta}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-base font-semibold text-neutral-900">{path.title}</h2>
|
||||
<p className="text-sm text-neutral-600 leading-relaxed">{path.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-neutral-900">
|
||||
<span>Start here</span>
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-neutral-500">
|
||||
Not sure? Pick the closest fit — you can do all three once you're set up.
|
||||
</p>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {Button} from '@plunk/ui';
|
||||
import {motion} from 'framer-motion';
|
||||
import {ArrowLeft, ArrowRight, Mail, ShieldCheck, Users} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
import {OnboardingLayout} from '../../components/onboarding/OnboardingLayout';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {useOnboardingGate} from '../../lib/hooks/useOnboardingGate';
|
||||
import {useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
|
||||
|
||||
interface SetupStep {
|
||||
id: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
cta: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const steps: SetupStep[] = [
|
||||
{
|
||||
id: 'domain',
|
||||
icon: ShieldCheck,
|
||||
title: 'Verify your sender domain',
|
||||
description: 'Two DNS records prove you own the address your campaigns send from.',
|
||||
href: '/settings?tab=domains',
|
||||
cta: 'Verify domain',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
icon: Users,
|
||||
title: 'Add your contacts',
|
||||
description: 'Import a CSV, paste a list, or sync contacts from your app.',
|
||||
href: '/contacts',
|
||||
cta: 'Add contacts',
|
||||
},
|
||||
{
|
||||
id: 'campaign',
|
||||
icon: Mail,
|
||||
title: 'Draft your first campaign',
|
||||
description: 'Compose in the editor, preview, and schedule — or send when you are ready.',
|
||||
href: '/campaigns/create',
|
||||
cta: 'Draft campaign',
|
||||
},
|
||||
];
|
||||
|
||||
export default function OnboardingMarketing() {
|
||||
const router = useRouter();
|
||||
const {state} = useOnboardingGate();
|
||||
const {activeProject} = useActiveProject();
|
||||
const {path, setPath} = useOnboardingPath(activeProject?.id);
|
||||
|
||||
// Record the marketing path so the persistent banner guides the user after
|
||||
// they click into a step. Only set if unset — don't overwrite a developer
|
||||
// choice if the user navigated here manually.
|
||||
useEffect(() => {
|
||||
if (activeProject && !path) setPath('marketing');
|
||||
}, [activeProject, path, setPath]);
|
||||
|
||||
if (state !== 'show') return null;
|
||||
|
||||
const handleContinue = () => {
|
||||
void router.push('/');
|
||||
};
|
||||
|
||||
const handleStepClick = (href: string) => {
|
||||
void router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Set up your campaigns" />
|
||||
<OnboardingLayout step={2}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900">
|
||||
Set up for campaigns.
|
||||
</h1>
|
||||
<p className="text-base text-neutral-600 max-w-xl leading-relaxed">
|
||||
Three steps to your first broadcast. Start anywhere — you can come back to finish the rest.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.3, delay: 0.05 + index * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="flex flex-col gap-4 rounded-xl border border-neutral-200 bg-white p-5 shadow-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50 text-xs font-semibold tabular-nums text-neutral-900">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-900">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">{step.title}</h2>
|
||||
{step.required && (
|
||||
<span className="rounded bg-neutral-900 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="sm:self-center sm:flex-shrink-0"
|
||||
onClick={() => handleStepClick(step.href)}
|
||||
>
|
||||
{step.cta}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-neutral-500 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button onClick={handleContinue}>
|
||||
Continue to dashboard
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {Button} from '@plunk/ui';
|
||||
import {motion} from 'framer-motion';
|
||||
import {ArrowLeft, ArrowRight, FileText, ShieldCheck, Workflow, Zap} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
|
||||
import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
|
||||
import {CodeTabs, type CodeSnippet} from '../../components/onboarding/CodeTabs';
|
||||
import {OnboardingLayout} from '../../components/onboarding/OnboardingLayout';
|
||||
import {API_URI} from '../../lib/constants';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {useOnboardingGate} from '../../lib/hooks/useOnboardingGate';
|
||||
import {useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
|
||||
|
||||
interface SetupStep {
|
||||
id: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
cta?: string;
|
||||
href?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const steps: SetupStep[] = [
|
||||
{
|
||||
id: 'domain',
|
||||
icon: ShieldCheck,
|
||||
title: 'Verify your sender domain',
|
||||
description: 'Workflows send from a verified domain. Two DNS records and you\'re set.',
|
||||
cta: 'Verify domain',
|
||||
href: '/settings?tab=domains',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'track',
|
||||
icon: Zap,
|
||||
title: 'Fire events from your app',
|
||||
description: 'Call /v1/track whenever something interesting happens. Workflows listen and respond.',
|
||||
},
|
||||
{
|
||||
id: 'template',
|
||||
icon: FileText,
|
||||
title: 'Create an email template',
|
||||
description: 'Design the email your workflow will send — drag blocks or write HTML.',
|
||||
cta: 'Create template',
|
||||
href: '/templates/create',
|
||||
},
|
||||
{
|
||||
id: 'workflow',
|
||||
icon: Workflow,
|
||||
title: 'Build your first workflow',
|
||||
description: 'Pick a trigger event, attach your template, chain delays, hit publish.',
|
||||
cta: 'Open workflow builder',
|
||||
href: '/workflows',
|
||||
},
|
||||
];
|
||||
|
||||
function buildTrackSnippets(apiUrl: string, publicKey: string): CodeSnippet[] {
|
||||
const body = {
|
||||
event: 'user.signed-up',
|
||||
email: '[email protected]',
|
||||
data: {plan: 'pro'},
|
||||
};
|
||||
|
||||
const curl = `curl -X POST ${apiUrl}/v1/track \\
|
||||
-H "Authorization: Bearer ${publicKey}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}'`;
|
||||
|
||||
const node = `await fetch("${apiUrl}/v1/track", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer ${publicKey}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}),
|
||||
});`;
|
||||
|
||||
const python = `import requests
|
||||
|
||||
requests.post(
|
||||
"${apiUrl}/v1/track",
|
||||
headers={"Authorization": "Bearer ${publicKey}"},
|
||||
json=${JSON.stringify(body, null, 4).replace(/\n/g, '\n ')},
|
||||
)`;
|
||||
|
||||
const php = `$ch = curl_init("${apiUrl}/v1/track");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Bearer ${publicKey}",
|
||||
"Content-Type: application/json",
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode(${JSON.stringify(body, null, 4).replace(/\n/g, '\n ')}),
|
||||
]);
|
||||
curl_exec($ch);`;
|
||||
|
||||
return [
|
||||
{id: 'curl', label: 'cURL', code: curl},
|
||||
{id: 'node', label: 'Node.js', code: node},
|
||||
{id: 'python', label: 'Python', code: python},
|
||||
{id: 'php', label: 'PHP', code: php},
|
||||
];
|
||||
}
|
||||
|
||||
export default function OnboardingWorkflows() {
|
||||
const router = useRouter();
|
||||
const {activeProject} = useActiveProject();
|
||||
const {state} = useOnboardingGate();
|
||||
const {path, setPath} = useOnboardingPath(activeProject?.id);
|
||||
|
||||
const snippets = useMemo(
|
||||
() => buildTrackSnippets(API_URI, activeProject?.public ?? 'pk_your_public_key'),
|
||||
[activeProject?.public],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProject && !path) setPath('workflows');
|
||||
}, [activeProject, path, setPath]);
|
||||
|
||||
if (state !== 'show') return null;
|
||||
|
||||
const handleContinue = () => {
|
||||
void router.push('/');
|
||||
};
|
||||
|
||||
const handleStepClick = (href: string) => {
|
||||
void router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Automate with workflows" />
|
||||
<OnboardingLayout step={2}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900">
|
||||
Automate with workflows.
|
||||
</h1>
|
||||
<p className="text-base text-neutral-600 max-w-xl leading-relaxed">
|
||||
Four steps to your first automated email. Start with your domain so emails actually reach the inbox.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.3, delay: 0.05 + index * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="flex flex-col gap-4 rounded-xl border border-neutral-200 bg-white p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50 text-xs font-semibold tabular-nums text-neutral-900">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-900">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">{step.title}</h2>
|
||||
{step.required && (
|
||||
<span className="rounded bg-neutral-900 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{step.cta && step.href && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="sm:self-center sm:flex-shrink-0"
|
||||
onClick={() => handleStepClick(step.href!)}
|
||||
>
|
||||
{step.cta}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step.id === 'track' && (
|
||||
<div className="flex flex-col gap-3 border-t border-neutral-100 pt-4">
|
||||
{activeProject ? (
|
||||
<ApiKeyDisplay
|
||||
label="Public key"
|
||||
value={activeProject.public}
|
||||
description="Use this key to track events from browsers or clients."
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 animate-pulse rounded-lg bg-neutral-100" />
|
||||
)}
|
||||
<div className="overflow-hidden rounded-lg border border-neutral-200">
|
||||
<CodeTabs snippets={snippets} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-neutral-500 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<Button onClick={handleContinue}>
|
||||
Continue to dashboard
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -54,8 +54,8 @@ export default function CreateProject() {
|
||||
// Set the newly created project as active
|
||||
setActiveProject(newProject);
|
||||
|
||||
// Redirect to dashboard
|
||||
await router.push('/');
|
||||
// Redirect into onboarding for this project
|
||||
await router.push('/onboarding');
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Something went wrong');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user