Refine onboarding UI and fix dashboard checklist progress

This commit is contained in:
Timo Knuth
2026-04-23 19:03:41 +02:00
parent fc0e6a0a69
commit eacaef1fbd
7 changed files with 316 additions and 386 deletions

View File

@@ -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<string, number> = {
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 (
<Card className="overflow-hidden rounded-[28px] border border-slate-200 bg-gradient-to-br from-white via-slate-50 to-blue-50 p-0 shadow-lg shadow-slate-200/60">
<div className="border-b border-slate-200/80 bg-slate-950 px-6 py-6 text-white">
<Card className="overflow-hidden rounded-[28px] border border-slate-200 bg-white p-0 shadow-none">
<CardContent className="space-y-5 p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-2xl">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-white/80">
<Sparkles className="h-3.5 w-3.5" />
Activation path
</div>
<h3 className="mt-4 text-2xl font-semibold tracking-tight text-white">Get to your first result faster</h3>
<p className="mt-3 text-sm leading-7 text-slate-300">
Finish the onboarding checklist to unlock a cleaner dashboard state and move from setup into real usage.
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
{allDone ? 'Onboarding complete' : 'Onboarding progress'}
</p>
<h3 className="mt-2 text-xl font-semibold tracking-tight text-slate-950">
{allDone ? 'Your first setup is complete' : 'Finish the first-run checklist'}
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
{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.'}
</p>
</div>
<div className="flex items-center gap-3">
<Badge className="border border-white/10 bg-white/10 px-3 py-1 text-white" variant="default">
{completed}/{items.length} done
</Badge>
<div className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-slate-600">
{completed}/{items.length}
</div>
<button
type="button"
aria-label="Dismiss onboarding checklist"
className="flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/10 text-white/80 transition hover:text-white"
className="flex h-10 w-10 items-center justify-center rounded-2xl border border-slate-200 bg-white text-slate-500 transition hover:border-slate-300 hover:text-slate-900"
onClick={() => {
localStorage.setItem(ONBOARDING_CHECKLIST_DISMISS_KEY, '1');
setDismissed(true);
@@ -69,62 +106,50 @@ export function OnboardingChecklist({ state }: OnboardingChecklistProps) {
</div>
</div>
<div className="mt-5 h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-gradient-to-r from-primary-400 via-cyan-300 to-emerald-300 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<CardContent className="space-y-5 p-6">
<div className="grid gap-3 md:grid-cols-2">
{items.map((item) => (
<div className="space-y-3">
<div className="h-1.5 overflow-hidden rounded-full bg-slate-100">
<div
key={item.id}
className={`rounded-[22px] border p-4 transition-colors ${
item.done
? 'border-emerald-200 bg-emerald-50'
: 'border-slate-200 bg-white'
}`}
>
<div className="flex items-start gap-3">
<div
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${
item.done ? 'bg-emerald-500 text-slate-950' : 'bg-slate-100 text-slate-500'
}`}
>
{item.done ? <Check className="h-4 w-4" /> : <QrCode className="h-4 w-4" />}
</div>
<div className="min-w-0">
<p className={`text-sm font-semibold ${item.done ? 'text-emerald-900' : 'text-slate-900'}`}>
{item.label}
</p>
<p className="mt-1 text-sm leading-6 text-slate-600">
{item.done ? 'Completed and saved in your setup state.' : 'Still pending. Finish this to move closer to activation.'}
</p>
</div>
</div>
</div>
))}
className="h-full rounded-full bg-slate-900 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{items.map((item, index) => (
<Link
key={item.id}
href={appendRedirectParam('/onboarding', '/dashboard', {
step: String(stepMap[item.id] || index + 1),
})}
className={`rounded-full border px-3 py-2 text-center text-xs font-medium transition-colors ${
item.done
? 'border-slate-900 bg-slate-900 text-white'
: 'border-slate-200 bg-slate-50 text-slate-500 hover:border-slate-300 hover:text-slate-700'
}`}
>
{index + 1}. {item.label}
</Link>
))}
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Link href="/onboarding" className="block">
<Button className="h-11 rounded-2xl px-5 text-sm font-semibold">
Continue onboarding
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
{!allDone && (
<Link href="/onboarding" className="block">
<Button className="h-11 rounded-2xl bg-slate-900 px-5 text-sm font-semibold text-white hover:bg-slate-800">
Continue onboarding
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
)}
<Button
variant="outline"
className="h-11 rounded-2xl border-slate-300 px-5 text-sm font-semibold"
className="h-11 rounded-2xl border-slate-300 px-5 text-sm font-semibold text-slate-700"
onClick={() => {
localStorage.setItem(ONBOARDING_CHECKLIST_DISMISS_KEY, '1');
setDismissed(true);
}}
>
Dismiss
{allDone ? 'Hide checklist' : 'Dismiss'}
</Button>
</div>
</CardContent>

View File

@@ -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<QRCodeCardProps> = ({
qr,
onEdit,
onDelete,
}) => {
// For dynamic QR codes, use the redirect URL for tracking
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
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 (
<Card hover>
<CardContent className="p-4">
<Card hover className="rounded-[24px] border-slate-200 p-0 shadow-none">
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="font-semibold text-gray-900 mb-1">{qr.title}</h3>
<div className="flex items-center space-x-2">
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
{qr.type}
</Badge>
</div>
<Badge className={qr.type === 'DYNAMIC' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700'}>
{qr.type}
</Badge>
</div>
</div>
<Dropdown
@@ -218,9 +234,12 @@ END:VCARD`;
</Dropdown>
</div>
<div className="flex flex-col items-center justify-center bg-gray-50 rounded-lg p-4 mb-3">
{/* Download wrapper - tightly wraps content */}
<div id={`qr-download-${qr.id}`} className="inline-flex flex-col items-center bg-white p-4 rounded-xl">
<div className="mb-3 flex flex-col items-center justify-center rounded-[20px] bg-slate-50 p-4">
{/* Download wrapper - tightly wraps content */}
<div
id={`qr-download-${qr.id}`}
className="inline-flex flex-col items-center rounded-[20px] border border-slate-100 bg-white p-4"
>
{/* Frame Label */}
{qr.style?.frameType && qr.style.frameType !== 'none' && (
<div
@@ -299,7 +318,7 @@ END:VCARD`;
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
</div>
{qr.type === 'DYNAMIC' && (
<div className="pt-2 border-t">
<div className="border-t border-slate-100 pt-2">
<p className="text-xs text-gray-500">
📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
</p>
@@ -318,4 +337,4 @@ END:VCARD`;
</CardContent>
</Card>
);
};
};

View File

@@ -80,29 +80,29 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats, trends }) => {
},
];
return (
<div className="grid md:grid-cols-3 gap-6">
{cards.map((card, index) => (
<Card key={index}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">{card.title}</p>
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
<p className={`text-sm mt-2 ${card.changeType === 'positive' ? 'text-success-600' :
card.changeType === 'negative' ? 'text-red-600' :
'text-gray-500'
}`}>
{card.change}
</p>
</div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center text-primary-600">
{card.icon}
</div>
</div>
</CardContent>
return (
<div className="grid gap-4 md:grid-cols-3">
{cards.map((card, index) => (
<Card key={index} className="rounded-[24px] border-slate-200 p-0 shadow-none">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="mb-1 text-sm text-gray-500">{card.title}</p>
<p className="text-2xl font-semibold text-gray-900">{card.value}</p>
<p className={`text-sm mt-2 ${card.changeType === 'positive' ? 'text-success-600' :
card.changeType === 'negative' ? 'text-red-600' :
'text-gray-500'
}`}>
{card.change}
</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-700">
{card.icon}
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
};
};