From eacaef1fbdcf09d43b7dd011a058ea5a43b794e1 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Thu, 23 Apr 2026 19:03:41 +0200 Subject: [PATCH] Refine onboarding UI and fix dashboard checklist progress --- src/app/(main)/(app)/create/page.tsx | 49 ++- src/app/(main)/(app)/dashboard/page.tsx | 68 ++-- .../(main)/onboarding/OnboardingClient.tsx | 303 ++++++------------ .../dashboard/OnboardingChecklist.tsx | 149 +++++---- src/components/dashboard/QRCodeCard.tsx | 87 +++-- src/components/dashboard/StatsGrid.tsx | 44 +-- src/lib/revops.ts | 2 + 7 files changed, 316 insertions(+), 386 deletions(-) diff --git a/src/app/(main)/(app)/create/page.tsx b/src/app/(main)/(app)/create/page.tsx index fed5ef4..91ce483 100644 --- a/src/app/(main)/(app)/create/page.tsx +++ b/src/app/(main)/(app)/create/page.tsx @@ -16,6 +16,10 @@ import { useCsrf } from '@/hooks/useCsrf'; import { showToast } from '@/components/ui/Toast'; import { trackEvent } from '@/components/PostHogProvider'; import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow'; +import { + ONBOARDING_DOWNLOAD_COMPLETE_EVENT, + ONBOARDING_DOWNLOAD_COMPLETE_KEY, +} from '@/lib/revops'; import { Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon } from 'lucide-react'; @@ -149,6 +153,15 @@ export default function CreatePage() { // QR preview const [qrDataUrl, setQrDataUrl] = useState(''); + + const markDownloadComplete = () => { + if (typeof window === 'undefined') { + return; + } + + localStorage.setItem(ONBOARDING_DOWNLOAD_COMPLETE_KEY, '1'); + window.dispatchEvent(new CustomEvent(ONBOARDING_DOWNLOAD_COMPLETE_EVENT)); + }; // Check if user can customize colors (PRO+ only) const canCustomizeColors = userPlan === 'PRO' || userPlan === 'BUSINESS'; @@ -255,7 +268,8 @@ export default function CreatePage() { } }; - const qrContent = getQRContent(); + const qrContent = getQRContent(); + const previewScale = contentType === 'BARCODE' ? 1 : Math.min(1, 240 / Math.max(size, 1)); const getFrameLabel = () => { const frame = frameOptions.find((f: { id: string; label: string }) => f.id === frameType); @@ -271,6 +285,7 @@ export default function CreatePage() { link.download = `qrcode-${title || 'download'}.png`; link.href = dataUrl; link.click(); + markDownloadComplete(); trackEvent('qr_code_downloaded', { format: 'png', content_type: contentType, @@ -304,6 +319,7 @@ export default function CreatePage() { a.download = `qrcode-${title || 'download'}.svg`; a.click(); URL.revokeObjectURL(url); + markDownloadComplete(); trackEvent('qr_code_downloaded', { format: 'svg', content_type: contentType, @@ -318,6 +334,7 @@ export default function CreatePage() { link.download = `qrcode-${title || 'download'}.png`; link.href = dataUrl; link.click(); + markDownloadComplete(); trackEvent('qr_code_downloaded', { format: 'png', content_type: contentType, @@ -1162,13 +1179,13 @@ export default function CreatePage() {
{/* WRAPPER FOR REF AND FRAME */} -
+
{/* Frame Label */} {getFrameLabel() && (
) - ) : qrContent ? ( -
- + { - switch (plan) { - case 'PRO': - return 'info'; - case 'BUSINESS': - return 'warning'; - default: - return 'default'; - } - }; - - const getPlanEmoji = (plan: string) => { - // No emojis anymore - return ''; - }; - - return ( -
+ return ( +
{/* Header with Plan Badge */} -
-
+
+

{t('dashboard.title')}

{!loading && qrCodes.length === 0 @@ -348,17 +332,17 @@ export default function DashboardPage() { : t('dashboard.subtitle')}

-
- - {userPlan} Plan - - {userPlan === 'FREE' && ( - - - - )} -
-
+
+ + {userPlan} Plan + + {userPlan === 'FREE' && ( + + + + )} +
+
{/* Stats Grid */} @@ -373,9 +357,9 @@ export default function DashboardPage() { {/* Recent QR Codes */}
-
-

{t('dashboard.recent_codes')}

-
+
+

{t('dashboard.recent_codes')}

+
{qrCodes.length > 0 && ( - )} - - - -
-
+ )} + + + +
+
{loading ? (
@@ -410,14 +394,14 @@ export default function DashboardPage() { ))}
) : qrCodes.length === 0 ? ( -
+

Create your first QR code

You have {FREE_DYNAMIC_QR_LIMIT} free dynamic QR codes. They redirect wherever you want and track every scan.

- +
) : ( diff --git a/src/app/(main)/onboarding/OnboardingClient.tsx b/src/app/(main)/onboarding/OnboardingClient.tsx index 39866a2..66e1246 100644 --- a/src/app/(main)/onboarding/OnboardingClient.tsx +++ b/src/app/(main)/onboarding/OnboardingClient.tsx @@ -5,13 +5,11 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import type { LucideIcon } from 'lucide-react'; import { - ArrowRight, BadgeCheck, BarChart3, BriefcaseBusiness, Building2, Check, - ChevronRight, Compass, ContactRound, Facebook, @@ -35,7 +33,6 @@ import { } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; -import { Badge } from '@/components/ui/Badge'; import { useCsrf } from '@/hooks/useCsrf'; import { showToast } from '@/components/ui/Toast'; import { @@ -47,9 +44,6 @@ import { getCreatePresetForUseCase, getGoalLabel, getOnboardingHeadlineForUseCase, - getRoleLabel, - getSourceLabel, - getTeamSizeLabel, getUseCaseLabel, JOB_ROLE_OPTIONS, PRIMARY_GOAL_OPTIONS, @@ -401,12 +395,12 @@ function StepOptions({ onClick={() => onSelect(option.value)} className={`group relative overflow-hidden rounded-[24px] border p-4 text-left transition-all duration-200 ${ selected - ? 'border-primary-400 bg-gradient-to-br from-primary-50 via-white to-blue-50 shadow-lg shadow-blue-100/70' - : 'border-slate-200 bg-white hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md' + ? 'border-slate-900 bg-slate-50 shadow-sm' + : 'border-slate-200 bg-white hover:border-slate-300' }`} >
-
+
@@ -414,7 +408,7 @@ function StepOptions({ {option.label} @@ -430,30 +424,6 @@ function StepOptions({ ); } -function SummaryItem({ - label, - value, - icon: Icon, -}: { - label: string; - value: string; - icon: LucideIcon; -}) { - return ( -
-
-
- -
-
-

{label}

-

{value}

-
-
-
- ); -} - export default function OnboardingClient() { const router = useRouter(); const searchParams = useSearchParams(); @@ -470,6 +440,8 @@ export default function OnboardingClient() { const [companyWebsite, setCompanyWebsite] = useState(''); const [teamSize, setTeamSize] = useState(''); const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect')); + const requestedStep = Number(searchParams.get('step') || ''); + const hasExplicitStep = Number.isInteger(requestedStep) && requestedStep >= 1 && requestedStep <= 8; useEffect(() => { const fetchState = async () => { @@ -490,8 +462,7 @@ export default function OnboardingClient() { setCompanyWebsite(data.companyWebsite || ''); setTeamSize(data.teamSizeBucket || ''); - const explicitStep = Number(searchParams.get('step') || ''); - const nextStep = explicitStep >= 1 && explicitStep <= 8 ? explicitStep : deriveStep(data); + const nextStep = hasExplicitStep ? requestedStep : deriveStep(data); setStep(nextStep); if (searchParams.get('authMethod') === 'google') { @@ -518,7 +489,7 @@ export default function OnboardingClient() { }; fetchState(); - }, [redirectTarget, router, searchParams]); + }, [hasExplicitStep, redirectTarget, requestedStep, router, searchParams]); const createPreset = useMemo( () => getCreatePresetForUseCase(useCase || state?.primaryUseCase), @@ -547,10 +518,10 @@ export default function OnboardingClient() { ); useEffect(() => { - if (state?.onboardingCompletedAt && step !== 8) { + if (state?.onboardingCompletedAt && step !== 8 && !hasExplicitStep) { router.replace(getPostOnboardingDestination(redirectTarget)); } - }, [redirectTarget, router, state?.onboardingCompletedAt, step]); + }, [hasExplicitStep, redirectTarget, router, state?.onboardingCompletedAt, step]); const saveStep = async (payload: Record, nextStep: number) => { setSaving(true); @@ -578,24 +549,29 @@ export default function OnboardingClient() { if (loading || !state) { return ( -
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ {stepLabels.map((label) => ( +
+ ))}
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -603,15 +579,12 @@ export default function OnboardingClient() { ); } - if (state.onboardingCompletedAt && step !== 8) { + if (state.onboardingCompletedAt && step !== 8 && !hasExplicitStep) { return null; } - const activeSource = source || state.signupSourceSelfReported || state.signupSource || ''; const activeUseCase = useCase || state.primaryUseCase || ''; const activeGoal = goal || state.primaryGoal || ''; - const activeRole = jobRole || state.jobRole || ''; - const activeTeam = teamSize || state.teamSizeBucket || ''; const progress = Math.min(100, Math.round((step / 8) * 100)); const currentStep = stepContent[step as keyof typeof stepContent]; const guidedHeadline = getOnboardingHeadlineForUseCase(activeUseCase || state.primaryUseCase); @@ -619,165 +592,70 @@ export default function OnboardingClient() { `/create?onboarding=1&source=onboarding&useCase=${encodeURIComponent(activeUseCase || '')}&contentType=${createPreset.contentType}&dynamic=${createPreset.dynamic ? '1' : '0'}&title=${encodeURIComponent(createPreset.title)}`, redirectTarget ); - const completionText = step >= 8 ? 'Ready to scale' : `${8 - step} steps left`; const destinationHref = getPostOnboardingDestination(redirectTarget); - const summaryItems = [ - activeSource ? { label: 'Source', value: getSourceLabel(activeSource), icon: Compass } : null, - activeUseCase ? { label: 'Use case', value: getUseCaseLabel(activeUseCase), icon: QrCode } : null, - activeGoal ? { label: 'Goal', value: getGoalLabel(activeGoal), icon: Target } : null, - activeRole ? { label: 'Role', value: getRoleLabel(activeRole), icon: BriefcaseBusiness } : null, - companyName ? { label: 'Workspace', value: companyName, icon: Building2 } : null, - activeTeam ? { label: 'Team', value: getTeamSizeLabel(activeTeam), icon: Users } : null, - ].filter(Boolean) as Array<{ label: string; value: string; icon: LucideIcon }>; - return ( -
-
-
-
-
-
-
- -
-
- - +
+
+
+ + QR MASTER - +
Step {step} of 8 - +
-
-
-
- - Personalized setup +
+
+
+

{currentStep.eyebrow}

+

{currentStep.title}

+
{progress}% complete
+
-

- Build a sharper first-run experience, not a generic setup wizard. -

-

- Answer a few focused questions and we will shape the workspace, first QR preset, and next action around how you actually plan to use QR Master. -

+
+
+
-
-
-
- +
+ {stepLabels.map((label, index) => { + const stepNumber = index + 1; + const isCurrent = stepNumber === step; + const isDone = stepNumber < step; + + return ( +
+
{stepNumber}
+
{label}
-

Cleaner first build

-

We skip the dead-end defaults and move you into a stronger starting setup.

-
-
-
- -
-

Sharper guidance

-

Your use case and goal drive the recommendations instead of a one-size-fits-all flow.

-
-
-
- -
-

Faster activation

-

The goal is not to collect data. The goal is to get you to a real QR asset faster.

-
-
+ ); + })} +
+
-
-
-
-

Progress

-

{progress}% complete

-
-
- {completionText} -
-
-
-
-
+
+

{currentStep.description}

-
- {stepLabels.map((label, index) => { - const stepNumber = index + 1; - const isCurrent = stepNumber === step; - const isDone = stepNumber < step; - - return ( -
-
- {isDone ? : stepNumber} -
-
-

{label}

-

{isCurrent ? 'Current step' : isDone ? 'Completed' : 'Upcoming'}

-
-
- ); - })} -
-
- - {summaryItems.length > 0 && ( -
-
- - Your setup - - Live summary based on your answers so far. -
-
- {summaryItems.map((item) => ( - - ))} -
-
- )} -
- -
-
-
-

{currentStep.eyebrow}

-

{currentStep.title}

-

{currentStep.description}

-
-
- -
-
- -
+
{step === 1 && (
@@ -987,7 +865,7 @@ export default function OnboardingClient() {
-
+

Create another QR code

@@ -999,7 +877,6 @@ export default function OnboardingClient() { )}
-
); diff --git a/src/components/dashboard/OnboardingChecklist.tsx b/src/components/dashboard/OnboardingChecklist.tsx index bc08a24..0d53ec4 100644 --- a/src/components/dashboard/OnboardingChecklist.tsx +++ b/src/components/dashboard/OnboardingChecklist.tsx @@ -2,17 +2,20 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; -import { ArrowRight, Check, QrCode, Sparkles, X } from 'lucide-react'; +import { ArrowRight, X } from 'lucide-react'; import { Button } from '@/components/ui/Button'; -import { Badge } from '@/components/ui/Badge'; import { Card, CardContent } from '@/components/ui/Card'; +import { appendRedirectParam } from '@/lib/auth-flow'; import { getChecklistItems, ONBOARDING_CHECKLIST_DISMISS_KEY, + ONBOARDING_DOWNLOAD_COMPLETE_EVENT, + ONBOARDING_DOWNLOAD_COMPLETE_KEY, } from '@/lib/revops'; type OnboardingChecklistProps = { state: { + id?: string; signupSourceSelfReported?: string | null; primaryUseCase?: string | null; firstQrCreatedAt?: string | null; @@ -24,41 +27,75 @@ type OnboardingChecklistProps = { export function OnboardingChecklist({ state }: OnboardingChecklistProps) { const [dismissed, setDismissed] = useState(false); + const [downloadDone, setDownloadDone] = useState(false); + + const stepMap: Record = { + source: 1, + 'use-case': 2, + 'first-qr': 7, + 'first-dynamic': 7, + download: 8, + scan: 8, + }; useEffect(() => { setDismissed(localStorage.getItem(ONBOARDING_CHECKLIST_DISMISS_KEY) === '1'); + setDownloadDone(localStorage.getItem(ONBOARDING_DOWNLOAD_COMPLETE_KEY) === '1'); + + const syncDownloadState = () => { + setDownloadDone(localStorage.getItem(ONBOARDING_DOWNLOAD_COMPLETE_KEY) === '1'); + }; + + window.addEventListener(ONBOARDING_DOWNLOAD_COMPLETE_EVENT, syncDownloadState); + window.addEventListener('storage', syncDownloadState); + + return () => { + window.removeEventListener(ONBOARDING_DOWNLOAD_COMPLETE_EVENT, syncDownloadState); + window.removeEventListener('storage', syncDownloadState); + }; }, []); - if (!state || state.firstScanAt || dismissed) { + if (!state || dismissed) { return null; } - const items = getChecklistItems(state); + const items = getChecklistItems(state).map((item) => + item.id === 'download' + ? { + ...item, + done: downloadDone || Boolean(state.firstScanAt), + } + : item + ); const completed = items.filter((item) => item.done).length; const progress = Math.round((completed / items.length) * 100); + const allDone = completed === items.length; return ( - -
+ +
-
- - Activation path -
-

Get to your first result faster

-

- Finish the onboarding checklist to unlock a cleaner dashboard state and move from setup into real usage. +

+ {allDone ? 'Onboarding complete' : 'Onboarding progress'} +

+

+ {allDone ? 'Your first setup is complete' : 'Finish the first-run checklist'} +

+

+ {allDone + ? 'You created, downloaded, and tested your first code. You can keep this visible or dismiss it.' + : 'Keep this minimal checklist visible until the first QR workflow is fully done.'}

- - {completed}/{items.length} done - +
+ {completed}/{items.length} +
-
-
-
-
- - -
- {items.map((item) => ( +
+
-
-
- {item.done ? : } -
-
-

- {item.label} -

-

- {item.done ? 'Completed and saved in your setup state.' : 'Still pending. Finish this to move closer to activation.'} -

-
-
-
- ))} + className="h-full rounded-full bg-slate-900 transition-all duration-300" + style={{ width: `${progress}%` }} + /> +
+
+ {items.map((item, index) => ( + + {index + 1}. {item.label} + + ))} +
- - - + {!allDone && ( + + + + )}
diff --git a/src/components/dashboard/QRCodeCard.tsx b/src/components/dashboard/QRCodeCard.tsx index 806816a..70500b4 100644 --- a/src/components/dashboard/QRCodeCard.tsx +++ b/src/components/dashboard/QRCodeCard.tsx @@ -3,10 +3,14 @@ import React from 'react'; import { QRCodeSVG } from 'qrcode.react'; import Barcode from 'react-barcode'; -import { Card, CardContent } from '@/components/ui/Card'; -import { Badge } from '@/components/ui/Badge'; -import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'; -import { formatDate } from '@/lib/utils'; +import { Card, CardContent } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'; +import { formatDate } from '@/lib/utils'; +import { + ONBOARDING_DOWNLOAD_COMPLETE_EVENT, + ONBOARDING_DOWNLOAD_COMPLETE_KEY, +} from '@/lib/revops'; function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string { const cloned = svgElement.cloneNode(true) as SVGElement; @@ -59,12 +63,21 @@ interface QRCodeCardProps { onDelete: (id: string) => void; } -export const QRCodeCard: React.FC = ({ - qr, - onEdit, - onDelete, -}) => { - // For dynamic QR codes, use the redirect URL for tracking +export const QRCodeCard: React.FC = ({ + qr, + onEdit, + onDelete, +}) => { + const markDownloadComplete = () => { + if (typeof window === 'undefined') { + return; + } + + localStorage.setItem(ONBOARDING_DOWNLOAD_COMPLETE_KEY, '1'); + window.dispatchEvent(new CustomEvent(ONBOARDING_DOWNLOAD_COMPLETE_EVENT)); + }; + + // For dynamic QR codes, use the redirect URL for tracking // For static QR codes, use the direct URL from content const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050'); @@ -125,10 +138,11 @@ END:VCARD`; pixelRatio: 3, backgroundColor: '#ffffff' // White background for clean export }); - const link = document.createElement('a'); - link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`; - link.href = dataUrl; - link.click(); + const link = document.createElement('a'); + link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`; + link.href = dataUrl; + link.click(); + markDownloadComplete(); } else { // For SVG, if no frame, export just the QR code SVG for vector quality // If frame exists, use toPng as fallback since HTML-to-SVG is complex @@ -139,10 +153,11 @@ END:VCARD`; pixelRatio: 3, backgroundColor: '#ffffff' }); - const link = document.createElement('a'); - link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`; - link.href = dataUrl; - link.click(); + const link = document.createElement('a'); + link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`; + link.href = dataUrl; + link.click(); + markDownloadComplete(); } else { // No frame - export clean SVG from the svg wrapper const svgContainer = document.querySelector(`#qr-svg-${qr.id}`); @@ -170,11 +185,12 @@ END:VCARD`; const blob = new Blob([svgData], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`; - a.click(); - URL.revokeObjectURL(url); + const a = document.createElement('a'); + a.href = url; + a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`; + a.click(); + URL.revokeObjectURL(url); + markDownloadComplete(); } } } @@ -184,16 +200,16 @@ END:VCARD`; }; return ( - - + +

{qr.title}

- - {qr.type} - -
+ + {qr.type} + +
-
- {/* Download wrapper - tightly wraps content */} -
+
+ {/* Download wrapper - tightly wraps content */} +
{/* Frame Label */} {qr.style?.frameType && qr.style.frameType !== 'none' && (
{formatDate(qr.createdAt)}
{qr.type === 'DYNAMIC' && ( -
+

📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}

@@ -318,4 +337,4 @@ END:VCARD`; ); -}; \ No newline at end of file +}; diff --git a/src/components/dashboard/StatsGrid.tsx b/src/components/dashboard/StatsGrid.tsx index aca93a7..797da4b 100644 --- a/src/components/dashboard/StatsGrid.tsx +++ b/src/components/dashboard/StatsGrid.tsx @@ -80,29 +80,29 @@ export const StatsGrid: React.FC = ({ stats, trends }) => { }, ]; - return ( -
- {cards.map((card, index) => ( - - -
-
-

{card.title}

-

{card.value}

-

- {card.change} -

-
-
- {card.icon} -
-
-
+ return ( +
+ {cards.map((card, index) => ( + + +
+
+

{card.title}

+

{card.value}

+

+ {card.change} +

+
+
+ {card.icon} +
+
+
))}
); -}; \ No newline at end of file +}; diff --git a/src/lib/revops.ts b/src/lib/revops.ts index ac189d2..5e09f10 100644 --- a/src/lib/revops.ts +++ b/src/lib/revops.ts @@ -1,5 +1,7 @@ export const ATTRIBUTION_COOKIE_NAME = 'qrmaster_first_touch'; export const ONBOARDING_CHECKLIST_DISMISS_KEY = 'qrmaster_onboarding_checklist_dismissed'; +export const ONBOARDING_DOWNLOAD_COMPLETE_KEY = 'qrmaster_onboarding_download_complete'; +export const ONBOARDING_DOWNLOAD_COMPLETE_EVENT = 'qrmaster:onboarding-download-complete'; export type LifecycleStage = | 'cold'