From bd5a0858021b9d74fae07cca5d2a0b8c10deabd7 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Sun, 19 Apr 2026 11:26:12 +0200 Subject: [PATCH] feat: Add onboarding flow --- apps/web/src/components/DashboardLayout.tsx | 6 +- .../src/components/onboarding/CodeTabs.tsx | 93 +++++++ .../onboarding/OnboardingBanner.tsx | 246 ++++++++++++++++++ .../onboarding/OnboardingLayout.tsx | 83 ++++++ .../src/lib/hooks/useOnboardingComplete.ts | 35 +++ apps/web/src/lib/hooks/useOnboardingGate.ts | 28 ++ apps/web/src/lib/hooks/useOnboardingPath.ts | 51 ++++ apps/web/src/lib/hooks/useOnboardingStatus.ts | 26 ++ apps/web/src/pages/index.tsx | 11 +- apps/web/src/pages/onboarding/developer.tsx | 227 ++++++++++++++++ apps/web/src/pages/onboarding/index.tsx | 111 ++++++++ apps/web/src/pages/onboarding/marketing.tsx | 152 +++++++++++ apps/web/src/pages/onboarding/workflows.tsx | 233 +++++++++++++++++ apps/web/src/pages/projects/create.tsx | 4 +- 14 files changed, 1300 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/onboarding/CodeTabs.tsx create mode 100644 apps/web/src/components/onboarding/OnboardingBanner.tsx create mode 100644 apps/web/src/components/onboarding/OnboardingLayout.tsx create mode 100644 apps/web/src/lib/hooks/useOnboardingComplete.ts create mode 100644 apps/web/src/lib/hooks/useOnboardingGate.ts create mode 100644 apps/web/src/lib/hooks/useOnboardingPath.ts create mode 100644 apps/web/src/lib/hooks/useOnboardingStatus.ts create mode 100644 apps/web/src/pages/onboarding/developer.tsx create mode 100644 apps/web/src/pages/onboarding/index.tsx create mode 100644 apps/web/src/pages/onboarding/marketing.tsx create mode 100644 apps/web/src/pages/onboarding/workflows.tsx diff --git a/apps/web/src/components/DashboardLayout.tsx b/apps/web/src/components/DashboardLayout.tsx index 625b3fa..e5cdb72 100644 --- a/apps/web/src/components/DashboardLayout.tsx +++ b/apps/web/src/components/DashboardLayout.tsx @@ -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 */}
-
{children}
+
+ + {children} +
diff --git a/apps/web/src/components/onboarding/CodeTabs.tsx b/apps/web/src/components/onboarding/CodeTabs.tsx new file mode 100644 index 0000000..3a4c189 --- /dev/null +++ b/apps/web/src/components/onboarding/CodeTabs.tsx @@ -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 ( + +
+ + {snippets.map(s => ( + + {s.label} + + ))} + + +
+ + {snippets.map(s => ( + +
+            {s.code}
+          
+
+ ))} +
+ ); +} diff --git a/apps/web/src/components/onboarding/OnboardingBanner.tsx b/apps/web/src/components/onboarding/OnboardingBanner.tsx new file mode 100644 index 0000000..460253e --- /dev/null +++ b/apps/web/src/components/onboarding/OnboardingBanner.tsx @@ -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['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['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['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['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(() => { + 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 ( + + {visible && ( + +
+
+
+

Finish your setup

+

+ {doneCount} of {steps.length} {doneCount === 1 ? 'step' : 'steps'} done +

+
+ +
+ +
+ {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 ( + + ); + })} +
+ + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/onboarding/OnboardingLayout.tsx b/apps/web/src/components/onboarding/OnboardingLayout.tsx new file mode 100644 index 0000000..49bee72 --- /dev/null +++ b/apps/web/src/components/onboarding/OnboardingLayout.tsx @@ -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 ( +
+
+ +
+ +
+ Plunk + + +
+ + Step {step} of {totalSteps} + +
+ {dots.map(d => ( + + ))} +
+
+ + +
+ +
+ + {children} + +
+
+ ); +} diff --git a/apps/web/src/lib/hooks/useOnboardingComplete.ts b/apps/web/src/lib/hooks/useOnboardingComplete.ts new file mode 100644 index 0000000..9d67f4e --- /dev/null +++ b/apps/web/src/lib/hooks/useOnboardingComplete.ts @@ -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}; +} diff --git a/apps/web/src/lib/hooks/useOnboardingGate.ts b/apps/web/src/lib/hooks/useOnboardingGate.ts new file mode 100644 index 0000000..87eb43d --- /dev/null +++ b/apps/web/src/lib/hooks/useOnboardingGate.ts @@ -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}; +} diff --git a/apps/web/src/lib/hooks/useOnboardingPath.ts b/apps/web/src/lib/hooks/useOnboardingPath.ts new file mode 100644 index 0000000..624f1f4 --- /dev/null +++ b/apps/web/src/lib/hooks/useOnboardingPath.ts @@ -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}; +} diff --git a/apps/web/src/lib/hooks/useOnboardingStatus.ts b/apps/web/src/lib/hooks/useOnboardingStatus.ts new file mode 100644 index 0000000..0f0582b --- /dev/null +++ b/apps/web/src/lib/hooks/useOnboardingStatus.ts @@ -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(() => { + if (isComplete === true) return 'skip'; + if (isComplete === null) return 'loading'; + if (isLoading) return 'loading'; + if (totalEmailsSent > 0) return 'skip'; + return 'show'; + }, [isComplete, isLoading, totalEmailsSent]); +} diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index 06faecc..a2cabf9 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -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(''); @@ -200,9 +205,9 @@ export default function Index() { {/* Quick Actions & API Keys */} -
- {/* Quick Start */} - +
+ {/* Quick Start — hidden when the persistent onboarding banner is guiding the user */} + {!bannerActive && } {/* API Keys */} diff --git a/apps/web/src/pages/onboarding/developer.tsx b/apps/web/src/pages/onboarding/developer.tsx new file mode 100644 index 0000000..4aa6582 --- /dev/null +++ b/apps/web/src/pages/onboarding/developer.tsx @@ -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: 'you@example.com', + subject: 'Hello from Plunk', + body: '

Your first email is live.

', + from: 'sender@yourdomain.com', + }; + 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 ( + <> + + +
+
+

+ Send your first email. +

+

+ Three steps to your first send. Start with your domain so emails actually reach the inbox. +

+
+ +
+ {steps.map((step, index) => { + const Icon = step.icon; + return ( + +
+
+
+ + {index + 1} + + + + +
+
+
+

{step.title}

+ {step.required && ( + + Required + + )} +
+

{step.description}

+
+
+ {step.cta && step.href && ( + + )} +
+ + {step.id === 'key' && ( +
+ {activeProject ? ( + + ) : ( +
+ )} +
+ )} + + {step.id === 'send' && ( +
+ +
+ )} + + ); + })} +
+ +
+ + + Back + + +
+
+ + + ); +} diff --git a/apps/web/src/pages/onboarding/index.tsx b/apps/web/src/pages/onboarding/index.tsx new file mode 100644 index 0000000..63b67a2 --- /dev/null +++ b/apps/web/src/pages/onboarding/index.tsx @@ -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 ( + <> + + +
+
+

+ Welcome to Plunk. +

+

+ Two minutes of setup and you'll be sending. How do you plan to use Plunk? +

+
+ +
+ {paths.map((path, index) => { + const Icon = path.icon; + return ( + 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" + > +
+ + + + {path.meta} +
+
+

{path.title}

+

{path.description}

+
+
+ Start here + +
+
+ ); + })} +
+ +

+ Not sure? Pick the closest fit — you can do all three once you're set up. +

+
+
+ + ); +} diff --git a/apps/web/src/pages/onboarding/marketing.tsx b/apps/web/src/pages/onboarding/marketing.tsx new file mode 100644 index 0000000..78abd28 --- /dev/null +++ b/apps/web/src/pages/onboarding/marketing.tsx @@ -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 ( + <> + + +
+
+

+ Set up for campaigns. +

+

+ Three steps to your first broadcast. Start anywhere — you can come back to finish the rest. +

+
+ +
+ {steps.map((step, index) => { + const Icon = step.icon; + return ( + +
+
+ + {index + 1} + + + + +
+
+
+

{step.title}

+ {step.required && ( + + Required + + )} +
+

{step.description}

+
+
+ +
+ ); + })} +
+ +
+ + + Back + + +
+
+
+ + ); +} diff --git a/apps/web/src/pages/onboarding/workflows.tsx b/apps/web/src/pages/onboarding/workflows.tsx new file mode 100644 index 0000000..f1be843 --- /dev/null +++ b/apps/web/src/pages/onboarding/workflows.tsx @@ -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: 'user@example.com', + 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 ( + <> + + +
+
+

+ Automate with workflows. +

+

+ Four steps to your first automated email. Start with your domain so emails actually reach the inbox. +

+
+ +
+ {steps.map((step, index) => { + const Icon = step.icon; + return ( + +
+
+
+ + {index + 1} + + + + +
+
+
+

{step.title}

+ {step.required && ( + + Required + + )} +
+

{step.description}

+
+
+ {step.cta && step.href && ( + + )} +
+ + {step.id === 'track' && ( +
+ {activeProject ? ( + + ) : ( +
+ )} +
+ +
+
+ )} + + ); + })} +
+ +
+ + + Back + + +
+
+ + + ); +} diff --git a/apps/web/src/pages/projects/create.tsx b/apps/web/src/pages/projects/create.tsx index f3c77f8..409f516 100644 --- a/apps/web/src/pages/projects/create.tsx +++ b/apps/web/src/pages/projects/create.tsx @@ -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'); }