revops + onboarding
This commit is contained in:
47
src/lib/auth-flow.ts
Normal file
47
src/lib/auth-flow.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const GOOGLE_OAUTH_STATE_COOKIE_NAME = 'qrmaster_google_oauth_state';
|
||||
export const POST_AUTH_REDIRECT_COOKIE_NAME = 'qrmaster_post_auth_redirect';
|
||||
|
||||
export function sanitizeRedirectPath(value?: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('/api/') || trimmed.startsWith('/login') || trimmed.startsWith('/signup')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function appendRedirectParam(
|
||||
path: string,
|
||||
redirectPath?: string | null,
|
||||
extraParams?: Record<string, string | null | undefined>
|
||||
): string {
|
||||
const [pathname, existingQuery = ''] = path.split('?');
|
||||
const searchParams = new URLSearchParams(existingQuery);
|
||||
const safeRedirect = sanitizeRedirectPath(redirectPath);
|
||||
|
||||
if (safeRedirect) {
|
||||
searchParams.set('redirect', safeRedirect);
|
||||
}
|
||||
|
||||
Object.entries(extraParams || {}).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const query = searchParams.toString();
|
||||
return query ? `${pathname}?${query}` : pathname;
|
||||
}
|
||||
|
||||
export function getPostOnboardingDestination(redirectPath?: string | null): string {
|
||||
return sanitizeRedirectPath(redirectPath) || '/dashboard';
|
||||
}
|
||||
@@ -905,7 +905,7 @@ export async function sendActivationNudgeEmail(email: string, name: string) {
|
||||
<td style="padding:40px 48px 0;">
|
||||
<p style="margin:0 0 8px;font-family:'DM Sans',-apple-system,sans-serif;
|
||||
font-size:16px;line-height:1.75;color:${clr.text};">
|
||||
Your 3 free dynamic QR codes are still there. Unused.<br>
|
||||
Your 3 free dynamic QR codes are still there. Unused.<br>
|
||||
Dynamic means: one code, update the link anytime, every scan tracked.
|
||||
</p>
|
||||
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||
@@ -1039,7 +1039,7 @@ export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount
|
||||
|
||||
<h1 style="margin:28px 0 0;font-family:'DM Serif Display',Georgia,serif;
|
||||
font-size:34px;font-weight:400;line-height:1.2;color:#FFFFFF;">
|
||||
${qrCount} of 3 free codes used,<br>
|
||||
${qrCount} of 3 free codes used,<br>
|
||||
<em style="color:${clr.gold};">${firstName}.</em>
|
||||
</h1>
|
||||
|
||||
|
||||
10
src/lib/plans.ts
Normal file
10
src/lib/plans.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const FREE_DYNAMIC_QR_LIMIT = 3;
|
||||
export const PRO_DYNAMIC_QR_LIMIT = 50;
|
||||
export const BUSINESS_DYNAMIC_QR_LIMIT = 500;
|
||||
|
||||
export const DYNAMIC_QR_LIMITS = {
|
||||
FREE: FREE_DYNAMIC_QR_LIMIT,
|
||||
PRO: PRO_DYNAMIC_QR_LIMIT,
|
||||
BUSINESS: BUSINESS_DYNAMIC_QR_LIMIT,
|
||||
ENTERPRISE: 99999,
|
||||
} as const;
|
||||
361
src/lib/revops-server.ts
Normal file
361
src/lib/revops-server.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { db } from '@/lib/db';
|
||||
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||
import {
|
||||
getEmailDomain,
|
||||
isFreemailDomain,
|
||||
LifecycleStage,
|
||||
normalizeSource,
|
||||
} from '@/lib/revops';
|
||||
|
||||
type ScoreReason =
|
||||
| 'signup'
|
||||
| 'onboarding_update'
|
||||
| 'qr_created'
|
||||
| 'scan_recorded'
|
||||
| 'subscription_changed';
|
||||
|
||||
type UserForScoring = {
|
||||
id: string;
|
||||
email: string;
|
||||
plan: string;
|
||||
primaryUseCase: string | null;
|
||||
primaryGoal: string | null;
|
||||
jobRole: string | null;
|
||||
companyName: string | null;
|
||||
teamSizeBucket: string | null;
|
||||
firstQrCreatedAt: Date | null;
|
||||
firstDynamicQrAt: Date | null;
|
||||
firstStaticQrAt: Date | null;
|
||||
firstScanAt: Date | null;
|
||||
activationAt: Date | null;
|
||||
onboardingCompletedAt: Date | null;
|
||||
lastQualifiedAt: Date | null;
|
||||
lifecycleStage: string;
|
||||
};
|
||||
|
||||
type UserMetricSnapshot = {
|
||||
qrCount: number;
|
||||
dynamicQrCount: number;
|
||||
contentTypeCount: number;
|
||||
businessishTypeCount: number;
|
||||
scanCount: number;
|
||||
firstQrCreatedAt: Date | null;
|
||||
firstDynamicQrAt: Date | null;
|
||||
firstStaticQrAt: Date | null;
|
||||
};
|
||||
|
||||
export function triggerLifecycleScoring(userId: string, reason: ScoreReason) {
|
||||
void scoreUserLifecycle(userId, reason).catch((error) => {
|
||||
console.error(`Lifecycle scoring failed for ${userId} (${reason}):`, error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function scoreUserLifecycle(userId: string, reason: ScoreReason) {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
plan: true,
|
||||
primaryUseCase: true,
|
||||
primaryGoal: true,
|
||||
jobRole: true,
|
||||
companyName: true,
|
||||
teamSizeBucket: true,
|
||||
firstQrCreatedAt: true,
|
||||
firstDynamicQrAt: true,
|
||||
firstStaticQrAt: true,
|
||||
firstScanAt: true,
|
||||
activationAt: true,
|
||||
onboardingCompletedAt: true,
|
||||
lastQualifiedAt: true,
|
||||
lifecycleStage: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const qrCodes = await db.qRCode.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
contentType: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
scans: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const firstScan = await db.qRScan.findFirst({
|
||||
where: {
|
||||
qr: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
ts: 'asc',
|
||||
},
|
||||
select: {
|
||||
ts: true,
|
||||
},
|
||||
});
|
||||
|
||||
const metrics = getMetricSnapshot(qrCodes);
|
||||
const computedTimestamps = {
|
||||
firstQrCreatedAt: user.firstQrCreatedAt ?? metrics.firstQrCreatedAt,
|
||||
firstDynamicQrAt: user.firstDynamicQrAt ?? metrics.firstDynamicQrAt,
|
||||
firstStaticQrAt: user.firstStaticQrAt ?? metrics.firstStaticQrAt,
|
||||
firstScanAt: user.firstScanAt ?? firstScan?.ts ?? null,
|
||||
activationAt: user.activationAt ?? user.firstScanAt ?? firstScan?.ts ?? null,
|
||||
onboardingCompletedAt:
|
||||
user.onboardingCompletedAt ?? metrics.firstQrCreatedAt,
|
||||
};
|
||||
|
||||
const fitScore = calculateFitScore(user);
|
||||
const intentScore = calculateIntentScore({
|
||||
...computedTimestamps,
|
||||
...metrics,
|
||||
});
|
||||
const leadScore = fitScore + intentScore;
|
||||
const nextStage = resolveLifecycleStage({
|
||||
plan: user.plan,
|
||||
leadScore,
|
||||
activationAt: computedTimestamps.activationAt,
|
||||
});
|
||||
const shouldRefreshQualifiedAt = nextStage === 'paid' || nextStage === 'hot' || nextStage === 'upgrade_candidate';
|
||||
|
||||
const updatedUser = await db.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
emailDomain: getEmailDomain(user.email),
|
||||
firstQrCreatedAt: computedTimestamps.firstQrCreatedAt,
|
||||
firstDynamicQrAt: computedTimestamps.firstDynamicQrAt,
|
||||
firstStaticQrAt: computedTimestamps.firstStaticQrAt,
|
||||
firstScanAt: computedTimestamps.firstScanAt,
|
||||
activationAt: computedTimestamps.activationAt,
|
||||
onboardingCompletedAt: computedTimestamps.onboardingCompletedAt,
|
||||
fitScore,
|
||||
intentScore,
|
||||
leadScore,
|
||||
lifecycleStage: nextStage,
|
||||
lastScoredAt: new Date(),
|
||||
lastQualifiedAt: shouldRefreshQualifiedAt ? new Date() : user.lastQualifiedAt,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
lifecycleStage: true,
|
||||
fitScore: true,
|
||||
intentScore: true,
|
||||
leadScore: true,
|
||||
firstQrCreatedAt: true,
|
||||
firstDynamicQrAt: true,
|
||||
firstScanAt: true,
|
||||
activationAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user.lifecycleStage !== nextStage) {
|
||||
await db.userLifecycleLog.create({
|
||||
data: {
|
||||
userId,
|
||||
fromStage: user.lifecycleStage,
|
||||
toStage: nextStage,
|
||||
fitScore,
|
||||
intentScore,
|
||||
leadScore,
|
||||
reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
export async function getOnboardingState(userId: string) {
|
||||
return db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
signupSource: true,
|
||||
signupSourceSelfReported: true,
|
||||
signupCampaign: true,
|
||||
signupLandingPath: true,
|
||||
primaryUseCase: true,
|
||||
primaryGoal: true,
|
||||
jobRole: true,
|
||||
companyName: true,
|
||||
companyWebsite: true,
|
||||
teamSizeBucket: true,
|
||||
onboardingStartedAt: true,
|
||||
sourceConfirmedAt: true,
|
||||
useCaseSelectedAt: true,
|
||||
goalSelectedAt: true,
|
||||
profileCompletedAt: true,
|
||||
firstQrCreatedAt: true,
|
||||
firstDynamicQrAt: true,
|
||||
firstStaticQrAt: true,
|
||||
firstScanAt: true,
|
||||
activationAt: true,
|
||||
onboardingCompletedAt: true,
|
||||
lifecycleStage: true,
|
||||
fitScore: true,
|
||||
intentScore: true,
|
||||
leadScore: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getMetricSnapshot(
|
||||
qrCodes: Array<{
|
||||
type: 'STATIC' | 'DYNAMIC';
|
||||
contentType: string;
|
||||
createdAt: Date;
|
||||
_count: { scans: number };
|
||||
}>
|
||||
): UserMetricSnapshot {
|
||||
const sorted = [...qrCodes].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
const dynamicOnly = sorted.filter((qr) => qr.type === 'DYNAMIC');
|
||||
const staticOnly = sorted.filter((qr) => qr.type === 'STATIC');
|
||||
const businessish = sorted.filter((qr) =>
|
||||
['BARCODE', 'PDF', 'VCARD', 'COUPON', 'FEEDBACK'].includes(qr.contentType)
|
||||
);
|
||||
|
||||
return {
|
||||
qrCount: sorted.length,
|
||||
dynamicQrCount: dynamicOnly.length,
|
||||
contentTypeCount: new Set(sorted.map((qr) => qr.contentType)).size,
|
||||
businessishTypeCount: businessish.length,
|
||||
scanCount: sorted.reduce((sum, qr) => sum + qr._count.scans, 0),
|
||||
firstQrCreatedAt: sorted[0]?.createdAt ?? null,
|
||||
firstDynamicQrAt: dynamicOnly[0]?.createdAt ?? null,
|
||||
firstStaticQrAt: staticOnly[0]?.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateFitScore(user: Pick<UserForScoring, 'email' | 'primaryUseCase' | 'primaryGoal' | 'jobRole' | 'companyName' | 'teamSizeBucket'>): number {
|
||||
const emailDomain = getEmailDomain(user.email);
|
||||
let score = 0;
|
||||
|
||||
if (emailDomain) {
|
||||
score += isFreemailDomain(emailDomain) ? -15 : 20;
|
||||
}
|
||||
|
||||
if (['marketing_campaign', 'bulk_qr', 'menu_pdf', 'barcode'].includes(user.primaryUseCase ?? '')) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
if (['track_printed_campaigns', 'generate_leads', 'manage_multiple_qr_codes'].includes(user.primaryGoal ?? '')) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
if (['founder_owner', 'marketing_manager', 'agency_freelancer', 'operations'].includes(user.jobRole ?? '')) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
if (user.companyName?.trim()) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
if (['6_20', '21_100', '100_plus'].includes(user.teamSizeBucket ?? '')) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function calculateIntentScore(input: {
|
||||
firstQrCreatedAt: Date | null;
|
||||
firstDynamicQrAt: Date | null;
|
||||
qrCount: number;
|
||||
scanCount: number;
|
||||
businessishTypeCount: number;
|
||||
contentTypeCount: number;
|
||||
}): number {
|
||||
let score = 0;
|
||||
|
||||
score += input.firstQrCreatedAt ? 20 : -10;
|
||||
score += input.firstDynamicQrAt ? 20 : 0;
|
||||
score += input.qrCount >= 3 ? 15 : 0;
|
||||
score += input.scanCount > 0 ? 10 : 0;
|
||||
score += input.businessishTypeCount > 0 ? 10 : 0;
|
||||
score += input.contentTypeCount >= 2 ? 10 : 0;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function resolveLifecycleStage(input: {
|
||||
plan: string;
|
||||
leadScore: number;
|
||||
activationAt: Date | null;
|
||||
}): LifecycleStage {
|
||||
if (input.plan === 'PRO' || input.plan === 'BUSINESS') {
|
||||
return 'paid';
|
||||
}
|
||||
if (input.leadScore >= 70) {
|
||||
return 'upgrade_candidate';
|
||||
}
|
||||
if (input.leadScore >= 55) {
|
||||
return 'hot';
|
||||
}
|
||||
if (input.leadScore >= 30) {
|
||||
return 'warm';
|
||||
}
|
||||
if (input.activationAt) {
|
||||
return 'activated';
|
||||
}
|
||||
return 'cold';
|
||||
}
|
||||
|
||||
export function getUpgradeCandidateBadges(user: {
|
||||
email?: string | null;
|
||||
primaryUseCase?: string | null;
|
||||
primaryGoal?: string | null;
|
||||
}, metrics: {
|
||||
dynamicQrCount: number;
|
||||
qrCount: number;
|
||||
scanCount: number;
|
||||
}): string[] {
|
||||
const emailDomain = getEmailDomain(user.email);
|
||||
const badges: string[] = [];
|
||||
|
||||
if (emailDomain && !isFreemailDomain(emailDomain)) {
|
||||
badges.push('business domain');
|
||||
}
|
||||
if (metrics.dynamicQrCount > 0) {
|
||||
badges.push('dynamic usage');
|
||||
}
|
||||
if (metrics.qrCount >= 3) {
|
||||
badges.push('3+ QRs');
|
||||
}
|
||||
if (metrics.scanCount > 0) {
|
||||
badges.push('scans detected');
|
||||
}
|
||||
if (
|
||||
user.primaryUseCase === 'marketing_campaign' ||
|
||||
user.primaryGoal === 'track_printed_campaigns' ||
|
||||
user.primaryGoal === 'generate_leads'
|
||||
) {
|
||||
badges.push('marketing campaign intent');
|
||||
}
|
||||
if (metrics.dynamicQrCount >= Math.max(1, FREE_DYNAMIC_QR_LIMIT - 1)) {
|
||||
badges.push('near free plan limit');
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
export function normalizeTrackedSource(source?: string | null, referrer?: string | null, landingPath?: string | null) {
|
||||
return normalizeSource({
|
||||
utmSource: source,
|
||||
referrer,
|
||||
landingPath,
|
||||
});
|
||||
}
|
||||
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),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
// Use a placeholder during build time, real key at runtime
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
|
||||
import Stripe from 'stripe';
|
||||
import {
|
||||
FREE_DYNAMIC_QR_LIMIT,
|
||||
PRO_DYNAMIC_QR_LIMIT,
|
||||
BUSINESS_DYNAMIC_QR_LIMIT,
|
||||
} from '@/lib/plans';
|
||||
|
||||
// Use a placeholder during build time, real key at runtime
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
|
||||
|
||||
export const stripe = new Stripe(stripeKey, {
|
||||
apiVersion: '2025-10-29.clover',
|
||||
@@ -20,18 +25,18 @@ export const STRIPE_PLANS = {
|
||||
name: 'Free / Starter',
|
||||
price: 0,
|
||||
currency: 'EUR',
|
||||
interval: 'month',
|
||||
features: [
|
||||
'3 dynamische QR-Codes',
|
||||
'Basis-Tracking (Scans + Standort)',
|
||||
'Einfache Designs',
|
||||
'Unbegrenzte statische QR-Codes',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 3,
|
||||
staticQRCodes: -1, // unlimited
|
||||
teamMembers: 1,
|
||||
},
|
||||
interval: 'month',
|
||||
features: [
|
||||
`${FREE_DYNAMIC_QR_LIMIT} dynamische QR-Codes`,
|
||||
'Basis-Tracking (Scans + Standort)',
|
||||
'Einfache Designs',
|
||||
'Unbegrenzte statische QR-Codes',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: FREE_DYNAMIC_QR_LIMIT,
|
||||
staticQRCodes: -1, // unlimited
|
||||
teamMembers: 1,
|
||||
},
|
||||
priceId: null, // No Stripe price for free plan
|
||||
},
|
||||
PRO: {
|
||||
@@ -46,12 +51,12 @@ export const STRIPE_PLANS = {
|
||||
'Detailed Analytics (Date, Device, City)',
|
||||
'CSV Export',
|
||||
'SVG/PNG Download',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 50,
|
||||
staticQRCodes: -1,
|
||||
teamMembers: 1,
|
||||
},
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: PRO_DYNAMIC_QR_LIMIT,
|
||||
staticQRCodes: -1,
|
||||
teamMembers: 1,
|
||||
},
|
||||
priceId: process.env.STRIPE_PRICE_ID_PRO_MONTHLY,
|
||||
priceIdYearly: process.env.STRIPE_PRICE_ID_PRO_YEARLY,
|
||||
},
|
||||
@@ -66,12 +71,12 @@ export const STRIPE_PLANS = {
|
||||
'Everything from Pro',
|
||||
'Bulk QR Generation (up to 1,000)',
|
||||
'Priority Support',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 500,
|
||||
staticQRCodes: -1,
|
||||
teamMembers: 1,
|
||||
},
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: BUSINESS_DYNAMIC_QR_LIMIT,
|
||||
staticQRCodes: -1,
|
||||
teamMembers: 1,
|
||||
},
|
||||
priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY,
|
||||
priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY,
|
||||
},
|
||||
|
||||
@@ -115,12 +115,23 @@ export const resetPasswordSchema = z.object({
|
||||
// Settings Schemas
|
||||
// ==========================================
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
name: z.string()
|
||||
.min(2, 'Name must be at least 2 characters')
|
||||
.max(100, 'Name must be less than 100 characters')
|
||||
.trim(),
|
||||
});
|
||||
export const updateProfileSchema = z.object({
|
||||
name: z.string()
|
||||
.min(2, 'Name must be at least 2 characters')
|
||||
.max(100, 'Name must be less than 100 characters')
|
||||
.trim(),
|
||||
});
|
||||
|
||||
export const onboardingUpdateSchema = z.object({
|
||||
signupSourceSelfReported: z.string().max(100).optional(),
|
||||
primaryUseCase: z.string().max(100).optional(),
|
||||
primaryGoal: z.string().max(100).optional(),
|
||||
jobRole: z.string().max(100).optional(),
|
||||
companyName: z.string().max(200).optional(),
|
||||
companyWebsite: z.string().max(200).optional(),
|
||||
teamSizeBucket: z.string().max(100).optional(),
|
||||
markProfileComplete: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string()
|
||||
|
||||
Reference in New Issue
Block a user