Refine onboarding UI and fix dashboard checklist progress
This commit is contained in:
@@ -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() {
|
||||
<CardContent className="text-center">
|
||||
<div id="create-qr-preview" className="flex justify-center mb-4 w-full min-w-0 overflow-hidden">
|
||||
{/* WRAPPER FOR REF AND FRAME */}
|
||||
<div
|
||||
ref={qrRef}
|
||||
className="relative bg-white rounded-xl p-4 flex flex-col items-center justify-center transition-all duration-300 w-full max-w-full min-w-0"
|
||||
style={{
|
||||
minHeight: '280px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={qrRef}
|
||||
className="relative flex w-full min-w-0 max-w-full flex-col items-center justify-center rounded-xl bg-white p-3 transition-all duration-300 sm:p-4"
|
||||
style={{
|
||||
minHeight: '220px',
|
||||
}}
|
||||
>
|
||||
{/* Frame Label */}
|
||||
{getFrameLabel() && (
|
||||
<div
|
||||
@@ -1203,11 +1220,17 @@ export default function CreatePage() {
|
||||
Enter barcode value
|
||||
</div>
|
||||
)
|
||||
) : qrContent ? (
|
||||
<div className={cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
|
||||
<QRCodeSVG
|
||||
value={qrContent}
|
||||
size={size}
|
||||
) : qrContent ? (
|
||||
<div
|
||||
className={cornerStyle === 'rounded' ? 'overflow-hidden rounded-lg' : ''}
|
||||
style={{
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={qrContent}
|
||||
size={size}
|
||||
fgColor={foregroundColor}
|
||||
bgColor={backgroundColor}
|
||||
level="H"
|
||||
|
||||
@@ -320,27 +320,11 @@ export default function DashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanBadgeColor = (plan: string) => {
|
||||
switch (plan) {
|
||||
case 'PRO':
|
||||
return 'info';
|
||||
case 'BUSINESS':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanEmoji = (plan: string) => {
|
||||
// No emojis anymore
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with Plan Badge */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('dashboard.title')}</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
{!loading && qrCodes.length === 0
|
||||
@@ -348,17 +332,17 @@ export default function DashboardPage() {
|
||||
: t('dashboard.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge variant={getPlanBadgeColor(userPlan)} className="text-lg px-4 py-2">
|
||||
{userPlan} Plan
|
||||
</Badge>
|
||||
{userPlan === 'FREE' && (
|
||||
<Link href="/pricing">
|
||||
<Button variant="primary">Upgrade</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge className="border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700">
|
||||
{userPlan} Plan
|
||||
</Badge>
|
||||
{userPlan === 'FREE' && (
|
||||
<Link href="/pricing">
|
||||
<Button className="bg-slate-900 text-white hover:bg-slate-800">Upgrade</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<OnboardingChecklist state={onboardingState} />
|
||||
@@ -373,9 +357,9 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Recent QR Codes */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('dashboard.recent_codes')}</h2>
|
||||
<div className="flex gap-3">
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('dashboard.recent_codes')}</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{qrCodes.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -385,12 +369,12 @@ export default function DashboardPage() {
|
||||
>
|
||||
{deletingAll ? 'Deleting...' : 'Delete All'}
|
||||
</Button>
|
||||
)}
|
||||
<Link href="/create">
|
||||
<Button>Create New QR Code</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Link href="/create">
|
||||
<Button className="bg-slate-900 text-white hover:bg-slate-800">Create New QR Code</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -410,14 +394,14 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
) : qrCodes.length === 0 ? (
|
||||
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
||||
<div className="rounded-[24px] border border-dashed border-gray-200 py-16 text-center">
|
||||
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">Create your first QR code</h3>
|
||||
<p className="text-gray-500 mb-6 max-w-sm mx-auto">
|
||||
You have {FREE_DYNAMIC_QR_LIMIT} free dynamic QR codes. They redirect wherever you want and track every scan.
|
||||
</p>
|
||||
<Link href="/create">
|
||||
<Button>Create QR Code — it takes 90 seconds</Button>
|
||||
<Button className="bg-slate-900 text-white hover:bg-slate-800">Create QR Code — it takes 90 seconds</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl ${selected ? 'bg-primary-600 text-white' : option.accentClassName}`}>
|
||||
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl ${selected ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600'}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -414,7 +408,7 @@ function StepOptions({
|
||||
<span className="text-sm font-semibold text-slate-900">{option.label}</span>
|
||||
<span
|
||||
className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border transition-colors ${
|
||||
selected ? 'border-primary-600 bg-primary-600 text-white' : 'border-slate-200 bg-white text-transparent'
|
||||
selected ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
@@ -430,30 +424,6 @@ function StepOptions({
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/70 bg-white/75 p-3 shadow-sm shadow-slate-200/40 backdrop-blur">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">{label}</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-800">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, unknown>, nextStep: number) => {
|
||||
setSaving(true);
|
||||
@@ -578,24 +549,29 @@ export default function OnboardingClient() {
|
||||
|
||||
if (loading || !state) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 px-4 py-8 text-white">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.05fr_1fr]">
|
||||
<div className="rounded-[32px] border border-white/10 bg-white/5 p-8 backdrop-blur">
|
||||
<div className="h-4 w-28 rounded-full bg-white/10" />
|
||||
<div className="mt-6 h-12 max-w-lg rounded-2xl bg-white/10" />
|
||||
<div className="mt-3 h-5 max-w-md rounded-full bg-white/10" />
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2">
|
||||
<div className="h-24 rounded-3xl bg-white/10" />
|
||||
<div className="h-24 rounded-3xl bg-white/10" />
|
||||
</div>
|
||||
<div className="min-h-screen bg-white px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mx-auto max-w-3xl animate-pulse">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="h-10 w-40 rounded-full bg-slate-100" />
|
||||
<div className="h-9 w-24 rounded-full bg-slate-100" />
|
||||
</div>
|
||||
<div className="rounded-[28px] border border-slate-200 p-5">
|
||||
<div className="h-3 w-24 rounded-full bg-slate-100" />
|
||||
<div className="mt-4 h-8 w-64 rounded-2xl bg-slate-100" />
|
||||
<div className="mt-4 h-1.5 rounded-full bg-slate-100" />
|
||||
<div className="mt-4 grid grid-cols-4 gap-2 sm:grid-cols-8">
|
||||
{stepLabels.map((label) => (
|
||||
<div key={label} className="h-10 rounded-2xl bg-slate-100" />
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-[32px] border border-white/10 bg-white/80 p-8">
|
||||
<div className="h-4 w-24 rounded-full bg-slate-200" />
|
||||
<div className="mt-6 h-10 rounded-2xl bg-slate-200" />
|
||||
<div className="mt-3 h-20 rounded-3xl bg-slate-100" />
|
||||
<div className="mt-3 h-20 rounded-3xl bg-slate-100" />
|
||||
<div className="mt-8 h-12 rounded-2xl bg-slate-200" />
|
||||
</div>
|
||||
<div className="mt-6 rounded-[28px] border border-slate-200 p-5 sm:p-8">
|
||||
<div className="h-3 w-24 rounded-full bg-slate-100" />
|
||||
<div className="mt-4 h-10 w-72 rounded-2xl bg-slate-100" />
|
||||
<div className="mt-3 h-5 w-full rounded-full bg-slate-100" />
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2">
|
||||
<div className="h-24 rounded-[24px] bg-slate-100" />
|
||||
<div className="h-24 rounded-[24px] bg-slate-100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="relative min-h-screen overflow-hidden bg-slate-950 px-4 py-6 text-white sm:px-6 lg:px-8">
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute left-[-8rem] top-[-8rem] h-80 w-80 rounded-full bg-primary-600/25 blur-3xl" />
|
||||
<div className="absolute right-[-6rem] top-24 h-72 w-72 rounded-full bg-cyan-400/20 blur-3xl" />
|
||||
<div className="absolute bottom-[-8rem] left-1/3 h-80 w-80 rounded-full bg-violet-500/10 blur-3xl" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_bottom,rgba(15,23,42,0.78),rgba(15,23,42,0.96))]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-6xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<Link href="/" className="inline-flex items-center gap-3 text-sm font-semibold tracking-[0.14em] text-white/80 transition hover:text-white">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/10 shadow-lg shadow-black/20 backdrop-blur">
|
||||
<div className="min-h-screen bg-white px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<Link href="/" className="inline-flex items-center gap-3 text-sm font-semibold tracking-[0.14em] text-slate-900">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl border border-slate-200 bg-white">
|
||||
<QrCode className="h-5 w-5" />
|
||||
</span>
|
||||
QR MASTER
|
||||
</Link>
|
||||
<Badge className="border border-white/10 bg-white/10 px-3 py-1 text-white backdrop-blur" variant="default">
|
||||
<div className="rounded-full border border-slate-200 px-3 py-1 text-sm font-medium text-slate-600">
|
||||
Step {step} of 8
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1.05fr_1fr]">
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/8 p-6 shadow-2xl shadow-slate-950/40 backdrop-blur-xl sm:p-8">
|
||||
<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/70">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Personalized setup
|
||||
<section className="rounded-[28px] border border-slate-200 bg-white p-5 shadow-[0_20px_60px_-48px_rgba(15,23,42,0.45)] sm:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{currentStep.eyebrow}</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-slate-950 sm:text-3xl">{currentStep.title}</h1>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-slate-500">{progress}% complete</div>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-6 max-w-xl text-4xl font-semibold tracking-tight text-white sm:text-5xl">
|
||||
Build a sharper first-run experience, not a generic setup wizard.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-base leading-7 text-slate-300 sm:text-lg">
|
||||
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.
|
||||
</p>
|
||||
<div className="mt-5 h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||||
<div className="h-full rounded-full bg-slate-900 transition-all duration-300" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/10 p-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary-500/20 text-primary-200">
|
||||
<BadgeCheck className="h-5 w-5" />
|
||||
<div className="mt-4 grid grid-cols-4 gap-2 sm:grid-cols-8">
|
||||
{stepLabels.map((label, index) => {
|
||||
const stepNumber = index + 1;
|
||||
const isCurrent = stepNumber === step;
|
||||
const isDone = stepNumber < step;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={`rounded-2xl border px-2 py-3 text-center text-xs font-medium transition-colors ${
|
||||
isCurrent
|
||||
? 'border-slate-900 bg-slate-900 text-white'
|
||||
: isDone
|
||||
? 'border-slate-300 bg-slate-100 text-slate-700'
|
||||
: 'border-slate-200 bg-white text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 text-[11px] font-semibold">{stepNumber}</div>
|
||||
<div className="truncate">{label}</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-semibold text-white">Cleaner first build</p>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-300">We skip the dead-end defaults and move you into a stronger starting setup.</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/10 p-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-cyan-500/20 text-cyan-200">
|
||||
<Target className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-semibold text-white">Sharper guidance</p>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-300">Your use case and goal drive the recommendations instead of a one-size-fits-all flow.</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/10 p-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-violet-500/20 text-violet-200">
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-semibold text-white">Faster activation</p>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-300">The goal is not to collect data. The goal is to get you to a real QR asset faster.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 rounded-[28px] border border-white/10 bg-slate-950/40 p-5 shadow-inner shadow-black/20">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">Progress</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-white">{progress}% complete</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-sm font-medium text-emerald-200">
|
||||
{completionText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 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>
|
||||
<section className="mt-6 rounded-[28px] border border-slate-200 bg-white p-5 text-slate-900 shadow-[0_20px_60px_-48px_rgba(15,23,42,0.45)] sm:p-8">
|
||||
<p className="max-w-2xl text-sm leading-7 text-slate-600 sm:text-base">{currentStep.description}</p>
|
||||
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-2">
|
||||
{stepLabels.map((label, index) => {
|
||||
const stepNumber = index + 1;
|
||||
const isCurrent = stepNumber === step;
|
||||
const isDone = stepNumber < step;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={`flex items-center gap-3 rounded-2xl border px-3 py-3 transition-colors ${
|
||||
isCurrent
|
||||
? 'border-primary-400/40 bg-primary-500/15'
|
||||
: isDone
|
||||
? 'border-emerald-400/20 bg-emerald-400/10'
|
||||
: 'border-white/8 bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold ${
|
||||
isCurrent
|
||||
? 'bg-primary-500 text-white'
|
||||
: isDone
|
||||
? 'bg-emerald-400 text-slate-950'
|
||||
: 'bg-white/10 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{isDone ? <Check className="h-4 w-4" /> : stepNumber}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{label}</p>
|
||||
<p className="text-xs text-slate-400">{isCurrent ? 'Current step' : isDone ? 'Completed' : 'Upcoming'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summaryItems.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Badge variant="info" className="px-3 py-1">
|
||||
Your setup
|
||||
</Badge>
|
||||
<span className="text-sm text-slate-300">Live summary based on your answers so far.</span>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{summaryItems.map((item) => (
|
||||
<SummaryItem key={`${item.label}-${item.value}`} label={item.label} value={item.value} icon={item.icon} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/95 p-6 text-slate-900 shadow-2xl shadow-black/25 sm:p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary-600">{currentStep.eyebrow}</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">{currentStep.title}</h2>
|
||||
<p className="mt-3 max-w-xl text-sm leading-7 text-slate-600 sm:text-base">{currentStep.description}</p>
|
||||
</div>
|
||||
<div className="hidden rounded-2xl bg-slate-100 p-3 sm:block">
|
||||
<ChevronRight className="h-5 w-5 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="mt-8">
|
||||
{step === 1 && (
|
||||
<div className="space-y-6">
|
||||
<StepOptions options={signupSourceOptions} value={source} onSelect={setSource} />
|
||||
<Button
|
||||
className="h-12 w-full rounded-2xl text-sm font-semibold"
|
||||
className="h-12 w-full rounded-2xl bg-slate-900 text-sm font-semibold text-white hover:bg-slate-800"
|
||||
loading={saving}
|
||||
disabled={!source}
|
||||
onClick={() => saveStep({ signupSourceSelfReported: source }, 2)}
|
||||
@@ -791,7 +669,7 @@ export default function OnboardingClient() {
|
||||
<div className="space-y-6">
|
||||
<StepOptions options={useCaseOptions} value={useCase} onSelect={setUseCase} />
|
||||
<Button
|
||||
className="h-12 w-full rounded-2xl text-sm font-semibold"
|
||||
className="h-12 w-full rounded-2xl bg-slate-900 text-sm font-semibold text-white hover:bg-slate-800"
|
||||
loading={saving}
|
||||
disabled={!useCase}
|
||||
onClick={() => saveStep({ primaryUseCase: useCase }, 3)}
|
||||
@@ -805,7 +683,7 @@ export default function OnboardingClient() {
|
||||
<div className="space-y-6">
|
||||
<StepOptions options={goalOptions} value={goal} onSelect={setGoal} />
|
||||
<Button
|
||||
className="h-12 w-full rounded-2xl text-sm font-semibold"
|
||||
className="h-12 w-full rounded-2xl bg-slate-900 text-sm font-semibold text-white hover:bg-slate-800"
|
||||
loading={saving}
|
||||
disabled={!goal}
|
||||
onClick={() => saveStep({ primaryGoal: goal }, 4)}
|
||||
@@ -819,7 +697,7 @@ export default function OnboardingClient() {
|
||||
<div className="space-y-6">
|
||||
<StepOptions options={roleOptions} value={jobRole} onSelect={setJobRole} />
|
||||
<Button
|
||||
className="h-12 w-full rounded-2xl text-sm font-semibold"
|
||||
className="h-12 w-full rounded-2xl bg-slate-900 text-sm font-semibold text-white hover:bg-slate-800"
|
||||
loading={saving}
|
||||
disabled={!jobRole}
|
||||
onClick={() => saveStep({ jobRole }, 5)}
|
||||
@@ -863,14 +741,14 @@ export default function OnboardingClient() {
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 w-full rounded-2xl border-slate-300 text-sm font-semibold"
|
||||
className="h-12 w-full rounded-2xl border-slate-300 text-sm font-semibold text-slate-700"
|
||||
loading={saving}
|
||||
onClick={() => saveStep({ companyName, companyWebsite }, 6)}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
className="h-12 w-full rounded-2xl text-sm font-semibold"
|
||||
className="h-12 w-full rounded-2xl bg-slate-900 text-sm font-semibold text-white hover:bg-slate-800"
|
||||
loading={saving}
|
||||
onClick={() => saveStep({ companyName, companyWebsite }, 6)}
|
||||
>
|
||||
@@ -884,7 +762,7 @@ export default function OnboardingClient() {
|
||||
<div className="space-y-6">
|
||||
<StepOptions options={teamOptions} value={teamSize} onSelect={setTeamSize} />
|
||||
<Button
|
||||
className="h-12 w-full rounded-2xl text-sm font-semibold"
|
||||
className="h-12 w-full rounded-2xl bg-slate-900 text-sm font-semibold text-white hover:bg-slate-800"
|
||||
loading={saving}
|
||||
disabled={!teamSize}
|
||||
onClick={() =>
|
||||
@@ -901,7 +779,7 @@ export default function OnboardingClient() {
|
||||
|
||||
{step === 7 && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[28px] border border-primary-100 bg-gradient-to-br from-primary-50 via-white to-blue-50 p-6">
|
||||
<div className="rounded-[28px] border border-slate-200 bg-slate-50 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900 text-white">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
@@ -928,13 +806,13 @@ export default function OnboardingClient() {
|
||||
</div>
|
||||
|
||||
<Link href={createHref} className="block">
|
||||
<Button className="h-12 w-full rounded-2xl text-sm font-semibold">
|
||||
<Button className="h-12 w-full rounded-2xl bg-slate-900 text-sm font-semibold text-white hover:bg-slate-800">
|
||||
Open guided creator
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 w-full rounded-2xl border-slate-300 text-sm font-semibold"
|
||||
className="h-12 w-full rounded-2xl border-slate-300 text-sm font-semibold text-slate-700"
|
||||
onClick={() => router.push(destinationHref)}
|
||||
>
|
||||
Skip for now
|
||||
@@ -944,9 +822,9 @@ export default function OnboardingClient() {
|
||||
|
||||
{step === 8 && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[28px] border border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-cyan-50 p-6">
|
||||
<div className="rounded-[28px] border border-slate-200 bg-slate-50 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500 text-slate-950">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900 text-white">
|
||||
<BadgeCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -963,7 +841,7 @@ export default function OnboardingClient() {
|
||||
</div>
|
||||
|
||||
<Link href={destinationHref} className="block">
|
||||
<Button className="h-12 w-full rounded-2xl text-sm font-semibold">
|
||||
<Button className="h-12 w-full rounded-2xl bg-slate-900 text-sm font-semibold text-white hover:bg-slate-800">
|
||||
{destinationHref === '/dashboard' ? 'Open dashboard' : 'Continue to your page'}
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -987,7 +865,7 @@ export default function OnboardingClient() {
|
||||
|
||||
<Link href="/create" className="block">
|
||||
<div className="rounded-[24px] border border-slate-200 p-4 transition-colors hover:border-slate-300 hover:bg-slate-50">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary-600 text-white">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-900 text-white">
|
||||
<QrCode className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-semibold text-slate-900">Create another QR code</p>
|
||||
@@ -999,7 +877,6 @@ export default function OnboardingClient() {
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user