+): 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';
+}
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 0e091c4..ae297a0 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -905,7 +905,7 @@ export async function sendActivationNudgeEmail(email: string, name: string) {
|
- Your 3 free dynamic QR codes are still there. Unused.
+ Your 3 free dynamic QR codes are still there. Unused.
Dynamic means: one code, update the link anytime, every scan tracked.
- ${qrCount} of 3 free codes used,
+ ${qrCount} of 3 free codes used,
${firstName}.
diff --git a/src/lib/plans.ts b/src/lib/plans.ts
new file mode 100644
index 0000000..45e326c
--- /dev/null
+++ b/src/lib/plans.ts
@@ -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;
diff --git a/src/lib/revops-server.ts b/src/lib/revops-server.ts
new file mode 100644
index 0000000..d107834
--- /dev/null
+++ b/src/lib/revops-server.ts
@@ -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): 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,
+ });
+}
diff --git a/src/lib/revops.ts b/src/lib/revops.ts
new file mode 100644
index 0000000..ac189d2
--- /dev/null
+++ b/src/lib/revops.ts
@@ -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;
+} {
+ 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),
+ },
+ ];
+}
diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts
index 278253f..d272c4b 100644
--- a/src/lib/stripe.ts
+++ b/src/lib/stripe.ts
@@ -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,
},
diff --git a/src/lib/validationSchemas.ts b/src/lib/validationSchemas.ts
index 77e7017..c854a8a 100644
--- a/src/lib/validationSchemas.ts
+++ b/src/lib/validationSchemas.ts
@@ -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()
diff --git a/src/middleware.ts b/src/middleware.ts
index 9eff156..7edfb8c 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,22 +1,62 @@
-import { NextResponse } from 'next/server';
-import type { NextRequest } from 'next/server';
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import {
+ ATTRIBUTION_COOKIE_NAME,
+ buildAttributionSnapshot,
+ serializeAttributionCookie,
+} from '@/lib/revops';
+
+const isProduction = process.env.NODE_ENV === 'production';
+
+function attachAttributionCookie(req: NextRequest, response: NextResponse) {
+ if (req.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value) {
+ return response;
+ }
+
+ const path = req.nextUrl.pathname;
+
+ if (path.startsWith('/api/') || path.startsWith('/_next') || path.startsWith('/r/') || path.includes('.')) {
+ return response;
+ }
+
+ const snapshot = buildAttributionSnapshot({
+ utmSource: req.nextUrl.searchParams.get('utm_source'),
+ utmMedium: req.nextUrl.searchParams.get('utm_medium'),
+ utmCampaign: req.nextUrl.searchParams.get('utm_campaign'),
+ utmContent: req.nextUrl.searchParams.get('utm_content'),
+ utmTerm: req.nextUrl.searchParams.get('utm_term'),
+ referrer: req.headers.get('referer'),
+ landingPath: path,
+ firstSeenAt: new Date(),
+ });
+
+ response.cookies.set(ATTRIBUTION_COOKIE_NAME, serializeAttributionCookie(snapshot), {
+ httpOnly: false,
+ secure: isProduction,
+ sameSite: 'lax',
+ path: '/',
+ maxAge: 60 * 60 * 24 * 90,
+ });
+
+ return response;
+}
export function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
- // 301 Redirects for /guide -> /learn to avoid duplicate content and consolidate authority
- if (path === '/guide/tracking-analytics') {
- return NextResponse.redirect(new URL('/learn/tracking', req.url), 301);
- }
- if (path === '/guide/bulk-qr-code-generation') {
- return NextResponse.redirect(new URL('/learn/developer', req.url), 301);
- }
- if (path === '/guide/qr-code-best-practices') {
- return NextResponse.redirect(new URL('/learn/basics', req.url), 301);
- }
- if (path === '/create-qr') {
- return NextResponse.redirect(new URL('/dynamic-qr-code-generator', req.url), 301);
- }
+ // 301 Redirects for /guide -> /learn to avoid duplicate content and consolidate authority
+ if (path === '/guide/tracking-analytics') {
+ return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/tracking', req.url), 301));
+ }
+ if (path === '/guide/bulk-qr-code-generation') {
+ return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/developer', req.url), 301));
+ }
+ if (path === '/guide/qr-code-best-practices') {
+ return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/basics', req.url), 301));
+ }
+ if (path === '/create-qr') {
+ return attachAttributionCookie(req, NextResponse.redirect(new URL('/dynamic-qr-code-generator', req.url), 301));
+ }
// Public routes that don't require authentication
const publicPaths = [
@@ -68,38 +108,40 @@ export function middleware(req: NextRequest) {
// Check if path is public
const isPublicPath = publicPaths.some(p => path === p || path.startsWith(p + '/'));
- // Allow API routes
- if (path.startsWith('/api/')) {
- return NextResponse.next();
- }
+ // Allow API routes
+ if (path.startsWith('/api/')) {
+ return attachAttributionCookie(req, NextResponse.next());
+ }
// Allow redirect routes (QR code redirects)
- if (path.startsWith('/r/')) {
- return NextResponse.next();
- }
+ if (path.startsWith('/r/')) {
+ return attachAttributionCookie(req, NextResponse.next());
+ }
// Allow static files
- if (path.includes('.') || path.startsWith('/_next')) {
- return NextResponse.next();
- }
+ if (path.includes('.') || path.startsWith('/_next')) {
+ return attachAttributionCookie(req, NextResponse.next());
+ }
// Allow public paths
- if (isPublicPath) {
- return NextResponse.next();
- }
+ if (isPublicPath) {
+ return attachAttributionCookie(req, NextResponse.next());
+ }
// For protected routes, check for userId cookie
const userId = req.cookies.get('userId')?.value;
- if (!userId) {
- // Not authenticated - redirect to signup
- const signupUrl = new URL('/signup', req.url);
- return NextResponse.redirect(signupUrl);
- }
-
- // Authenticated - allow access
- return NextResponse.next();
-}
+ if (!userId) {
+ // Not authenticated - redirect to signup
+ const signupUrl = new URL('/signup', req.url);
+ const redirectTarget = `${path}${req.nextUrl.search}`;
+ signupUrl.searchParams.set('redirect', redirectTarget);
+ return attachAttributionCookie(req, NextResponse.redirect(signupUrl));
+ }
+
+ // Authenticated - allow access
+ return attachAttributionCookie(req, NextResponse.next());
+}
export const config = {
matcher: [
|