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
+227
View File
@@ -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>
</>
);
}
+111
View File
@@ -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&#39;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&#39;re set up.
</p>
</div>
</OnboardingLayout>
</>
);
}
+152
View File
@@ -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>
</>
);
}
+233
View File
@@ -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>
</>
);
}