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

47
src/lib/auth-flow.ts Normal file
View 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';
}

View File

@@ -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
View 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
View 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
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),
},
];
}

View File

@@ -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,
},

View File

@@ -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()