revops + onboarding
This commit is contained in:
368
src/lib/revops.ts
Normal file
368
src/lib/revops.ts
Normal 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: '2–5' },
|
||||
{ value: '6_20', label: '6–20' },
|
||||
{ value: '21_100', label: '21–100' },
|
||||
{ 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),
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user