revops + onboarding

This commit is contained in:
Timo Knuth
2026-04-22 20:00:44 +02:00
parent ce724662d4
commit 7d2724b65d
37 changed files with 5073 additions and 1286 deletions

368
src/lib/revops.ts Normal file
View File

@@ -0,0 +1,368 @@
export const ATTRIBUTION_COOKIE_NAME = 'qrmaster_first_touch';
export const ONBOARDING_CHECKLIST_DISMISS_KEY = 'qrmaster_onboarding_checklist_dismissed';
export type LifecycleStage =
| 'cold'
| 'activated'
| 'warm'
| 'hot'
| 'upgrade_candidate'
| 'paid';
export type SignupSourceOption = {
value: string;
label: string;
};
export type OnboardingOption = {
value: string;
label: string;
description?: string;
};
export type AttributionSnapshot = {
signupSource?: string | null;
signupMedium?: string | null;
signupCampaign?: string | null;
signupContent?: string | null;
signupTerm?: string | null;
signupReferrer?: string | null;
signupLandingPath?: string | null;
signupFirstSeenAt?: string | null;
};
const FREEMAIL_DOMAINS = new Set([
'gmail.com',
'yahoo.com',
'hotmail.com',
'outlook.com',
'icloud.com',
'gmx.de',
'gmx.net',
'web.de',
'mail.com',
'aol.com',
'proton.me',
'protonmail.com',
]);
export const SIGNUP_SOURCE_OPTIONS: SignupSourceOption[] = [
{ value: 'google_search', label: 'Google Search' },
{ value: 'instagram', label: 'Instagram' },
{ value: 'facebook', label: 'Facebook' },
{ value: 'tiktok', label: 'TikTok' },
{ value: 'linkedin', label: 'LinkedIn' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'blog_article', label: 'Blog or article' },
{ value: 'friend_colleague', label: 'Friend or colleague' },
{ value: 'direct', label: 'Direct / I typed the URL' },
{ value: 'other', label: 'Other' },
];
export const PRIMARY_USE_CASE_OPTIONS: OnboardingOption[] = [
{ value: 'website_qr', label: 'Website QR Code' },
{ value: 'menu_pdf', label: 'Menu or PDF QR Code' },
{ value: 'contact_card', label: 'Contact Card QR Code' },
{ value: 'wifi_qr', label: 'WiFi QR Code' },
{ value: 'marketing_campaign', label: 'Marketing Campaign QR Code' },
{ value: 'barcode', label: 'Barcode' },
{ value: 'bulk_qr', label: 'Bulk QR Codes' },
{ value: 'something_else', label: 'Something else' },
];
export const PRIMARY_GOAL_OPTIONS: OnboardingOption[] = [
{ value: 'drive_website_traffic', label: 'Drive website traffic' },
{ value: 'track_printed_campaigns', label: 'Track printed campaigns' },
{ value: 'share_contact_details', label: 'Share contact details' },
{ value: 'replace_printed_menus', label: 'Replace printed menus' },
{ value: 'generate_leads', label: 'Generate leads' },
{ value: 'label_products', label: 'Label products' },
{ value: 'manage_multiple_qr_codes', label: 'Manage multiple QR codes' },
{ value: 'something_else', label: 'Something else' },
];
export const JOB_ROLE_OPTIONS: OnboardingOption[] = [
{ value: 'founder_owner', label: 'Founder / Owner' },
{ value: 'marketing_manager', label: 'Marketing Manager' },
{ value: 'operations', label: 'Operations' },
{ value: 'agency_freelancer', label: 'Agency / Freelancer' },
{ value: 'it_technical', label: 'IT / Technical' },
{ value: 'sales', label: 'Sales' },
{ value: 'designer', label: 'Designer' },
{ value: 'other', label: 'Other' },
];
export const TEAM_SIZE_OPTIONS: OnboardingOption[] = [
{ value: 'just_me', label: 'Just me' },
{ value: '2_5', label: '25' },
{ value: '6_20', label: '620' },
{ value: '21_100', label: '21100' },
{ value: '100_plus', label: '100+' },
];
export function serializeAttributionCookie(snapshot: AttributionSnapshot): string {
return JSON.stringify(snapshot);
}
export function parseAttributionCookie(value?: string | null): AttributionSnapshot | null {
if (!value) {
return null;
}
try {
return JSON.parse(value) as AttributionSnapshot;
} catch {
return null;
}
}
export function getEmailDomain(email?: string | null): string | null {
if (!email || !email.includes('@')) {
return null;
}
return email.split('@')[1]?.trim().toLowerCase() || null;
}
export function isFreemailDomain(domain?: string | null): boolean {
return Boolean(domain && FREEMAIL_DOMAINS.has(domain.toLowerCase()));
}
export function getSourceLabel(value?: string | null): string {
const option = SIGNUP_SOURCE_OPTIONS.find((item) => item.value === value);
return option?.label || 'Unknown';
}
export function getUseCaseLabel(value?: string | null): string {
const option = PRIMARY_USE_CASE_OPTIONS.find((item) => item.value === value);
return option?.label || 'Unknown';
}
export function getGoalLabel(value?: string | null): string {
const option = PRIMARY_GOAL_OPTIONS.find((item) => item.value === value);
return option?.label || 'Unknown';
}
export function getRoleLabel(value?: string | null): string {
const option = JOB_ROLE_OPTIONS.find((item) => item.value === value);
return option?.label || 'Unknown';
}
export function getTeamSizeLabel(value?: string | null): string {
const option = TEAM_SIZE_OPTIONS.find((item) => item.value === value);
return option?.label || 'Unknown';
}
export function normalizeSource(input: {
utmSource?: string | null;
referrer?: string | null;
landingPath?: string | null;
}): string {
const utmSource = input.utmSource?.toLowerCase().trim();
const referrer = input.referrer?.toLowerCase().trim();
const landingPath = input.landingPath?.toLowerCase().trim();
if (utmSource) {
if (utmSource.includes('google')) return 'google_search';
if (utmSource.includes('instagram')) return 'instagram';
if (utmSource.includes('facebook') || utmSource.includes('meta')) return 'facebook';
if (utmSource.includes('tiktok')) return 'tiktok';
if (utmSource.includes('linkedin')) return 'linkedin';
if (utmSource.includes('youtube')) return 'youtube';
if (utmSource.includes('blog') || utmSource.includes('article') || utmSource.includes('seo')) return 'blog_article';
}
if (referrer) {
if (referrer.includes('google.')) return 'google_search';
if (referrer.includes('instagram.')) return 'instagram';
if (referrer.includes('facebook.') || referrer.includes('fb.com')) return 'facebook';
if (referrer.includes('tiktok.')) return 'tiktok';
if (referrer.includes('linkedin.')) return 'linkedin';
if (referrer.includes('youtube.')) return 'youtube';
if (referrer.includes('medium.com') || referrer.includes('substack.com') || referrer.includes('/blog')) return 'blog_article';
}
if (landingPath && landingPath.startsWith('/blog')) {
return 'blog_article';
}
return 'direct';
}
export function buildAttributionSnapshot(params: {
utmSource?: string | null;
utmMedium?: string | null;
utmCampaign?: string | null;
utmContent?: string | null;
utmTerm?: string | null;
referrer?: string | null;
landingPath?: string | null;
firstSeenAt?: Date | string | null;
}): AttributionSnapshot {
const signupFirstSeenAt =
params.firstSeenAt instanceof Date
? params.firstSeenAt.toISOString()
: params.firstSeenAt || new Date().toISOString();
return {
signupSource: normalizeSource({
utmSource: params.utmSource,
referrer: params.referrer,
landingPath: params.landingPath,
}),
signupMedium: params.utmMedium || null,
signupCampaign: params.utmCampaign || null,
signupContent: params.utmContent || null,
signupTerm: params.utmTerm || null,
signupReferrer: params.referrer || null,
signupLandingPath: params.landingPath || null,
signupFirstSeenAt,
};
}
export function shouldResumeOnboarding(user: {
onboardingStartedAt?: Date | null;
onboardingCompletedAt?: Date | null;
} | null): boolean {
if (!user) {
return false;
}
return Boolean(user.onboardingStartedAt && !user.onboardingCompletedAt);
}
export function getLifecycleStageLabel(stage?: string | null): string {
switch (stage) {
case 'activated':
return 'Activated';
case 'warm':
return 'Warm';
case 'hot':
return 'Hot';
case 'upgrade_candidate':
return 'Upgrade Candidate';
case 'paid':
return 'Paid';
default:
return 'Cold';
}
}
export function getOnboardingHeadlineForUseCase(useCase?: string | null): string {
switch (useCase) {
case 'marketing_campaign':
return 'Create your first campaign QR code';
case 'menu_pdf':
return 'Create your first menu QR code';
case 'contact_card':
return 'Create your first contact QR code';
case 'barcode':
return 'Create your first barcode';
case 'bulk_qr':
return 'Set up your first business-ready QR';
default:
return 'Create your first QR code';
}
}
export function getCreatePresetForUseCase(useCase?: string | null): {
contentType: string;
dynamic: boolean;
title: string;
content: Record<string, string>;
} {
switch (useCase) {
case 'menu_pdf':
return {
contentType: 'PDF',
dynamic: true,
title: 'Restaurant menu',
content: {},
};
case 'contact_card':
return {
contentType: 'VCARD',
dynamic: true,
title: 'Business contact card',
content: {},
};
case 'barcode':
return {
contentType: 'BARCODE',
dynamic: false,
title: 'Product barcode',
content: { format: 'CODE128' },
};
case 'bulk_qr':
return {
contentType: 'URL',
dynamic: true,
title: 'Bulk campaign starter',
content: { url: '' },
};
case 'wifi_qr':
return {
contentType: 'TEXT',
dynamic: false,
title: 'WiFi access QR',
content: { text: 'WIFI:T:WPA;S:MyNetwork;P:password;;' },
};
case 'website_qr':
return {
contentType: 'URL',
dynamic: true,
title: 'Website QR',
content: { url: '' },
};
case 'marketing_campaign':
default:
return {
contentType: 'URL',
dynamic: true,
title: 'Campaign landing page',
content: { url: '' },
};
}
}
export function getChecklistItems(user: {
signupSourceSelfReported?: string | null;
primaryUseCase?: string | null;
firstQrCreatedAt?: string | Date | null;
firstDynamicQrAt?: string | Date | null;
firstScanAt?: string | Date | null;
activationAt?: string | Date | null;
}): Array<{ id: string; label: string; done: boolean }> {
return [
{
id: 'source',
label: 'Confirm how you found QR Master',
done: Boolean(user.signupSourceSelfReported),
},
{
id: 'use-case',
label: 'Choose your use case',
done: Boolean(user.primaryUseCase),
},
{
id: 'first-qr',
label: 'Create your first QR code',
done: Boolean(user.firstQrCreatedAt),
},
{
id: 'first-dynamic',
label: 'Create your first dynamic QR code',
done: Boolean(user.firstDynamicQrAt),
},
{
id: 'download',
label: 'Download your QR code',
done: false,
},
{
id: 'scan',
label: 'Get your first scan',
done: Boolean(user.firstScanAt),
},
];
}