diff --git a/output/imagegen/onboarding-mockup-v1.png b/output/imagegen/onboarding-mockup-v1.png new file mode 100644 index 0000000..9311227 Binary files /dev/null and b/output/imagegen/onboarding-mockup-v1.png differ diff --git a/output/paused-qr-preview/index.html b/output/paused-qr-preview/index.html new file mode 100644 index 0000000..09b8511 --- /dev/null +++ b/output/paused-qr-preview/index.html @@ -0,0 +1,776 @@ + + + + + + QR Code is paused + + + + +
+
+
+
+ + QR Master +
+
+ + Scanning temporarily unavailable +
+
+ +
+
+
+ +
+ Status + Paused by owner +
+
+ +

QR Code is paused

+ +

+ This QR code has been temporarily disabled by its owner, so scanning is currently unavailable. +

+ +

+ Please try again later or contact the owner for the active link. Paused codes should feel intentional and trustworthy, not broken. +

+ + + +
+
+ Redirect + Temporarily disabled +

No destination opens while this code remains paused.

+
+
+ Tracking + Scan logging stopped +

Paused scans should not continue into analytics.

+
+
+ + +
+ +
+ + +
+ Glass-like QR tile floating in a cinematic studio environment +
+
+ Paused + + The scan was intentionally interrupted +
+
+
+
+
+ + + + diff --git a/output/paused-qr-preview/paused-qr-hero-cinematic.png b/output/paused-qr-preview/paused-qr-hero-cinematic.png new file mode 100644 index 0000000..16359fb Binary files /dev/null and b/output/paused-qr-preview/paused-qr-hero-cinematic.png differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9abbad7..ffe950a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,11 +11,11 @@ datasource db { url = env("DATABASE_URL") } -model User { - id String @id @default(cuid()) - email String @unique - name String? - password String? +model User { + id String @id @default(cuid()) + email String @unique + name String? + password String? image String? emailVerified DateTime? createdAt DateTime @default(now()) @@ -32,16 +32,56 @@ model User { resetPasswordToken String? @unique resetPasswordExpires DateTime? - // Retention email tracking - activationNudgeSentAt DateTime? - upgradeNudgeSentAt DateTime? - thirtyDayNudgeSentAt DateTime? - - qrCodes QRCode[] - integrations Integration[] - accounts Account[] - sessions Session[] -} + // Retention email tracking + activationNudgeSentAt DateTime? + upgradeNudgeSentAt DateTime? + thirtyDayNudgeSentAt DateTime? + + // RevOps attribution + signupSource String? + signupSourceSelfReported String? + signupMedium String? + signupCampaign String? + signupContent String? + signupTerm String? + signupReferrer String? + signupLandingPath String? + signupFirstSeenAt DateTime? + emailDomain String? + + // Onboarding and qualification + primaryUseCase String? + primaryGoal String? + jobRole String? + companyName String? + companyWebsite String? + teamSizeBucket String? + onboardingStartedAt DateTime? + sourceConfirmedAt DateTime? + useCaseSelectedAt DateTime? + goalSelectedAt DateTime? + profileCompletedAt DateTime? + firstQrCreatedAt DateTime? + firstDynamicQrAt DateTime? + firstStaticQrAt DateTime? + firstScanAt DateTime? + activationAt DateTime? + onboardingCompletedAt DateTime? + + // RevOps scoring + fitScore Int @default(0) + intentScore Int @default(0) + leadScore Int @default(0) + lifecycleStage String @default("cold") + lastQualifiedAt DateTime? + lastScoredAt DateTime? + + qrCodes QRCode[] + integrations Integration[] + accounts Account[] + sessions Session[] + lifecycleLogs UserLifecycleLog[] +} enum Plan { FREE @@ -149,7 +189,7 @@ model QRScan { @@index([qrId, ts]) } -model Integration { +model Integration { id String @id @default(cuid()) userId String provider String @@ -158,8 +198,22 @@ model Integration { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) -} + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model UserLifecycleLog { + id String @id @default(cuid()) + userId String + fromStage String? + toStage String + fitScore Int @default(0) + intentScore Int @default(0) + leadScore Int @default(0) + reason String? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} model NewsletterSubscription { id String @id @default(cuid()) diff --git a/public/Screenshot 2026-04-22 123347.png b/public/Screenshot 2026-04-22 123347.png new file mode 100644 index 0000000..f0a0b2a Binary files /dev/null and b/public/Screenshot 2026-04-22 123347.png differ diff --git a/public/marketing/qrmaster-hero-generated-v1.png b/public/marketing/qrmaster-hero-generated-v1.png new file mode 100644 index 0000000..4e4b5bd Binary files /dev/null and b/public/marketing/qrmaster-hero-generated-v1.png differ diff --git a/src/app/(main)/(app)/create/page.tsx b/src/app/(main)/(app)/create/page.tsx index dc3e9a8..fed5ef4 100644 --- a/src/app/(main)/(app)/create/page.tsx +++ b/src/app/(main)/(app)/create/page.tsx @@ -1,8 +1,8 @@ 'use client'; -import React, { useState, useEffect, useRef } from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import React, { useState, useEffect, useRef } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; import { QRCodeSVG } from 'qrcode.react'; import { toPng } from 'html-to-image'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; @@ -11,12 +11,14 @@ import { Select } from '@/components/ui/Select'; import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Badge'; import { calculateContrast, cn } from '@/lib/utils'; -import { useTranslation } from '@/hooks/useTranslation'; -import { useCsrf } from '@/hooks/useCsrf'; -import { showToast } from '@/components/ui/Toast'; -import { - Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon -} from 'lucide-react'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useCsrf } from '@/hooks/useCsrf'; +import { showToast } from '@/components/ui/Toast'; +import { trackEvent } from '@/components/PostHogProvider'; +import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow'; +import { + Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon +} from 'lucide-react'; import Barcode from 'react-barcode'; // Tooltip component for form field help @@ -99,9 +101,10 @@ function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string return new XMLSerializer().serializeToString(cloned); } -export default function CreatePage() { - const router = useRouter(); - const { t } = useTranslation(); +export default function CreatePage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { t } = useTranslation(); const { fetchWithCsrf } = useCsrf(); const [loading, setLoading] = useState(false); const [uploading, setUploading] = useState(false); @@ -145,14 +148,14 @@ export default function CreatePage() { const [excavate, setExcavate] = useState(true); // QR preview - const [qrDataUrl, setQrDataUrl] = useState(''); + const [qrDataUrl, setQrDataUrl] = useState(''); // Check if user can customize colors (PRO+ only) const canCustomizeColors = userPlan === 'PRO' || userPlan === 'BUSINESS'; // Load user plan - useEffect(() => { - const fetchUserPlan = async () => { + useEffect(() => { + const fetchUserPlan = async () => { try { const response = await fetch('/api/user/plan'); if (response.ok) { @@ -163,8 +166,44 @@ export default function CreatePage() { console.error('Error fetching user plan:', error); } }; - fetchUserPlan(); - }, []); + fetchUserPlan(); + }, []); + + useEffect(() => { + const queryContentType = searchParams.get('contentType'); + const useCase = searchParams.get('useCase'); + const titleParam = searchParams.get('title'); + const isDynamicParam = searchParams.get('dynamic'); + + if (queryContentType) { + setContentType(queryContentType); + } + + if (titleParam) { + setTitle(titleParam); + } + + if (isDynamicParam) { + setIsDynamic(isDynamicParam === '1'); + } + + if (useCase === 'menu_pdf') { + setContent((prev: any) => ({ ...prev, fileUrl: prev.fileUrl || '' })); + } else if (useCase === 'contact_card') { + setContent((prev: any) => ({ + ...prev, + firstName: prev.firstName || '', + lastName: prev.lastName || '', + })); + } else if (useCase === 'barcode') { + setContent((prev: any) => ({ + ...prev, + format: prev.format || 'CODE128', + })); + } else if (queryContentType === 'URL') { + setContent((prev: any) => ({ ...prev, url: prev.url || '' })); + } + }, [searchParams]); const contrast = calculateContrast(foregroundColor, backgroundColor); const hasGoodContrast = contrast >= 4.5; @@ -226,13 +265,19 @@ export default function CreatePage() { const downloadQR = async (format: 'svg' | 'png') => { if (!qrRef.current) return; try { - if (format === 'png') { - const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); - const link = document.createElement('a'); - link.download = `qrcode-${title || 'download'}.png`; - link.href = dataUrl; - link.click(); - } else { + if (format === 'png') { + const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); + const link = document.createElement('a'); + link.download = `qrcode-${title || 'download'}.png`; + link.href = dataUrl; + link.click(); + trackEvent('qr_code_downloaded', { + format: 'png', + content_type: contentType, + qr_type: isDynamic ? 'dynamic' : 'static', + plan: userPlan, + }); + } else { // For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed // Simplest is to check if we can export the SVG element directly but that misses the frame HTML. // html-to-image can generate SVG too. @@ -254,21 +299,34 @@ export default function CreatePage() { : new XMLSerializer().serializeToString(svgElement); const blob = new Blob([svgData], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `qrcode-${title || 'download'}.svg`; - a.click(); - URL.revokeObjectURL(url); - } - } else { - showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info'); - const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); - const link = document.createElement('a'); - link.download = `qrcode-${title || 'download'}.png`; - link.href = dataUrl; - link.click(); - } - } + const a = document.createElement('a'); + a.href = url; + a.download = `qrcode-${title || 'download'}.svg`; + a.click(); + URL.revokeObjectURL(url); + trackEvent('qr_code_downloaded', { + format: 'svg', + content_type: contentType, + qr_type: isDynamic ? 'dynamic' : 'static', + plan: userPlan, + }); + } + } else { + showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info'); + const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); + const link = document.createElement('a'); + link.download = `qrcode-${title || 'download'}.png`; + link.href = dataUrl; + link.click(); + trackEvent('qr_code_downloaded', { + format: 'png', + content_type: contentType, + qr_type: isDynamic ? 'dynamic' : 'static', + plan: userPlan, + fallback_from: 'svg_with_frame', + }); + } + } } catch (err) { console.error('Error downloading QR code:', err); showToast('Error downloading QR code', 'error'); @@ -354,18 +412,38 @@ export default function CreatePage() { const responseData = await response.json(); console.log('RESPONSE DATA:', responseData); - if (response.ok) { - showToast(`QR Code "${title}" created successfully!`, 'success'); - - // Wait a moment so user sees the toast, then redirect - setTimeout(() => { - router.push('/dashboard'); - router.refresh(); - }, 1000); - } else { - console.error('Error creating QR code:', responseData); - showToast(responseData.error || 'Error creating QR code', 'error'); - } + if (response.ok) { + trackEvent('qr_code_created', { + content_type: contentType, + qr_type: isDynamic ? 'dynamic' : 'static', + plan: userPlan, + has_logo: Boolean(logoUrl), + frame_type: frameType, + }); + + showToast(`QR Code "${title}" created successfully!`, 'success'); + + // Wait a moment so user sees the toast, then redirect + setTimeout(() => { + const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect')); + if (searchParams.get('onboarding') === '1') { + router.push(appendRedirectParam('/onboarding', redirectTarget, { step: '8' })); + } else { + router.push('/dashboard'); + } + router.refresh(); + }, 1000); + } else { + console.error('Error creating QR code:', responseData); + + if (response.status === 403 && responseData.error === 'Limit reached') { + showToast(responseData.message || 'You have reached your plan limit.', 'error'); + router.push('/pricing?reason=limit_reached'); + return; + } + + showToast(responseData.error || 'Error creating QR code', 'error'); + } } catch (error) { console.error('Error creating QR code:', error); showToast('Error creating QR code. Please try again.', 'error'); @@ -1180,4 +1258,4 @@ export default function CreatePage() { ); -} \ No newline at end of file +} diff --git a/src/app/(main)/(app)/dashboard/page.tsx b/src/app/(main)/(app)/dashboard/page.tsx index b859380..5f17f2b 100644 --- a/src/app/(main)/(app)/dashboard/page.tsx +++ b/src/app/(main)/(app)/dashboard/page.tsx @@ -7,12 +7,15 @@ import { StatsGrid } from '@/components/dashboard/StatsGrid'; import { QRCodeCard } from '@/components/dashboard/QRCodeCard'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; -import { Badge } from '@/components/ui/Badge'; -import { useTranslation } from '@/hooks/useTranslation'; -import { useCsrf } from '@/hooks/useCsrf'; -import { showToast } from '@/components/ui/Toast'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog'; -import { QrCode } from 'lucide-react'; +import { Badge } from '@/components/ui/Badge'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useCsrf } from '@/hooks/useCsrf'; +import { showToast } from '@/components/ui/Toast'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog'; +import { QrCode } from 'lucide-react'; +import { trackEvent, identifyUser } from '@/components/PostHogProvider'; +import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans'; +import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist'; interface QRCodeData { id: string; @@ -44,7 +47,8 @@ export default function DashboardPage() { conversionRate: 0, uniqueScans: 0, }); - const [analyticsData, setAnalyticsData] = useState(null); + const [analyticsData, setAnalyticsData] = useState(null); + const [onboardingState, setOnboardingState] = useState(null); const blogPosts = [ @@ -117,12 +121,11 @@ export default function DashboardPage() { // Store in localStorage for consistency localStorage.setItem('user', JSON.stringify(user)); - const { identifyUser, trackEvent } = await import('@/components/PostHogProvider'); - identifyUser(user.id, { - email: user.email, - name: user.name, - plan: user.plan || 'FREE', - provider: 'google', + identifyUser(user.id, { + email: user.email, + name: user.name, + plan: user.plan || 'FREE', + provider: 'google', }); trackEvent(isNewUser ? 'user_signup' : 'user_login', { @@ -143,25 +146,35 @@ export default function DashboardPage() { }, [searchParams, router]); // Check for successful payment and verify session - useEffect(() => { - const success = searchParams.get('success'); - if (success === 'true') { - const verifySession = async () => { - try { - const response = await fetch('/api/stripe/verify-session', { - method: 'POST', - }); - - if (response.ok) { - const data = await response.json(); - setUserPlan(data.plan); - setUpgradedPlan(data.plan); - setShowUpgradeDialog(true); - // Remove success parameter from URL - router.replace('/dashboard'); - } else { - console.error('Failed to verify session:', await response.text()); - } + useEffect(() => { + const success = searchParams.get('success'); + const sessionId = searchParams.get('session_id'); + + if (success === 'true' && sessionId) { + const verifySession = async () => { + try { + const response = await fetch('/api/stripe/verify-session', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ sessionId }), + }); + + if (response.ok) { + const data = await response.json(); + setUserPlan(data.plan); + setUpgradedPlan(data.plan); + setShowUpgradeDialog(true); + trackEvent('upgrade_completed', { + plan: data.plan, + source: 'stripe_checkout', + }); + // Remove success parameter from URL + router.replace('/dashboard'); + } else { + console.error('Failed to verify session:', await response.text()); + } } catch (error) { console.error('Error verifying session:', error); } @@ -212,13 +225,19 @@ export default function DashboardPage() { setUserPlan(userData.plan || 'FREE'); } - // Fetch analytics data for trends (last 30 days = month comparison) - const analyticsResponse = await fetch('/api/analytics/summary?range=30'); - if (analyticsResponse.ok) { - const analytics = await analyticsResponse.json(); - setAnalyticsData(analytics); - } - } catch (error) { + // Fetch analytics data for trends (last 30 days = month comparison) + const analyticsResponse = await fetch('/api/analytics/summary?range=30'); + if (analyticsResponse.ok) { + const analytics = await analyticsResponse.json(); + setAnalyticsData(analytics); + } + + const onboardingResponse = await fetch('/api/onboarding'); + if (onboardingResponse.ok) { + const onboardingData = await onboardingResponse.json(); + setOnboardingState(onboardingData); + } + } catch (error) { console.error('Error fetching data:', error); setQrCodes([]); setStats({ @@ -341,9 +360,11 @@ export default function DashboardPage() { - {/* Stats Grid */} - + +

Create your first QR code

- You have 3 free dynamic QR codes. They redirect wherever you want and track every scan. -

+ You have {FREE_DYNAMIC_QR_LIMIT} free dynamic QR codes. They redirect wherever you want and track every scan. +

@@ -521,4 +542,4 @@ export default function DashboardPage() { ); -} \ No newline at end of file +} diff --git a/src/app/(main)/(auth)/login/LoginClient.tsx b/src/app/(main)/(auth)/login/LoginClient.tsx index dd146ee..9a05857 100644 --- a/src/app/(main)/(auth)/login/LoginClient.tsx +++ b/src/app/(main)/(auth)/login/LoginClient.tsx @@ -5,9 +5,10 @@ import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { Card, CardContent } from '@/components/ui/Card'; import { Input } from '@/components/ui/Input'; -import { Button } from '@/components/ui/Button'; -import { useTranslation } from '@/hooks/useTranslation'; -import { useCsrf } from '@/hooks/useCsrf'; +import { Button } from '@/components/ui/Button'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useCsrf } from '@/hooks/useCsrf'; +import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow'; type LoginClientProps = { showPageHeading?: boolean; @@ -20,9 +21,10 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps const { fetchWithCsrf, loading: csrfLoading } = useCsrf(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect')); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -57,10 +59,12 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps console.error('PostHog tracking error:', error); } - // Check for redirect parameter - const redirectUrl = searchParams.get('redirect') || '/dashboard'; - router.push(redirectUrl); - router.refresh(); + // Check for redirect parameter + const redirectUrl = data.needsOnboarding + ? appendRedirectParam('/onboarding', redirectTarget) + : (redirectTarget || '/dashboard'); + router.push(redirectUrl); + router.refresh(); } else { setError(data.error || 'Invalid email or password'); } @@ -71,10 +75,10 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps } }; - const handleGoogleSignIn = () => { - // Redirect to Google OAuth API route - window.location.href = '/api/auth/google'; - }; + const handleGoogleSignIn = () => { + // Redirect to Google OAuth API route + window.location.href = appendRedirectParam('/api/auth/google', redirectTarget); + }; return (
@@ -199,9 +203,9 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps

Don't have an account?{' '} - - Sign up - + + Sign up +

diff --git a/src/app/(main)/(auth)/signup/SignupClient.tsx b/src/app/(main)/(auth)/signup/SignupClient.tsx index 286a67d..a39fc2a 100644 --- a/src/app/(main)/(auth)/signup/SignupClient.tsx +++ b/src/app/(main)/(auth)/signup/SignupClient.tsx @@ -1,26 +1,29 @@ 'use client'; -import React, { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; -import { Input } from '@/components/ui/Input'; -import { Button } from '@/components/ui/Button'; -import { useTranslation } from '@/hooks/useTranslation'; -import { useCsrf } from '@/hooks/useCsrf'; - -export default function SignupClient() { - const router = useRouter(); - const { t } = useTranslation(); - const { fetchWithCsrf } = useCsrf(); +import React, { useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useCsrf } from '@/hooks/useCsrf'; +import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow'; + +export default function SignupClient() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { t } = useTranslation(); + const { fetchWithCsrf } = useCsrf(); const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [error, setError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect')); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -68,9 +71,9 @@ export default function SignupClient() { console.error('PostHog tracking error:', error); } - // Redirect to dashboard - router.push('/dashboard'); - router.refresh(); + // Redirect to onboarding + router.push(appendRedirectParam('/onboarding', redirectTarget)); + router.refresh(); } else { setError(data.error || 'Failed to create account'); } @@ -81,10 +84,10 @@ export default function SignupClient() { } }; - const handleGoogleSignIn = () => { - // Redirect to Google OAuth API route - window.location.href = '/api/auth/google'; - }; + const handleGoogleSignIn = () => { + // Redirect to Google OAuth API route + window.location.href = appendRedirectParam('/api/auth/google', redirectTarget); + }; return (
@@ -234,11 +237,11 @@ export default function SignupClient() {
-

- Already have an account?{' '} - - Sign in - +

+ Already have an account?{' '} + + Sign in +

diff --git a/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx b/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx index 528a880..a2deae5 100644 --- a/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx +++ b/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx @@ -1,754 +1,829 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { Card } from '@/components/ui/Card'; -import { Button } from '@/components/ui/Button'; -import { Badge } from '@/components/ui/Badge'; -import { - Mail, - Users, - QrCode, - BarChart3, - TrendingUp, - Crown, - Activity, - Loader2, - Lock, - LogOut, - Zap, - Send, - CheckCircle2, - FileDown, - DollarSign, -} from 'lucide-react'; - -interface AdminStats { - users: { - total: number; - premium: number; - newThisWeek: number; - newThisMonth: number; - recent: Array<{ - email: string; - name: string | null; - plan: string; - createdAt: string; - }>; - }; - qrCodes: { - total: number; - dynamic: number; - static: number; - active: number; - }; - scans: { - total: number; - dynamicOnly: number; - avgPerDynamicQR: string; - }; - newsletter: { - subscribers: number; - }; - topQRCodes: Array<{ - id: string; - title: string; - type: string; - scans: number; - owner: string; - createdAt: string; - }>; -} - -export default function NewsletterClient() { - const router = useRouter(); - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [isAuthenticating, setIsAuthenticating] = useState(true); - const [loginError, setLoginError] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - - // Newsletter management state - const [newsletterData, setNewsletterData] = useState<{ - total: number; - recent: Array<{ email: string; createdAt: string }>; - } | null>(null); - const [sendingBroadcast, setSendingBroadcast] = useState(false); - const [broadcastResult, setBroadcastResult] = useState<{ - success: boolean; - message: string; - } | null>(null); - - // Lead management state - const [leadData, setLeadData] = useState<{ - total: number; - recent: Array<{ - id: string; - email: string; - source: string; - reprintCost: number | null; - updatesPerYear: number | null; - annualSavings: number | null; - createdAt: string; - }>; - } | null>(null); - - useEffect(() => { - checkAuth(); - }, []); - - const checkAuth = async () => { - try { - const response = await fetch('/api/admin/stats'); - if (response.ok) { - setIsAuthenticated(true); - const data = await response.json(); - setStats(data); - setLoading(false); - // Also fetch newsletter and lead data - fetchNewsletterData(); - fetchLeadsData(); - } else { - setIsAuthenticated(false); - } - } catch (error) { - setIsAuthenticated(false); - } finally { - setIsAuthenticating(false); - } - }; - - const fetchNewsletterData = async () => { - try { - const response = await fetch('/api/newsletter/broadcast'); - if (response.ok) { - const data = await response.json(); - setNewsletterData(data); - } - } catch (error) { - console.error('Failed to fetch newsletter data:', error); - } - }; - - const fetchLeadsData = async () => { - try { - const response = await fetch('/api/leads'); - if (response.ok) { - const data = await response.json(); - setLeadData(data); - } - } catch (error) { - console.error('Failed to fetch leads data:', error); - } - }; - - const handleSendBroadcast = async () => { - if (!confirm(`Are you sure you want to send the AI Feature Launch email to all ${newsletterData?.total || 0} subscribers?`)) { - return; - } - - setSendingBroadcast(true); - setBroadcastResult(null); - - try { - const response = await fetch('/api/newsletter/broadcast', { - method: 'POST', - }); - - const data = await response.json(); - - if (response.ok) { - setBroadcastResult({ - success: true, - message: data.message || `Successfully sent to ${data.sent} subscribers!`, - }); - } else { - setBroadcastResult({ - success: false, - message: data.error || 'Failed to send broadcast', - }); - } - } catch (error) { - setBroadcastResult({ - success: false, - message: 'Network error. Please try again.', - }); - } finally { - setSendingBroadcast(false); - } - }; - - const handleLogin = async (e: React.FormEvent) => { - e.preventDefault(); - setLoginError(''); - setIsAuthenticating(true); - - try { - const response = await fetch('/api/newsletter/admin-login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email, password }), - }); - - if (response.ok) { - setIsAuthenticated(true); - await checkAuth(); - } else { - const data = await response.json(); - setLoginError(data.error || 'Invalid credentials'); - } - } catch (error) { - setLoginError('Login failed. Please try again.'); - } finally { - setIsAuthenticating(false); - } - }; - - const handleLogout = async () => { - await fetch('/api/auth/logout', { method: 'POST' }); - router.push('/'); - }; - - // Login Screen - if (!isAuthenticated) { - return ( -
- -
-
- -
-

Admin Dashboard

-

- Sign in to access admin panel -

- - ← Back to Home - -
- -
-
- - setEmail(e.target.value)} - placeholder="admin@example.com" - required - className="w-full px-4 py-3 rounded-xl bg-background border border-border focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 outline-none transition-all" - /> -
- -
- - setPassword(e.target.value)} - placeholder="••••••••" - required - className="w-full px-4 py-3 rounded-xl bg-background border border-border focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 outline-none transition-all" - /> -
- - {loginError && ( -

{loginError}

- )} - - -
- -
-

- Admin credentials required -

-
-
-
- ); - } - - // Loading - if (loading) { - return ( -
- -
- ); - } - - // Admin Dashboard - return ( -
-
- {/* Header */} -
-
-

Admin Dashboard

-

- Platform overview and statistics -

-
- -
- - {/* Main Stats Grid */} -
- {/* All Time Users */} - -
-
- -
- - All Time - -
-

{stats?.users.total || 0}

-

Total Users

-
-
- This Month - - +{stats?.users.newThisMonth || 0} - -
-
- This Week - - +{stats?.users.newThisWeek || 0} - -
-
-
- - {/* Dynamic QR Codes */} - -
-
- -
- - Dynamic - -
-

{stats?.qrCodes.dynamic || 0}

-

Dynamic QR Codes

-
- Static - {stats?.qrCodes.static || 0} -
-
- - {/* Total Scans */} - -
-
- -
- - All Time - -
-

- {stats?.scans.dynamicOnly.toLocaleString() || 0} -

-

Dynamic QR Scans

-
- Avg per QR - {stats?.scans.avgPerDynamicQR || 0} -
-
- - {/* Total QR Codes */} - -
-
- -
- - All Time - -
-

{stats?.qrCodes.total || 0}

-

Total QR Codes

-
-
- Dynamic - {stats?.qrCodes.dynamic || 0} -
-
- Static - {stats?.qrCodes.static || 0} -
-
-
-
- - {/* Secondary Stats Row */} -
- {/* Total All Scans */} - -
-
- -
-
-

- {stats?.scans.total.toLocaleString() || 0} -

-

Total All Scans

-
-
-
- - {/* Total QR Codes */} - -
-
- -
-
-

{stats?.qrCodes.total || 0}

-

Total QR Codes

-
-
-
- - {/* Premium Users */} - -
-
- -
-
-

{stats?.users.premium || 0}

-

Premium Users

-
-
-
-
- - {/* Bottom Grid */} -
- {/* Top QR Codes */} - -
-
- -
-
-

Top QR Codes

-

Most scanned

-
-
- - {stats?.topQRCodes && stats.topQRCodes.length > 0 ? ( -
- {stats.topQRCodes.map((qr, index) => ( -
-
-
- - #{index + 1} - -
-
-

{qr.title}

-

- {qr.owner} -

-
-
-
-

{qr.scans.toLocaleString()}

-

scans

-
-
- ))} -
- ) : ( -

No QR codes yet

- )} -
- - {/* Recent Users */} - -
-
- -
-
-

Recent Users

-

Latest signups

-
-
- - {stats?.users.recent && stats.users.recent.length > 0 ? ( -
- {stats.users.recent.map((user, index) => ( -
-
-
- - {(user.name || user.email).charAt(0).toUpperCase()} - -
-
-

- {user.name || user.email} -

-

- {new Date(user.createdAt).toLocaleDateString()} -

-
-
- - {user.plan === 'PRO' && } - {user.plan} - -
- ))} -
- ) : ( -

No users yet

- )} -
-
- - {/* Newsletter Management Section */} -
- -
-
- -
-
-

Newsletter Management

-

Manage AI feature launch notifications

-
-
- {newsletterData?.total || 0} -

Total Subscribers

-
- - Active - -
- - {/* Broadcast Section */} -
-
- -
-

Broadcast AI Feature Launch

-

- Send the AI feature launch announcement to all {newsletterData?.total || 0} subscribers. - This will inform them that the features are now available. -

-
-
- - {/* Resend Free Tier Warning */} - {(newsletterData?.total || 0) > 100 && ( -
- -
- Warning: Resend Free Limit -

You have more than 100 subscribers. The Resend Free Tier only allows 100 emails per day. Sending this broadcast might fail for some users or block your account.

-
-
- )} - - {broadcastResult && ( -
- {broadcastResult.success && } - {broadcastResult.message} -
- )} - - -
- - {/* Recent Subscribers */} -
-

Recent Subscribers

- {newsletterData?.recent && newsletterData.recent.length > 0 ? ( -
- {newsletterData.recent.map((subscriber, index) => ( -
-
- - {subscriber.email} -
- - {new Date(subscriber.createdAt).toLocaleDateString()} - -
- ))} -
- ) : ( -

No subscribers yet

- )} -
- - {/* Tip */} -
-

- 💡 Tip: View all subscribers in{' '} - - Prisma Studio - - {' '}(NewsletterSubscription table) -

-
-
-
- - {/* Lead Management Section */} -
- -
-
- -
-
-

Lead Management

-

Reprint Calculator PDF downloads

-
-
- {leadData?.total || 0} -

Total Leads

-
- - Active - -
- - {/* Recent Leads */} -
-

Recent Leads

- {leadData?.recent && leadData.recent.length > 0 ? ( -
- {leadData.recent.map((lead) => ( -
-
- -
- {lead.email} - {lead.annualSavings && ( - - - €{lead.annualSavings.toLocaleString()} potential savings - - )} -
-
-
- - {new Date(lead.createdAt).toLocaleDateString()} - - {lead.reprintCost && lead.updatesPerYear && ( - - €{lead.reprintCost} × {lead.updatesPerYear}/yr - - )} -
-
- ))} -
- ) : ( -

No leads yet. Leads appear when users download a PDF report from the Reprint Calculator.

- )} -
- - {/* Tip */} -
-

- 💡 Tip: View all leads in{' '} - - Prisma Studio - - {' '}(Lead table) -

-
-
-
-
-
- ); -} +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; +import { Input } from '@/components/ui/Input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/Table'; +import { + BarChart3, + Copy, + Download, + Filter, + Loader2, + Lock, + LogOut, + Mail, + Search, + TrendingUp, + Users, +} from 'lucide-react'; +import { + getGoalLabel, + getRoleLabel, + getSourceLabel, + getTeamSizeLabel, + getUseCaseLabel, +} from '@/lib/revops'; + +type SegmentRow = { + id: string; + name: string | null; + email: string; + emailDomain: string | null; + plan: string; + lifecycleStage: string; + lifecycleStageLabel: string; + fitScore: number; + intentScore: number; + leadScore: number; + signupSource: string | null; + signupSourceLabel: string; + signupSourceSelfReported: string | null; + signupSourceSelfReportedLabel: string; + signupCampaign: string | null; + signupLandingPath: string | null; + primaryUseCase: string | null; + primaryUseCaseLabel: string; + primaryGoal: string | null; + primaryGoalLabel: string; + jobRole: string | null; + jobRoleLabel: string; + companyName: string | null; + teamSizeBucket: string | null; + teamSizeLabel: string; + upgradeBadges: string[]; + createdAt: string; + firstQrCreatedAt: string | null; + activationAt: string | null; + qrCount: number; + dynamicQrCount: number; + scanCount: number; +}; + +type DashboardData = { + overview: { + totalUsers: number; + mismatchCount: number; + activatedUsers: number; + paidUsers: number; + }; + acquisition: { + bySource: Array<{ + key: string; + label: string; + signups: number; + firstQr: number; + activated: number; + hot: number; + upgradeCandidates: number; + paid: number; + activationRate: number; + }>; + byCampaign: Array<{ + key: string; + signups: number; + activated: number; + paid: number; + }>; + byLandingPath: Array<{ + key: string; + signups: number; + activated: number; + paid: number; + }>; + }; + funnel: { + signup: number; + sourceConfirmed: number; + useCaseSelected: number; + goalSelected: number; + profileCaptured: number; + firstQrCreated: number; + firstDynamicQrCreated: number; + activated: number; + }; + funnelBreakdowns: { + bySource: Array; + byUseCase: Array; + byRole: Array; + byTeamSize: Array; + }; + lifecycleSummary: Record; + campaignSourceQuality: Array; + upgradeCandidates: Array; + filterOptions: { + stages: string[]; + sources: string[]; + campaigns: string[]; + landingPaths: string[]; + useCases: string[]; + goals: string[]; + roles: string[]; + teamSizes: string[]; + plans: string[]; + }; + segments: { + total: number; + page: number; + pageSize: number; + totalPages: number; + rows: SegmentRow[]; + }; +}; + +type Filters = { + stage: string; + source: string; + campaign: string; + landingPath: string; + useCase: string; + goal: string; + role: string; + teamSize: string; + plan: string; + search: string; + sort: string; + page: number; +}; + +const defaultFilters: Filters = { + stage: '', + source: '', + campaign: '', + landingPath: '', + useCase: '', + goal: '', + role: '', + teamSize: '', + plan: '', + search: '', + sort: 'leadScore_desc', + page: 1, +}; + +function buildQuery(filters: Filters) { + const params = new URLSearchParams(); + + Object.entries(filters).forEach(([key, value]) => { + if (value) { + params.set(key, String(value)); + } + }); + + params.set('pageSize', '25'); + return params.toString(); +} + +function LifecycleCard({ + label, + value, + active, + onClick, +}: { + label: string; + value: number; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function BreakdownTable({ + title, + rows, +}: { + title: string; + rows: Array<{ label?: string; key: string; signups: number; activated: number; paid: number }>; +}) { + return ( + + + {title} + + + + + + Segment + Signups + Activated + Paid + + + + {rows.slice(0, 8).map((row) => ( + + {row.label || row.key} + {row.signups} + {row.activated} + {row.paid} + + ))} + +
+
+
+ ); +} + +export default function NewsletterClient() { + const router = useRouter(); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isAuthenticating, setIsAuthenticating] = useState(true); + const [loginError, setLoginError] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [filters, setFilters] = useState(defaultFilters); + + const queryString = useMemo(() => buildQuery(filters), [filters]); + + const fetchDashboard = async (query: string) => { + setLoading(true); + try { + const response = await fetch(`/api/admin/revops?${query}`); + if (!response.ok) { + if (response.status === 401) { + setIsAuthenticated(false); + return; + } + throw new Error('Failed to load dashboard'); + } + + const payload = await response.json(); + setData(payload); + setIsAuthenticated(true); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + setIsAuthenticating(false); + } + }; + + useEffect(() => { + fetchDashboard(queryString); + }, [queryString]); + + const handleLogin = async (event: React.FormEvent) => { + event.preventDefault(); + setLoginError(''); + setIsAuthenticating(true); + + try { + const response = await fetch('/api/newsletter/admin-login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + const body = await response.json(); + setLoginError(body.error || 'Invalid credentials'); + setIsAuthenticating(false); + return; + } + + await fetchDashboard(queryString); + } catch (error) { + setLoginError('Login failed. Please try again.'); + setIsAuthenticating(false); + } + }; + + const handleLogout = async () => { + await fetch('/api/auth/logout', { method: 'POST' }); + setIsAuthenticated(false); + setData(null); + router.refresh(); + }; + + const updateFilter = (key: keyof Filters, value: string | number) => { + setFilters((current) => ({ + ...current, + [key]: value, + page: key === 'page' ? Number(value) : 1, + })); + }; + + const resetFilters = () => setFilters(defaultFilters); + + const copyEmails = async () => { + if (!data?.segments.rows.length) return; + const text = data.segments.rows.map((row) => row.email).join(', '); + await navigator.clipboard.writeText(text); + }; + + const downloadCsv = () => { + window.location.href = `/api/admin/revops?${queryString}&format=csv`; + }; + + if (!isAuthenticated) { + return ( +
+
+ + +
+ +
+ QR Master Ops Cockpit +

Internal access only.

+
+ +
+ setEmail(event.target.value)} + placeholder="support@qrmaster.net" + required + /> + setPassword(event.target.value)} + placeholder="••••••••" + required + /> + {loginError &&

{loginError}

} + +
+ + Back to home + +
+
+
+
+ ); + } + + if (!data) { + return ( +
+ +
+ ); + } + + const stageCards = [ + { key: 'cold', label: 'Cold' }, + { key: 'activated', label: 'Activated' }, + { key: 'warm', label: 'Warm' }, + { key: 'hot', label: 'Hot' }, + { key: 'upgrade_candidate', label: 'Upgrade Candidate' }, + { key: 'paid', label: 'Paid' }, + ]; + + const funnelSteps = [ + ['Signup', data.funnel.signup], + ['Source confirmed', data.funnel.sourceConfirmed], + ['Use case selected', data.funnel.useCaseSelected], + ['Goal selected', data.funnel.goalSelected], + ['Role/company/team captured', data.funnel.profileCaptured], + ['First QR created', data.funnel.firstQrCreated], + ['First dynamic QR created', data.funnel.firstDynamicQrCreated], + ['Activated', data.funnel.activated], + ]; + + return ( +
+
+
+
+

Ops Cockpit

+

+ Attribution, onboarding funnel, lifecycle quality, and filtered segments for QR Master. +

+
+ +
+ +
+ + +
+
+

Total users

+

{data.overview.totalUsers}

+
+ +
+
+
+ + +
+
+

Activated users

+

{data.overview.activatedUsers}

+
+ +
+
+
+ + +
+
+

Paid users

+

{data.overview.paidUsers}

+
+ +
+
+
+ + +
+
+

Tracked vs self-reported mismatch

+

{data.overview.mismatchCount}

+
+ +
+
+
+
+ +
+ + + Acquisition Overview + + + + + + Source + Signups + First QR + Activated + Hot + Upgrade + Paid + Activation rate + + + + {data.acquisition.bySource.map((row) => ( + + {row.label} + {row.signups} + {row.firstQr} + {row.activated} + {row.hot} + {row.upgradeCandidates} + {row.paid} + {row.activationRate}% + + ))} + +
+
+
+ +
+ ({ ...row, label: row.key || 'unknown' }))} /> + ({ ...row, label: row.key || 'unknown' }))} /> +
+
+ +
+ + + Onboarding Funnel + + + {funnelSteps.map(([label, value]) => { + const percentage = data.funnel.signup ? Math.round((Number(value) / data.funnel.signup) * 100) : 0; + return ( +
+
+ {label} + {value} ({percentage}%) +
+
+
+
+
+ ); + })} + + +
+ + + + +
+
+ +
+
+

Lifecycle Summary

+

Click a card to open the filtered list below.

+
+
+ {stageCards.map((card) => ( + updateFilter('stage', filters.stage === card.key ? '' : card.key)} + /> + ))} +
+
+ + + +
+
+ Full Segment Lists +

+ Filter and export cold, activated, warm, hot, upgrade_candidate, and paid users. +

+
+
+ + +
+
+
+ +
+
+ +
+ + updateFilter('search', event.target.value)} + placeholder="Email, name, company" + className="h-11 w-full rounded-xl border border-slate-300 bg-white pl-10 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" + /> +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + {data.segments.total} matching users + + {loading && } +
+ + + + + Name + Email + Domain + Plan + Stage + Fit + Intent + Lead + Source + Self-reported + Campaign + Landing page + Use case + Goal + Role + Company + Team + Created + First QR + Activated + QRs + Dynamic + Scans + + + + {data.segments.rows.map((row) => ( + + {row.name || '—'} + {row.email} + {row.emailDomain || '—'} + {row.plan} + {row.lifecycleStageLabel} + {row.fitScore} + {row.intentScore} + {row.leadScore} + {row.signupSourceLabel} + {row.signupSourceSelfReportedLabel} + {row.signupCampaign || '—'} + {row.signupLandingPath || '—'} + {row.primaryUseCaseLabel} + {row.primaryGoalLabel} + {row.jobRoleLabel} + {row.companyName || '—'} + {row.teamSizeLabel} + {new Date(row.createdAt).toLocaleDateString()} + {row.firstQrCreatedAt ? new Date(row.firstQrCreatedAt).toLocaleDateString() : '—'} + {row.activationAt ? new Date(row.activationAt).toLocaleDateString() : '—'} + {row.qrCount} + {row.dynamicQrCount} + {row.scanCount} + + ))} + +
+ +
+ + Page {data.segments.page} of {data.segments.totalPages} + +
+ + +
+
+
+
+ + + + Upgrade Candidates +

+ Free users with strong fit and clear commercial intent. +

+
+ + + + + User + Lead score + Use case + Role + Company + Reasons + + + + {data.upgradeCandidates.map((row) => ( + + +
+
{row.name || row.email}
+
{row.email}
+
+
+ {row.leadScore} + {getUseCaseLabel(row.primaryUseCase)} + {getRoleLabel(row.jobRole)} + {row.companyName || '—'} + +
+ {row.upgradeBadges.map((badge) => ( + {badge} + ))} +
+
+
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/src/app/(main)/(marketing)/pricing/PricingClient.tsx b/src/app/(main)/(marketing)/pricing/PricingClient.tsx index 9dbc2ac..c73a50e 100644 --- a/src/app/(main)/(marketing)/pricing/PricingClient.tsx +++ b/src/app/(main)/(marketing)/pricing/PricingClient.tsx @@ -8,6 +8,8 @@ import { showToast } from '@/components/ui/Toast'; import { useRouter } from 'next/navigation'; import { BillingToggle } from '@/components/ui/BillingToggle'; import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto'; +import { trackEvent } from '@/components/PostHogProvider'; +import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans'; export default function PricingPage() { const router = useRouter(); @@ -40,6 +42,13 @@ export default function PricingPage() { setLoading(plan); try { + trackEvent('upgrade_clicked', { + plan, + billing_interval: billingPeriod, + source: 'pricing_page', + current_plan: currentPlan, + }); + const response = await fetch('/api/stripe/create-checkout-session', { method: 'POST', headers: { @@ -52,14 +61,15 @@ export default function PricingPage() { }); if (!response.ok) { - throw new Error('Failed to create checkout session'); + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.error || 'Failed to create checkout session'); } const { url } = await response.json(); window.location.href = url; - } catch (error) { + } catch (error: any) { console.error('Error creating checkout session:', error); - showToast('Failed to start checkout. Please try again.', 'error'); + showToast(error?.message || 'Failed to start checkout. Please try again.', 'error'); setLoading(null); } }; @@ -132,7 +142,7 @@ export default function PricingPage() { period: 'forever', showDiscount: false, features: [ - '3 active dynamic QR codes (8 types available)', + `${FREE_DYNAMIC_QR_LIMIT} active dynamic QR codes (8 types available)`, 'Unlimited static QR codes', 'Basic scan tracking', 'Standard QR design templates', diff --git a/src/app/(main)/api/admin/revops/route.ts b/src/app/(main)/api/admin/revops/route.ts new file mode 100644 index 0000000..c258bae --- /dev/null +++ b/src/app/(main)/api/admin/revops/route.ts @@ -0,0 +1,430 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { + getGoalLabel, + getLifecycleStageLabel, + getRoleLabel, + getSourceLabel, + getTeamSizeLabel, + getUseCaseLabel, +} from '@/lib/revops'; +import { db } from '@/lib/db'; +import { getMetricSnapshot, getUpgradeCandidateBadges } from '@/lib/revops-server'; + +export const dynamic = 'force-dynamic'; + +type HydratedUser = { + id: string; + name: string | null; + email: string; + emailDomain: string | null; + plan: string; + lifecycleStage: string; + fitScore: number; + intentScore: number; + leadScore: number; + signupSource: string | null; + signupSourceSelfReported: string | null; + signupCampaign: string | null; + signupLandingPath: string | null; + primaryUseCase: string | null; + primaryGoal: string | null; + jobRole: string | null; + companyName: string | null; + companyWebsite: string | null; + teamSizeBucket: string | null; + createdAt: string; + firstQrCreatedAt: string | null; + activationAt: string | null; + firstDynamicQrAt: string | null; + qrCount: number; + dynamicQrCount: number; + scanCount: number; + contentTypeCount: number; + upgradeBadges: string[]; +}; + +function hasAdminSession() { + const adminCookie = cookies().get('newsletter-admin'); + return adminCookie?.value === 'authenticated'; +} + +function toIso(value: Date | null) { + return value ? value.toISOString() : null; +} + +function safeDate(value: string | null) { + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function applyUserFilters(users: HydratedUser[], request: NextRequest) { + const stage = request.nextUrl.searchParams.get('stage'); + const source = request.nextUrl.searchParams.get('source'); + const campaign = request.nextUrl.searchParams.get('campaign'); + const landingPath = request.nextUrl.searchParams.get('landingPath'); + const useCase = request.nextUrl.searchParams.get('useCase'); + const goal = request.nextUrl.searchParams.get('goal'); + const role = request.nextUrl.searchParams.get('role'); + const teamSize = request.nextUrl.searchParams.get('teamSize'); + const plan = request.nextUrl.searchParams.get('plan'); + const search = request.nextUrl.searchParams.get('search')?.toLowerCase().trim(); + const from = safeDate(request.nextUrl.searchParams.get('from')); + const to = safeDate(request.nextUrl.searchParams.get('to')); + + return users.filter((user) => { + const createdAt = new Date(user.createdAt); + const matchesSearch = !search || [ + user.name, + user.email, + user.companyName, + user.emailDomain, + ].filter(Boolean).some((value) => value!.toLowerCase().includes(search)); + + return ( + (!stage || user.lifecycleStage === stage) && + (!source || user.signupSource === source) && + (!campaign || user.signupCampaign === campaign) && + (!landingPath || user.signupLandingPath === landingPath) && + (!useCase || user.primaryUseCase === useCase) && + (!goal || user.primaryGoal === goal) && + (!role || user.jobRole === role) && + (!teamSize || user.teamSizeBucket === teamSize) && + (!plan || user.plan === plan) && + (!from || createdAt >= from) && + (!to || createdAt <= to) && + matchesSearch + ); + }); +} + +function sortUsers(users: HydratedUser[], sort: string) { + const sorted = [...users]; + + sorted.sort((a, b) => { + switch (sort) { + case 'createdAt_asc': + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + case 'activationAt_desc': + return new Date(b.activationAt || 0).getTime() - new Date(a.activationAt || 0).getTime(); + case 'leadScore_asc': + return a.leadScore - b.leadScore; + case 'fitScore_desc': + return b.fitScore - a.fitScore; + case 'intentScore_desc': + return b.intentScore - a.intentScore; + case 'leadScore_desc': + default: + return b.leadScore - a.leadScore || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + } + }); + + return sorted; +} + +function buildGroupedRows(users: HydratedUser[], key: keyof HydratedUser) { + const rows = new Map(); + + users.forEach((user) => { + const rawValue = (user[key] as string | null) || 'unknown'; + const row = rows.get(rawValue) || { + key: rawValue, + signups: 0, + firstQr: 0, + activated: 0, + hot: 0, + upgradeCandidates: 0, + paid: 0, + }; + + row.signups += 1; + if (user.firstQrCreatedAt) row.firstQr += 1; + if (user.activationAt) row.activated += 1; + if (user.lifecycleStage === 'hot') row.hot += 1; + if (user.lifecycleStage === 'upgrade_candidate') row.upgradeCandidates += 1; + if (user.lifecycleStage === 'paid') row.paid += 1; + + rows.set(rawValue, row); + }); + + return Array.from(rows.values()).sort((a, b) => b.signups - a.signups); +} + +function buildFunnel(users: HydratedUser[]) { + return { + signup: users.length, + sourceConfirmed: users.filter((user) => Boolean(user.signupSourceSelfReported)).length, + useCaseSelected: users.filter((user) => Boolean(user.primaryUseCase)).length, + goalSelected: users.filter((user) => Boolean(user.primaryGoal)).length, + profileCaptured: users.filter((user) => Boolean(user.jobRole && user.teamSizeBucket)).length, + firstQrCreated: users.filter((user) => Boolean(user.firstQrCreatedAt)).length, + firstDynamicQrCreated: users.filter((user) => Boolean(user.firstDynamicQrAt)).length, + activated: users.filter((user) => Boolean(user.activationAt)).length, + }; +} + +function buildLifecycleSummary(users: HydratedUser[]) { + return { + cold: users.filter((user) => user.lifecycleStage === 'cold').length, + activated: users.filter((user) => user.lifecycleStage === 'activated').length, + warm: users.filter((user) => user.lifecycleStage === 'warm').length, + hot: users.filter((user) => user.lifecycleStage === 'hot').length, + upgrade_candidate: users.filter((user) => user.lifecycleStage === 'upgrade_candidate').length, + paid: users.filter((user) => user.lifecycleStage === 'paid').length, + }; +} + +function buildCsv(rows: HydratedUser[]) { + const headers = [ + 'name', + 'email', + 'email_domain', + 'plan', + 'lifecycle_stage', + 'fit_score', + 'intent_score', + 'lead_score', + 'source', + 'self_reported_source', + 'campaign', + 'landing_page', + 'use_case', + 'goal', + 'role', + 'company', + 'team_size', + 'created_at', + 'first_qr_created_at', + 'activation_at', + 'qr_count', + 'dynamic_qr_count', + 'scan_count', + ]; + + const escape = (value: string | number | null) => { + const normalized = value == null ? '' : String(value); + return `"${normalized.replace(/"/g, '""')}"`; + }; + + const lines = rows.map((row) => [ + row.name, + row.email, + row.emailDomain, + row.plan, + row.lifecycleStage, + row.fitScore, + row.intentScore, + row.leadScore, + row.signupSource, + row.signupSourceSelfReported, + row.signupCampaign, + row.signupLandingPath, + row.primaryUseCase, + row.primaryGoal, + row.jobRole, + row.companyName, + row.teamSizeBucket, + row.createdAt, + row.firstQrCreatedAt, + row.activationAt, + row.qrCount, + row.dynamicQrCount, + row.scanCount, + ].map(escape).join(',')); + + return [headers.join(','), ...lines].join('\n'); +} + +export async function GET(request: NextRequest) { + try { + if (!hasAdminSession()) { + return NextResponse.json({ error: 'Unauthorized - Admin login required' }, { status: 401 }); + } + + const rawUsers = await db.user.findMany({ + select: { + id: true, + name: true, + email: true, + emailDomain: true, + plan: true, + lifecycleStage: true, + fitScore: true, + intentScore: true, + leadScore: true, + signupSource: true, + signupSourceSelfReported: true, + signupCampaign: true, + signupLandingPath: true, + primaryUseCase: true, + primaryGoal: true, + jobRole: true, + companyName: true, + companyWebsite: true, + teamSizeBucket: true, + createdAt: true, + firstQrCreatedAt: true, + firstDynamicQrAt: true, + activationAt: true, + qrCodes: { + select: { + type: true, + contentType: true, + createdAt: true, + _count: { + select: { + scans: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + const users: HydratedUser[] = rawUsers.map((user) => { + const metrics = getMetricSnapshot(user.qrCodes); + + return { + id: user.id, + name: user.name, + email: user.email, + emailDomain: user.emailDomain, + plan: user.plan, + lifecycleStage: user.lifecycleStage, + fitScore: user.fitScore, + intentScore: user.intentScore, + leadScore: user.leadScore, + signupSource: user.signupSource, + signupSourceSelfReported: user.signupSourceSelfReported, + signupCampaign: user.signupCampaign, + signupLandingPath: user.signupLandingPath, + primaryUseCase: user.primaryUseCase, + primaryGoal: user.primaryGoal, + jobRole: user.jobRole, + companyName: user.companyName, + companyWebsite: user.companyWebsite, + teamSizeBucket: user.teamSizeBucket, + createdAt: user.createdAt.toISOString(), + firstQrCreatedAt: toIso(user.firstQrCreatedAt), + activationAt: toIso(user.activationAt), + firstDynamicQrAt: toIso(user.firstDynamicQrAt), + qrCount: metrics.qrCount, + dynamicQrCount: metrics.dynamicQrCount, + scanCount: metrics.scanCount, + contentTypeCount: metrics.contentTypeCount, + upgradeBadges: getUpgradeCandidateBadges(user, metrics), + }; + }); + + const filteredUsers = sortUsers( + applyUserFilters(users, request), + request.nextUrl.searchParams.get('sort') || 'leadScore_desc' + ); + + if (request.nextUrl.searchParams.get('format') === 'csv') { + const csv = buildCsv(filteredUsers); + return new NextResponse(csv, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="qrmaster-revops-export.csv"', + }, + }); + } + + const page = Number(request.nextUrl.searchParams.get('page') || '1'); + const pageSize = Number(request.nextUrl.searchParams.get('pageSize') || '25'); + const total = filteredUsers.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const paginatedUsers = filteredUsers.slice((page - 1) * pageSize, page * pageSize); + + const acquisitionBySource = buildGroupedRows(users, 'signupSource').map((row) => ({ + ...row, + label: getSourceLabel(row.key), + activationRate: row.signups ? Math.round((row.activated / row.signups) * 100) : 0, + })); + const acquisitionByCampaign = buildGroupedRows(users, 'signupCampaign'); + const acquisitionByLandingPath = buildGroupedRows(users, 'signupLandingPath'); + const funnel = buildFunnel(users); + const lifecycleSummary = buildLifecycleSummary(users); + + const mismatchCount = users.filter( + (user) => + user.signupSource && + user.signupSourceSelfReported && + user.signupSource !== user.signupSourceSelfReported + ).length; + + const upgradeCandidates = users + .filter((user) => user.plan === 'FREE' && user.lifecycleStage === 'upgrade_candidate') + .sort((a, b) => b.leadScore - a.leadScore) + .slice(0, 25); + + const filterOptions = { + stages: ['cold', 'activated', 'warm', 'hot', 'upgrade_candidate', 'paid'], + sources: Array.from(new Set(users.map((user) => user.signupSource).filter((value): value is string => Boolean(value)))), + campaigns: Array.from(new Set(users.map((user) => user.signupCampaign).filter((value): value is string => Boolean(value)))), + landingPaths: Array.from(new Set(users.map((user) => user.signupLandingPath).filter((value): value is string => Boolean(value)))), + useCases: Array.from(new Set(users.map((user) => user.primaryUseCase).filter((value): value is string => Boolean(value)))), + goals: Array.from(new Set(users.map((user) => user.primaryGoal).filter((value): value is string => Boolean(value)))), + roles: Array.from(new Set(users.map((user) => user.jobRole).filter((value): value is string => Boolean(value)))), + teamSizes: Array.from(new Set(users.map((user) => user.teamSizeBucket).filter((value): value is string => Boolean(value)))), + plans: Array.from(new Set(users.map((user) => user.plan).filter((value): value is string => Boolean(value)))), + }; + + return NextResponse.json({ + overview: { + totalUsers: users.length, + mismatchCount, + activatedUsers: funnel.activated, + paidUsers: lifecycleSummary.paid, + }, + acquisition: { + bySource: acquisitionBySource, + byCampaign: acquisitionByCampaign.slice(0, 15), + byLandingPath: acquisitionByLandingPath.slice(0, 15), + }, + funnel, + funnelBreakdowns: { + bySource: acquisitionBySource.slice(0, 10), + byUseCase: buildGroupedRows(users, 'primaryUseCase').map((row) => ({ ...row, label: getUseCaseLabel(row.key) })), + byRole: buildGroupedRows(users, 'jobRole').map((row) => ({ ...row, label: getRoleLabel(row.key) })), + byTeamSize: buildGroupedRows(users, 'teamSizeBucket').map((row) => ({ ...row, label: getTeamSizeLabel(row.key) })), + }, + lifecycleSummary, + campaignSourceQuality: acquisitionBySource, + upgradeCandidates, + filterOptions, + segments: { + total, + page, + pageSize, + totalPages, + rows: paginatedUsers.map((user) => ({ + ...user, + lifecycleStageLabel: getLifecycleStageLabel(user.lifecycleStage), + signupSourceLabel: getSourceLabel(user.signupSource), + signupSourceSelfReportedLabel: getSourceLabel(user.signupSourceSelfReported), + primaryUseCaseLabel: getUseCaseLabel(user.primaryUseCase), + primaryGoalLabel: getGoalLabel(user.primaryGoal), + jobRoleLabel: getRoleLabel(user.jobRole), + teamSizeLabel: getTeamSizeLabel(user.teamSizeBucket), + })), + }, + }); + } catch (error) { + console.error('Error fetching RevOps dashboard data:', error); + return NextResponse.json({ error: 'Failed to fetch RevOps dashboard data' }, { status: 500 }); + } +} diff --git a/src/app/(main)/api/auth/google/route.ts b/src/app/(main)/api/auth/google/route.ts index c379ff7..7dadb68 100644 --- a/src/app/(main)/api/auth/google/route.ts +++ b/src/app/(main)/api/auth/google/route.ts @@ -1,14 +1,32 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/lib/db'; -import { cookies } from 'next/headers'; -import { getAuthCookieOptions } from '@/lib/cookieConfig'; - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const code = searchParams.get('code'); - - // If no code, redirect to Google OAuth - if (!code) { +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuthCookieOptions } from '@/lib/cookieConfig'; +import { + appendRedirectParam, + GOOGLE_OAUTH_STATE_COOKIE_NAME, + POST_AUTH_REDIRECT_COOKIE_NAME, + sanitizeRedirectPath, +} from '@/lib/auth-flow'; +import { + ATTRIBUTION_COOKIE_NAME, + getEmailDomain, + parseAttributionCookie, + shouldResumeOnboarding, +} from '@/lib/revops'; +import { triggerLifecycleScoring } from '@/lib/revops-server'; + +const isProduction = process.env.NODE_ENV === 'production'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value); + const savedOauthState = request.cookies.get(GOOGLE_OAUTH_STATE_COOKIE_NAME)?.value; + const savedRedirect = sanitizeRedirectPath(request.cookies.get(POST_AUTH_REDIRECT_COOKIE_NAME)?.value); + + // If no code, redirect to Google OAuth + if (!code) { const googleClientId = process.env.GOOGLE_CLIENT_ID; if (!googleClientId) { @@ -17,19 +35,56 @@ export async function GET(request: NextRequest) { { status: 500 } ); } - - const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`; - const scope = 'openid email profile'; - - const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}`; - - return NextResponse.redirect(googleAuthUrl); - } - - // Handle callback with code - try { - const googleClientId = process.env.GOOGLE_CLIENT_ID; - const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET; + + const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`; + const scope = 'openid email profile'; + const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect')); + const oauthState = crypto.randomUUID(); + + const googleAuthUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + googleAuthUrl.searchParams.set('client_id', googleClientId); + googleAuthUrl.searchParams.set('redirect_uri', redirectUri); + googleAuthUrl.searchParams.set('response_type', 'code'); + googleAuthUrl.searchParams.set('scope', scope); + googleAuthUrl.searchParams.set('state', oauthState); + + const response = NextResponse.redirect(googleAuthUrl); + response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, + }); + + if (redirectTarget) { + response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, + }); + } else { + response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME); + } + + return response; + } + + // Handle callback with code + try { + if (!state || !savedOauthState || state !== savedOauthState) { + const invalidStateResponse = NextResponse.redirect( + `${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-state-invalid` + ); + invalidStateResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME); + invalidStateResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME); + return invalidStateResponse; + } + + const googleClientId = process.env.GOOGLE_CLIENT_ID; + const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET; if (!googleClientId || !googleClientSecret) { return NextResponse.json( @@ -50,9 +105,9 @@ export async function GET(request: NextRequest) { code, client_id: googleClientId, client_secret: googleClientSecret, - redirect_uri: redirectUri, - grant_type: 'authorization_code', - }), + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), }); if (!tokenResponse.ok) { @@ -82,16 +137,27 @@ export async function GET(request: NextRequest) { const isNewUser = !user; // Create user if they don't exist - if (!user) { - user = await db.user.create({ - data: { - email: userInfo.email, - name: userInfo.name || userInfo.email.split('@')[0], - image: userInfo.picture, - emailVerified: new Date(), // Google already verified the email - password: null, // OAuth users don't need a password - }, - }); + if (!user) { + const onboardingStartedAt = new Date(); + user = await db.user.create({ + data: { + email: userInfo.email, + name: userInfo.name || userInfo.email.split('@')[0], + image: userInfo.picture, + emailVerified: new Date(), // Google already verified the email + password: null, // OAuth users don't need a password + onboardingStartedAt, + emailDomain: getEmailDomain(userInfo.email), + signupSource: firstTouch?.signupSource || null, + signupMedium: firstTouch?.signupMedium || null, + signupCampaign: firstTouch?.signupCampaign || null, + signupContent: firstTouch?.signupContent || null, + signupTerm: firstTouch?.signupTerm || null, + signupReferrer: firstTouch?.signupReferrer || null, + signupLandingPath: firstTouch?.signupLandingPath || '/signup', + signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt, + }, + }); // Create Account entry for the OAuth provider await db.account.create({ @@ -144,22 +210,35 @@ export async function GET(request: NextRequest) { id_token: tokens.id_token, }, }); - } - } - - // Set authentication cookie - cookies().set('userId', user.id, getAuthCookieOptions()); - - // Redirect to dashboard with tracking params - const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`); - redirectUrl.searchParams.set('authMethod', 'google'); - redirectUrl.searchParams.set('isNewUser', isNewUser.toString()); - - return NextResponse.redirect(redirectUrl.toString()); - } catch (error) { - console.error('Google OAuth error:', error); - return NextResponse.redirect( - `${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed` - ); - } -} + } + } + + triggerLifecycleScoring(user.id, isNewUser ? 'signup' : 'subscription_changed'); + + const onboardingTarget = isNewUser || shouldResumeOnboarding(user) + ? appendRedirectParam('/onboarding', savedRedirect, { + authMethod: 'google', + isNewUser: isNewUser.toString(), + }) + : (savedRedirect || appendRedirectParam('/dashboard', null, { + authMethod: 'google', + isNewUser: isNewUser.toString(), + })); + const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`); + + const response = NextResponse.redirect(redirectUrl.toString()); + response.cookies.set('userId', user.id, getAuthCookieOptions()); + response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME); + response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME); + response.cookies.delete(ATTRIBUTION_COOKIE_NAME); + return response; + } catch (error) { + console.error('Google OAuth error:', error); + const errorResponse = NextResponse.redirect( + `${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed` + ); + errorResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME); + errorResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME); + return errorResponse; + } +} diff --git a/src/app/(main)/api/auth/logout/route.ts b/src/app/(main)/api/auth/logout/route.ts new file mode 100644 index 0000000..a7e64bb --- /dev/null +++ b/src/app/(main)/api/auth/logout/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import { ATTRIBUTION_COOKIE_NAME } from '@/lib/revops'; + +export async function POST() { + const response = NextResponse.json({ success: true }); + + response.cookies.set('userId', '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 0, + }); + response.cookies.set('newsletter-admin', '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 0, + }); + response.cookies.set(ATTRIBUTION_COOKIE_NAME, '', { + httpOnly: false, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 0, + }); + + return response; +} diff --git a/src/app/(main)/api/auth/signup/route.ts b/src/app/(main)/api/auth/signup/route.ts index 07548ab..b712653 100644 --- a/src/app/(main)/api/auth/signup/route.ts +++ b/src/app/(main)/api/auth/signup/route.ts @@ -1,14 +1,19 @@ -import { NextRequest, NextResponse } from 'next/server'; -import bcrypt from 'bcryptjs'; -import { cookies } from 'next/headers'; -import { db } from '@/lib/db'; +import { NextRequest, NextResponse } from 'next/server'; +import bcrypt from 'bcryptjs'; +import { db } from '@/lib/db'; import { z } from 'zod'; import { csrfProtection } from '@/lib/csrf'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; -import { getAuthCookieOptions } from '@/lib/cookieConfig'; -import { signupSchema, validateRequest } from '@/lib/validationSchemas'; -import { sendWelcomeEmail } from '@/lib/email'; -import { sendConversionEvent } from '@/lib/meta'; +import { getAuthCookieOptions } from '@/lib/cookieConfig'; +import { signupSchema, validateRequest } from '@/lib/validationSchemas'; +import { sendWelcomeEmail } from '@/lib/email'; +import { sendConversionEvent } from '@/lib/meta'; +import { + ATTRIBUTION_COOKIE_NAME, + getEmailDomain, + parseAttributionCookie, +} from '@/lib/revops'; +import { triggerLifecycleScoring } from '@/lib/revops-server'; export async function POST(request: NextRequest) { try { @@ -67,14 +72,29 @@ export async function POST(request: NextRequest) { // Hash password const hashedPassword = await bcrypt.hash(password, 12); - // Create user - const user = await db.user.create({ - data: { - name, - email, - password: hashedPassword, - }, - }); + const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value); + const onboardingStartedAt = new Date(); + + // Create user + const user = await db.user.create({ + data: { + name, + email, + password: hashedPassword, + onboardingStartedAt, + emailDomain: getEmailDomain(email), + signupSource: firstTouch?.signupSource || null, + signupMedium: firstTouch?.signupMedium || null, + signupCampaign: firstTouch?.signupCampaign || null, + signupContent: firstTouch?.signupContent || null, + signupTerm: firstTouch?.signupTerm || null, + signupReferrer: firstTouch?.signupReferrer || null, + signupLandingPath: firstTouch?.signupLandingPath || '/signup', + signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt, + }, + }); + + triggerLifecycleScoring(user.id, 'signup'); // Send welcome email (fire-and-forget — never block signup) try { @@ -97,20 +117,22 @@ export async function POST(request: NextRequest) { }).catch(console.error); // Create response - const response = NextResponse.json({ - success: true, - user: { - id: user.id, - name: user.name, - email: user.email, - plan: 'FREE', + const response = NextResponse.json({ + success: true, + needsOnboarding: true, + user: { + id: user.id, + name: user.name, + email: user.email, + plan: 'FREE', }, }); - - // Set cookie for auto-login after signup - response.cookies.set('userId', user.id, getAuthCookieOptions()); - - return response; + + // Set cookie for auto-login after signup + response.cookies.set('userId', user.id, getAuthCookieOptions()); + response.cookies.delete(ATTRIBUTION_COOKIE_NAME); + + return response; } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( @@ -125,4 +147,4 @@ export async function POST(request: NextRequest) { { status: 500 } ); } -} \ No newline at end of file +} diff --git a/src/app/(main)/api/auth/simple-login/route.ts b/src/app/(main)/api/auth/simple-login/route.ts index 5f5951d..650689c 100644 --- a/src/app/(main)/api/auth/simple-login/route.ts +++ b/src/app/(main)/api/auth/simple-login/route.ts @@ -2,10 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import bcrypt from 'bcryptjs'; import { cookies } from 'next/headers'; -import { csrfProtection } from '@/lib/csrf'; -import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; -import { getAuthCookieOptions } from '@/lib/cookieConfig'; -import { loginSchema, validateRequest } from '@/lib/validationSchemas'; +import { csrfProtection } from '@/lib/csrf'; +import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; +import { getAuthCookieOptions } from '@/lib/cookieConfig'; +import { loginSchema, validateRequest } from '@/lib/validationSchemas'; +import { shouldResumeOnboarding } from '@/lib/revops'; export async function POST(request: NextRequest) { try { @@ -50,9 +51,18 @@ export async function POST(request: NextRequest) { const { email, password } = validation.data; // Find user - const user = await db.user.findUnique({ - where: { email }, - }); + const user = await db.user.findUnique({ + where: { email }, + select: { + id: true, + email: true, + name: true, + plan: true, + password: true, + onboardingStartedAt: true, + onboardingCompletedAt: true, + }, + }); if (!user) { return NextResponse.json( @@ -74,12 +84,13 @@ export async function POST(request: NextRequest) { // Set cookie cookies().set('userId', user.id, getAuthCookieOptions()); - return NextResponse.json({ - success: true, - user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' } - }); + return NextResponse.json({ + success: true, + needsOnboarding: shouldResumeOnboarding(user), + user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' } + }); } catch (error) { console.error('Login error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/(main)/api/onboarding/route.ts b/src/app/(main)/api/onboarding/route.ts new file mode 100644 index 0000000..4e53f29 --- /dev/null +++ b/src/app/(main)/api/onboarding/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { db } from '@/lib/db'; +import { csrfProtection } from '@/lib/csrf'; +import { getClientIdentifier, rateLimit, RateLimits } from '@/lib/rateLimit'; +import { onboardingUpdateSchema, validateRequest } from '@/lib/validationSchemas'; +import { getOnboardingState, triggerLifecycleScoring } from '@/lib/revops-server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const userId = cookies().get('userId')?.value; + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const state = await getOnboardingState(userId); + + if (!state) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + return NextResponse.json(state); + } catch (error) { + console.error('Error fetching onboarding state:', error); + return NextResponse.json({ error: 'Failed to fetch onboarding state' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest) { + try { + const csrfCheck = csrfProtection(request); + if (!csrfCheck.valid) { + return NextResponse.json({ error: csrfCheck.error }, { status: 403 }); + } + + const userId = cookies().get('userId')?.value; + const clientId = userId || getClientIdentifier(request); + const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE); + + if (!rateLimitResult.success) { + return NextResponse.json( + { + error: 'Too many requests. Please try again later.', + retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000), + }, + { status: 429 } + ); + } + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const validation = await validateRequest(onboardingUpdateSchema, body); + + if (!validation.success) { + return NextResponse.json(validation.error, { status: 400 }); + } + + const data = validation.data; + const now = new Date(); + const existingUser = await db.user.findUnique({ + where: { id: userId }, + select: { + onboardingStartedAt: true, + sourceConfirmedAt: true, + useCaseSelectedAt: true, + goalSelectedAt: true, + profileCompletedAt: true, + }, + }); + + if (!existingUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + await db.user.update({ + where: { id: userId }, + data: { + onboardingStartedAt: existingUser.onboardingStartedAt ?? now, + signupSourceSelfReported: data.signupSourceSelfReported, + primaryUseCase: data.primaryUseCase, + primaryGoal: data.primaryGoal, + jobRole: data.jobRole, + companyName: data.companyName, + companyWebsite: data.companyWebsite, + teamSizeBucket: data.teamSizeBucket, + sourceConfirmedAt: + data.signupSourceSelfReported && !existingUser.sourceConfirmedAt + ? now + : undefined, + useCaseSelectedAt: + data.primaryUseCase && !existingUser.useCaseSelectedAt + ? now + : undefined, + goalSelectedAt: + data.primaryGoal && !existingUser.goalSelectedAt + ? now + : undefined, + profileCompletedAt: + data.markProfileComplete && !existingUser.profileCompletedAt + ? now + : undefined, + }, + }); + + triggerLifecycleScoring(userId, 'onboarding_update'); + const state = await getOnboardingState(userId); + + return NextResponse.json({ success: true, state }); + } catch (error) { + console.error('Error updating onboarding state:', error); + return NextResponse.json({ error: 'Failed to update onboarding state' }, { status: 500 }); + } +} diff --git a/src/app/(main)/api/qrs/route.ts b/src/app/(main)/api/qrs/route.ts index 830f493..1103e6c 100644 --- a/src/app/(main)/api/qrs/route.ts +++ b/src/app/(main)/api/qrs/route.ts @@ -1,10 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { db } from '@/lib/db'; -import { generateSlug } from '@/lib/hash'; -import { createQRSchema, validateRequest } from '@/lib/validationSchemas'; -import { csrfProtection } from '@/lib/csrf'; -import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; +import { generateSlug } from '@/lib/hash'; +import { createQRSchema, validateRequest } from '@/lib/validationSchemas'; +import { csrfProtection } from '@/lib/csrf'; +import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; +import { DYNAMIC_QR_LIMITS } from '@/lib/plans'; +import { triggerLifecycleScoring } from '@/lib/revops-server'; // GET /api/qrs - List user's QR codes export async function GET(request: NextRequest) { @@ -47,12 +49,7 @@ export async function GET(request: NextRequest) { } // Plan limits -const PLAN_LIMITS = { - FREE: 3, - PRO: 50, - BUSINESS: 500, - ENTERPRISE: 99999, -}; +const PLAN_LIMITS = DYNAMIC_QR_LIMITS; // POST /api/qrs - Create a new QR code export async function POST(request: NextRequest) { @@ -208,9 +205,9 @@ END:VCARD`; const slug = generateSlug(body.title); // Create QR code - const qrCode = await db.qRCode.create({ - data: { - userId, + const qrCode = await db.qRCode.create({ + data: { + userId, title: body.title, type: isStatic ? 'STATIC' : 'DYNAMIC', contentType: body.contentType, @@ -224,10 +221,12 @@ END:VCARD`; }, slug, status: 'ACTIVE', - }, - }); - - return NextResponse.json(qrCode); + }, + }); + + triggerLifecycleScoring(userId, 'qr_created'); + + return NextResponse.json(qrCode); } catch (error) { console.error('Error creating QR code:', error); return NextResponse.json( @@ -235,4 +234,4 @@ END:VCARD`; { status: 500 } ); } -} \ No newline at end of file +} diff --git a/src/app/(main)/api/stripe/cancel-subscription/route.ts b/src/app/(main)/api/stripe/cancel-subscription/route.ts index d45270f..c98df2f 100644 --- a/src/app/(main)/api/stripe/cancel-subscription/route.ts +++ b/src/app/(main)/api/stripe/cancel-subscription/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { stripe } from '@/lib/stripe'; -import { db } from '@/lib/db'; -import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; +import { cookies } from 'next/headers'; +import { stripe } from '@/lib/stripe'; +import { db } from '@/lib/db'; +import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; +import { scoreUserLifecycle } from '@/lib/revops-server'; export async function POST(request: NextRequest) { try { @@ -53,32 +54,35 @@ export async function POST(request: NextRequest) { // No active subscription if (!user.stripeSubscriptionId) { // Just update plan to FREE if somehow plan is not FREE but no subscription - await db.user.update({ - where: { id: userId }, - data: { + await db.user.update({ + where: { id: userId }, + data: { plan: 'FREE', stripePriceId: null, stripeCurrentPeriodEnd: null, - }, - }); - return NextResponse.json({ success: true }); + }, + }); + await scoreUserLifecycle(userId, 'subscription_changed'); + return NextResponse.json({ success: true }); } // Cancel the Stripe subscription await stripe.subscriptions.cancel(user.stripeSubscriptionId); // Update user plan to FREE - await db.user.update({ - where: { id: userId }, - data: { + await db.user.update({ + where: { id: userId }, + data: { plan: 'FREE', stripeSubscriptionId: null, stripePriceId: null, stripeCurrentPeriodEnd: null, - }, - }); - - return NextResponse.json({ success: true }); + }, + }); + + await scoreUserLifecycle(userId, 'subscription_changed'); + + return NextResponse.json({ success: true }); } catch (error) { console.error('Error canceling subscription:', error); return NextResponse.json( diff --git a/src/app/(main)/api/stripe/create-checkout-session/route.ts b/src/app/(main)/api/stripe/create-checkout-session/route.ts index 486c0f2..b23ca66 100644 --- a/src/app/(main)/api/stripe/create-checkout-session/route.ts +++ b/src/app/(main)/api/stripe/create-checkout-session/route.ts @@ -65,13 +65,29 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } - // Create or get Stripe customer - let customerId = user.stripeCustomerId; - - if (!customerId) { - const customer = await stripe.customers.create({ - email: user.email, - metadata: { + // Create or get Stripe customer + let customerId = user.stripeCustomerId; + + if (customerId) { + try { + const existingCustomer = await stripe.customers.retrieve(customerId); + + if ('deleted' in existingCustomer && existingCustomer.deleted) { + customerId = null; + } + } catch (error: any) { + if (error?.code === 'resource_missing' || error?.type === 'StripeInvalidRequestError') { + customerId = null; + } else { + throw error; + } + } + } + + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email, + metadata: { userId: user.id, }, }); @@ -79,30 +95,33 @@ export async function POST(request: NextRequest) { customerId = customer.id; // Update user with Stripe customer ID - await db.user.update({ - where: { id: user.id }, - data: { stripeCustomerId: customerId }, - }); - } - - // Create Stripe Checkout Session - const checkoutSession = await stripe.checkout.sessions.create({ - customer: customerId, - mode: 'subscription', + await db.user.update({ + where: { id: user.id }, + data: { stripeCustomerId: customerId }, + }); + } + + const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin; + + // Create Stripe Checkout Session + const checkoutSession = await stripe.checkout.sessions.create({ + customer: customerId, + mode: 'subscription', payment_method_types: ['card'], line_items: [ { price: priceId, quantity: 1, - }, - ], - success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`, - cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`, - metadata: { - userId: user.id, - plan, - }, - }); + }, + ], + success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${appUrl}/pricing?canceled=true`, + metadata: { + userId: user.id, + plan, + billingInterval, + }, + }); return NextResponse.json({ url: checkoutSession.url }); } catch (error) { diff --git a/src/app/(main)/api/stripe/sync-subscription/route.ts b/src/app/(main)/api/stripe/sync-subscription/route.ts index b472fdf..068c279 100644 --- a/src/app/(main)/api/stripe/sync-subscription/route.ts +++ b/src/app/(main)/api/stripe/sync-subscription/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { stripe } from '@/lib/stripe'; -import { db } from '@/lib/db'; +import { cookies } from 'next/headers'; +import { stripe } from '@/lib/stripe'; +import { db } from '@/lib/db'; +import { scoreUserLifecycle } from '@/lib/revops-server'; /** * Manual sync endpoint to update user subscription from Stripe @@ -37,17 +38,19 @@ export async function POST(request: NextRequest) { if (subscriptions.data.length === 0) { // No active subscription - set to FREE - await db.user.update({ - where: { id: user.id }, - data: { + await db.user.update({ + where: { id: user.id }, + data: { stripeSubscriptionId: null, stripePriceId: null, stripeCurrentPeriodEnd: null, plan: 'FREE', - }, - }); - - return NextResponse.json({ + }, + }); + + await scoreUserLifecycle(user.id, 'subscription_changed'); + + return NextResponse.json({ success: true, plan: 'FREE', message: 'No active subscription found, set to FREE plan', @@ -87,18 +90,20 @@ export async function POST(request: NextRequest) { }); // Update user in database - await db.user.update({ - where: { id: user.id }, - data: { + await db.user.update({ + where: { id: user.id }, + data: { stripeSubscriptionId: subscription.id, stripePriceId: priceId, stripeCurrentPeriodEnd: currentPeriodEnd, plan: plan as any, - }, - }); - - return NextResponse.json({ - success: true, + }, + }); + + await scoreUserLifecycle(user.id, 'subscription_changed'); + + return NextResponse.json({ + success: true, plan, subscriptionId: subscription.id, currentPeriodEnd, diff --git a/src/app/(main)/api/stripe/verify-session/route.ts b/src/app/(main)/api/stripe/verify-session/route.ts index a05bfd1..81063d8 100644 --- a/src/app/(main)/api/stripe/verify-session/route.ts +++ b/src/app/(main)/api/stripe/verify-session/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { stripe } from '@/lib/stripe'; -import { db } from '@/lib/db'; +import { cookies } from 'next/headers'; +import { stripe } from '@/lib/stripe'; +import { db } from '@/lib/db'; +import { scoreUserLifecycle } from '@/lib/revops-server'; export async function POST(request: NextRequest) { try { @@ -20,26 +21,30 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } - if (!user.stripeCustomerId) { - return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 }); - } - - // Get the most recent checkout session for this customer - const checkoutSessions = await stripe.checkout.sessions.list({ - customer: user.stripeCustomerId, - limit: 1, - }); - - if (checkoutSessions.data.length === 0) { - return NextResponse.json({ error: 'No checkout session found' }, { status: 404 }); - } - - const checkoutSession = checkoutSessions.data[0]; - - // Only process if payment was successful - if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) { - const subscriptionId = typeof checkoutSession.subscription === 'string' - ? checkoutSession.subscription + if (!user.stripeCustomerId) { + return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 }); + } + + const { sessionId } = await request.json().catch(() => ({ sessionId: null })); + + if (!sessionId || typeof sessionId !== 'string') { + return NextResponse.json({ error: 'Missing checkout session ID' }, { status: 400 }); + } + + const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId); + + const sessionBelongsToUser = + checkoutSession.metadata?.userId === user.id || + checkoutSession.customer === user.stripeCustomerId; + + if (!sessionBelongsToUser) { + return NextResponse.json({ error: 'Checkout session does not belong to user' }, { status: 403 }); + } + + // Only process if payment was successful + if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) { + const subscriptionId = typeof checkoutSession.subscription === 'string' + ? checkoutSession.subscription : checkoutSession.subscription.id; // Retrieve the full subscription object @@ -48,41 +53,32 @@ export async function POST(request: NextRequest) { // Determine plan from metadata or price ID const plan = checkoutSession.metadata?.plan || 'PRO'; - // Debug log to see the subscription structure - console.log('Full subscription object:', JSON.stringify(subscription, null, 2)); - - // Get current_period_end - Stripe returns it as a Unix timestamp - // Try different possible field names - const periodEndTimestamp = subscription.current_period_end - || subscription.currentPeriodEnd - || subscription.billing_cycle_anchor; + // Get current_period_end - Stripe returns it as a Unix timestamp + const periodEndTimestamp = subscription.current_period_end + || subscription.currentPeriodEnd + || subscription.billing_cycle_anchor; const currentPeriodEnd = periodEndTimestamp ? new Date(periodEndTimestamp * 1000) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now - console.log('Subscription data:', { - id: subscription.id, - periodEndTimestamp, - currentPeriodEnd, - priceId: subscription.items?.data?.[0]?.price?.id, - }); - - // Update user in database - await db.user.update({ - where: { id: user.id }, - data: { - stripeSubscriptionId: subscription.id, - stripePriceId: subscription.items.data[0].price.id, + // Update user in database + await db.user.update({ + where: { id: user.id }, + data: { + stripeSubscriptionId: subscription.id, + stripePriceId: subscription.items.data[0].price.id, stripeCurrentPeriodEnd: currentPeriodEnd, plan: plan as any, - }, - }); - - return NextResponse.json({ - success: true, - plan, - subscriptionId: subscription.id, + }, + }); + + await scoreUserLifecycle(user.id, 'subscription_changed'); + + return NextResponse.json({ + success: true, + plan, + subscriptionId: subscription.id, }); } diff --git a/src/app/(main)/api/stripe/webhook/route.ts b/src/app/(main)/api/stripe/webhook/route.ts index 65a0bc3..559e8a8 100644 --- a/src/app/(main)/api/stripe/webhook/route.ts +++ b/src/app/(main)/api/stripe/webhook/route.ts @@ -1,9 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { headers } from 'next/headers'; import { stripe } from '@/lib/stripe'; -import { db } from '@/lib/db'; -import Stripe from 'stripe'; -import { sendConversionEvent } from '@/lib/meta'; +import { db } from '@/lib/db'; +import Stripe from 'stripe'; +import { sendConversionEvent } from '@/lib/meta'; +import { scoreUserLifecycle } from '@/lib/revops-server'; export async function POST(request: NextRequest) { const body = await request.text(); @@ -50,17 +51,19 @@ export async function POST(request: NextRequest) { ? new Date(periodEndTimestamp * 1000) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); - const updatedUser = await db.user.update({ - where: { - stripeCustomerId: session.customer as string, + const updatedUser = await db.user.update({ + where: { + stripeCustomerId: session.customer as string, }, data: { stripeSubscriptionId: subscription.id, stripePriceId: subscription.items.data[0].price.id, stripeCurrentPeriodEnd: currentPeriodEnd, plan: (session.metadata?.plan || 'FREE') as any, - }, - }); + }, + }); + + await scoreUserLifecycle(updatedUser.id, 'subscription_changed'); // Meta CAPI — Purchase event const amountCents = session.amount_total ?? 0; @@ -92,34 +95,43 @@ export async function POST(request: NextRequest) { ? new Date(periodEndTimestamp * 1000) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); - await db.user.update({ - where: { - stripeSubscriptionId: subscription.id, + await db.user.update({ + where: { + stripeSubscriptionId: subscription.id, }, data: { stripePriceId: subscription.items.data[0].price.id, stripeCurrentPeriodEnd: currentPeriodEnd, - }, - }); - break; - } + }, + }); + const updated = await db.user.findUnique({ + where: { stripeSubscriptionId: subscription.id }, + select: { id: true }, + }); + if (updated?.id) { + await scoreUserLifecycle(updated.id, 'subscription_changed'); + } + break; + } case 'customer.subscription.deleted': { const subscription = event.data.object as Stripe.Subscription; - await db.user.update({ - where: { - stripeSubscriptionId: subscription.id, - }, + const updatedUser = await db.user.update({ + where: { + stripeSubscriptionId: subscription.id, + }, data: { stripeSubscriptionId: null, stripePriceId: null, stripeCurrentPeriodEnd: null, - plan: 'FREE', - }, - }); - break; - } + plan: 'FREE', + }, + }); + + await scoreUserLifecycle(updatedUser.id, 'subscription_changed'); + break; + } } return NextResponse.json({ received: true }); diff --git a/src/app/(main)/onboarding/OnboardingClient.tsx b/src/app/(main)/onboarding/OnboardingClient.tsx new file mode 100644 index 0000000..39866a2 --- /dev/null +++ b/src/app/(main)/onboarding/OnboardingClient.tsx @@ -0,0 +1,1006 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import type { LucideIcon } from 'lucide-react'; +import { + ArrowRight, + BadgeCheck, + BarChart3, + BriefcaseBusiness, + Building2, + Check, + ChevronRight, + Compass, + ContactRound, + Facebook, + FileSpreadsheet, + FileText, + Globe, + Instagram, + Layers3, + LayoutDashboard, + Linkedin, + Music4, + Palette, + QrCode, + Radar, + Search, + Sparkles, + Target, + Users, + Wifi, + Youtube, +} from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Badge } from '@/components/ui/Badge'; +import { useCsrf } from '@/hooks/useCsrf'; +import { showToast } from '@/components/ui/Toast'; +import { + appendRedirectParam, + getPostOnboardingDestination, + sanitizeRedirectPath, +} from '@/lib/auth-flow'; +import { + getCreatePresetForUseCase, + getGoalLabel, + getOnboardingHeadlineForUseCase, + getRoleLabel, + getSourceLabel, + getTeamSizeLabel, + getUseCaseLabel, + JOB_ROLE_OPTIONS, + PRIMARY_GOAL_OPTIONS, + PRIMARY_USE_CASE_OPTIONS, + SIGNUP_SOURCE_OPTIONS, + TEAM_SIZE_OPTIONS, +} from '@/lib/revops'; +import { identifyUser, trackEvent } from '@/components/PostHogProvider'; + +type OnboardingState = { + id: string; + name: string | null; + email: string; + plan: string; + signupSource: string | null; + signupSourceSelfReported: string | null; + primaryUseCase: string | null; + primaryGoal: string | null; + jobRole: string | null; + companyName: string | null; + companyWebsite: string | null; + teamSizeBucket: string | null; + onboardingStartedAt: string | null; + sourceConfirmedAt: string | null; + useCaseSelectedAt: string | null; + goalSelectedAt: string | null; + profileCompletedAt: string | null; + firstQrCreatedAt: string | null; + firstDynamicQrAt: string | null; + firstScanAt: string | null; + activationAt: string | null; + onboardingCompletedAt: string | null; + lifecycleStage: string; +}; + +type RichOption = { + value: string; + label: string; + description: string; + icon: LucideIcon; + accentClassName: string; +}; + +const optionMeta = { + signupSource: { + google_search: { + description: 'High-intent discovery from search.', + icon: Search, + accentClassName: 'bg-blue-100 text-blue-700', + }, + instagram: { + description: 'Visual-first discovery and creator traffic.', + icon: Instagram, + accentClassName: 'bg-rose-100 text-rose-700', + }, + facebook: { + description: 'Community or ad-driven discovery.', + icon: Facebook, + accentClassName: 'bg-sky-100 text-sky-700', + }, + tiktok: { + description: 'Fast-moving creator and trend traffic.', + icon: Music4, + accentClassName: 'bg-fuchsia-100 text-fuchsia-700', + }, + linkedin: { + description: 'Professional discovery and referrals.', + icon: Linkedin, + accentClassName: 'bg-cyan-100 text-cyan-700', + }, + youtube: { + description: 'Long-form educational discovery.', + icon: Youtube, + accentClassName: 'bg-red-100 text-red-700', + }, + blog_article: { + description: 'Content-led research before signup.', + icon: FileText, + accentClassName: 'bg-amber-100 text-amber-700', + }, + friend_colleague: { + description: 'Word of mouth from your network.', + icon: Users, + accentClassName: 'bg-emerald-100 text-emerald-700', + }, + direct: { + description: 'You already knew what you wanted.', + icon: Compass, + accentClassName: 'bg-slate-100 text-slate-700', + }, + other: { + description: 'A different path brought you here.', + icon: Sparkles, + accentClassName: 'bg-violet-100 text-violet-700', + }, + }, + useCase: { + website_qr: { + description: 'Send people to a page you can update later.', + icon: Globe, + accentClassName: 'bg-blue-100 text-blue-700', + }, + menu_pdf: { + description: 'Share menus, brochures, or downloadable files.', + icon: FileText, + accentClassName: 'bg-amber-100 text-amber-700', + }, + contact_card: { + description: 'Create a fast shareable business card.', + icon: ContactRound, + accentClassName: 'bg-cyan-100 text-cyan-700', + }, + wifi_qr: { + description: 'Let guests connect without typing credentials.', + icon: Wifi, + accentClassName: 'bg-emerald-100 text-emerald-700', + }, + marketing_campaign: { + description: 'Track scans and performance by campaign.', + icon: Radar, + accentClassName: 'bg-violet-100 text-violet-700', + }, + barcode: { + description: 'Generate product-ready barcode assets.', + icon: Layers3, + accentClassName: 'bg-orange-100 text-orange-700', + }, + bulk_qr: { + description: 'Launch many codes from one upload workflow.', + icon: FileSpreadsheet, + accentClassName: 'bg-indigo-100 text-indigo-700', + }, + something_else: { + description: 'Start broadly and refine inside the creator.', + icon: Sparkles, + accentClassName: 'bg-slate-100 text-slate-700', + }, + }, + goal: { + drive_website_traffic: { + description: 'Guide more people into your core funnel.', + icon: Globe, + accentClassName: 'bg-blue-100 text-blue-700', + }, + track_printed_campaigns: { + description: 'Measure print and offline performance clearly.', + icon: BarChart3, + accentClassName: 'bg-violet-100 text-violet-700', + }, + share_contact_details: { + description: 'Make follow-up and contact capture frictionless.', + icon: ContactRound, + accentClassName: 'bg-cyan-100 text-cyan-700', + }, + replace_printed_menus: { + description: 'Keep menu content easy to update after printing.', + icon: FileText, + accentClassName: 'bg-amber-100 text-amber-700', + }, + generate_leads: { + description: 'Turn scans into measurable interest.', + icon: Target, + accentClassName: 'bg-emerald-100 text-emerald-700', + }, + label_products: { + description: 'Create clearer labeling and packaging systems.', + icon: Layers3, + accentClassName: 'bg-orange-100 text-orange-700', + }, + manage_multiple_qr_codes: { + description: 'Scale beyond one-off QR generation.', + icon: QrCode, + accentClassName: 'bg-indigo-100 text-indigo-700', + }, + something_else: { + description: 'We will still guide you to a strong setup.', + icon: Sparkles, + accentClassName: 'bg-slate-100 text-slate-700', + }, + }, + role: { + founder_owner: { + description: 'Move fast and own the outcome end to end.', + icon: BriefcaseBusiness, + accentClassName: 'bg-blue-100 text-blue-700', + }, + marketing_manager: { + description: 'Optimize campaign performance and attribution.', + icon: Radar, + accentClassName: 'bg-violet-100 text-violet-700', + }, + operations: { + description: 'Create reliable, repeatable real-world workflows.', + icon: Layers3, + accentClassName: 'bg-emerald-100 text-emerald-700', + }, + agency_freelancer: { + description: 'Deliver polished assets for multiple clients.', + icon: Palette, + accentClassName: 'bg-pink-100 text-pink-700', + }, + it_technical: { + description: 'Need control, structure, and clean setup paths.', + icon: Building2, + accentClassName: 'bg-cyan-100 text-cyan-700', + }, + sales: { + description: 'Help people convert fast from printed material.', + icon: Target, + accentClassName: 'bg-amber-100 text-amber-700', + }, + designer: { + description: 'Care about brand fit and presentation quality.', + icon: Sparkles, + accentClassName: 'bg-fuchsia-100 text-fuchsia-700', + }, + other: { + description: 'We will keep the setup flexible.', + icon: Users, + accentClassName: 'bg-slate-100 text-slate-700', + }, + }, + teamSize: { + just_me: { + description: 'A solo workflow with zero setup overhead.', + icon: Users, + accentClassName: 'bg-slate-100 text-slate-700', + }, + '2_5': { + description: 'A small team that needs quick alignment.', + icon: Users, + accentClassName: 'bg-blue-100 text-blue-700', + }, + '6_20': { + description: 'Multiple people need shared structure.', + icon: Users, + accentClassName: 'bg-cyan-100 text-cyan-700', + }, + '21_100': { + description: 'A growing org with more moving pieces.', + icon: Building2, + accentClassName: 'bg-violet-100 text-violet-700', + }, + '100_plus': { + description: 'You need consistency at scale.', + icon: Building2, + accentClassName: 'bg-emerald-100 text-emerald-700', + }, + }, +} as const; + +const stepContent = { + 1: { + eyebrow: 'Audience signal', + title: 'How did you hear about QR Master?', + description: 'This helps us understand which journeys are pulling in high-intent users. It does not affect your plan or access.', + }, + 2: { + eyebrow: 'First-use intent', + title: 'What do you want to create first?', + description: 'We use this choice to prepare a cleaner starting point instead of dropping you into a generic builder.', + }, + 3: { + eyebrow: 'Primary outcome', + title: 'What is your main goal?', + description: 'Your answer shapes the guidance, presets, and what we surface as the next best action.', + }, + 4: { + eyebrow: 'Operating context', + title: 'What best describes your role?', + description: 'Different roles care about different tradeoffs. This lets the setup feel more relevant and less noisy.', + }, + 5: { + eyebrow: 'Workspace context', + title: 'Who are you creating QR codes for?', + description: 'Add workspace context now or skip and keep moving. This is optional and mainly helps future organization.', + }, + 6: { + eyebrow: 'Scale signal', + title: 'How big is your team?', + description: 'We use this to understand whether you need a lightweight solo workflow or a more structured setup path.', + }, + 7: { + eyebrow: 'Guided setup', + title: 'Your workspace is ready for the first build', + description: 'We selected the strongest starting point based on your answers so you can get to a real QR asset faster.', + }, + 8: { + eyebrow: 'First result', + title: 'Your first QR code is ready', + description: 'You are past setup and into execution. From here the focus shifts to management, refinement, and scale.', + }, +} as const; + +const stepLabels = [ + 'Source', + 'Use case', + 'Goal', + 'Role', + 'Workspace', + 'Team', + 'Launch', + 'Finish', +]; + +function deriveStep(state: OnboardingState): number { + if (state.firstQrCreatedAt) return 8; + if (state.profileCompletedAt || state.teamSizeBucket) return 7; + if (state.companyName || state.companyWebsite) return 6; + if (state.jobRole) return 5; + if (state.goalSelectedAt || state.primaryGoal) return 4; + if (state.useCaseSelectedAt || state.primaryUseCase) return 3; + if (state.sourceConfirmedAt || state.signupSourceSelfReported) return 2; + return 1; +} + +function buildOptions( + options: Array<{ value: string; label: string }>, + metaMap: Record +): RichOption[] { + return options.map((option) => ({ + value: option.value, + label: option.label, + description: metaMap[option.value]?.description || 'Selected for a tailored setup.', + icon: metaMap[option.value]?.icon || Sparkles, + accentClassName: metaMap[option.value]?.accentClassName || 'bg-slate-100 text-slate-700', + })); +} + +function StepOptions({ + options, + value, + onSelect, +}: { + options: RichOption[]; + value: string; + onSelect: (nextValue: string) => void; +}) { + return ( +
+ {options.map((option) => { + const Icon = option.icon; + const selected = value === option.value; + + return ( + + ); + })} +
+ ); +} + +function SummaryItem({ + label, + value, + icon: Icon, +}: { + label: string; + value: string; + icon: LucideIcon; +}) { + return ( +
+
+
+ +
+
+

{label}

+

{value}

+
+
+
+ ); +} + +export default function OnboardingClient() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { fetchWithCsrf } = useCsrf(); + const [state, setState] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [step, setStep] = useState(1); + const [source, setSource] = useState(''); + const [useCase, setUseCase] = useState(''); + const [goal, setGoal] = useState(''); + const [jobRole, setJobRole] = useState(''); + const [companyName, setCompanyName] = useState(''); + const [companyWebsite, setCompanyWebsite] = useState(''); + const [teamSize, setTeamSize] = useState(''); + const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect')); + + useEffect(() => { + const fetchState = async () => { + try { + const response = await fetch('/api/onboarding'); + if (!response.ok) { + router.replace(appendRedirectParam('/login', redirectTarget)); + return; + } + + const data = (await response.json()) as OnboardingState; + setState(data); + setSource(data.signupSourceSelfReported || data.signupSource || ''); + setUseCase(data.primaryUseCase || ''); + setGoal(data.primaryGoal || ''); + setJobRole(data.jobRole || ''); + setCompanyName(data.companyName || ''); + setCompanyWebsite(data.companyWebsite || ''); + setTeamSize(data.teamSizeBucket || ''); + + const explicitStep = Number(searchParams.get('step') || ''); + const nextStep = explicitStep >= 1 && explicitStep <= 8 ? explicitStep : deriveStep(data); + setStep(nextStep); + + if (searchParams.get('authMethod') === 'google') { + identifyUser(data.id, { + email: data.email, + name: data.name, + plan: data.plan || 'FREE', + provider: 'google', + }); + trackEvent(searchParams.get('isNewUser') === 'true' ? 'user_signup' : 'user_login', { + method: 'google', + email: data.email, + isNewUser: searchParams.get('isNewUser') === 'true', + }); + router.replace( + appendRedirectParam('/onboarding', redirectTarget, nextStep === 8 ? { step: '8' } : undefined) + ); + } + } catch (error) { + console.error('Failed to load onboarding state:', error); + } finally { + setLoading(false); + } + }; + + fetchState(); + }, [redirectTarget, router, searchParams]); + + const createPreset = useMemo( + () => getCreatePresetForUseCase(useCase || state?.primaryUseCase), + [state?.primaryUseCase, useCase] + ); + + const signupSourceOptions = useMemo( + () => buildOptions(SIGNUP_SOURCE_OPTIONS, optionMeta.signupSource), + [] + ); + const useCaseOptions = useMemo( + () => buildOptions(PRIMARY_USE_CASE_OPTIONS, optionMeta.useCase), + [] + ); + const goalOptions = useMemo( + () => buildOptions(PRIMARY_GOAL_OPTIONS, optionMeta.goal), + [] + ); + const roleOptions = useMemo( + () => buildOptions(JOB_ROLE_OPTIONS, optionMeta.role), + [] + ); + const teamOptions = useMemo( + () => buildOptions(TEAM_SIZE_OPTIONS, optionMeta.teamSize), + [] + ); + + useEffect(() => { + if (state?.onboardingCompletedAt && step !== 8) { + router.replace(getPostOnboardingDestination(redirectTarget)); + } + }, [redirectTarget, router, state?.onboardingCompletedAt, step]); + + const saveStep = async (payload: Record, nextStep: number) => { + setSaving(true); + try { + const response = await fetchWithCsrf('/api/onboarding', { + method: 'PATCH', + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to save onboarding step'); + } + + setState(data.state); + setStep(nextStep); + } catch (error) { + console.error(error); + showToast(error instanceof Error ? error.message : 'Failed to save onboarding step', 'error'); + } finally { + setSaving(false); + } + }; + + if (loading || !state) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (state.onboardingCompletedAt && step !== 8) { + return null; + } + + const activeSource = source || state.signupSourceSelfReported || state.signupSource || ''; + const activeUseCase = useCase || state.primaryUseCase || ''; + const activeGoal = goal || state.primaryGoal || ''; + const activeRole = jobRole || state.jobRole || ''; + const activeTeam = teamSize || state.teamSizeBucket || ''; + const progress = Math.min(100, Math.round((step / 8) * 100)); + const currentStep = stepContent[step as keyof typeof stepContent]; + const guidedHeadline = getOnboardingHeadlineForUseCase(activeUseCase || state.primaryUseCase); + const createHref = appendRedirectParam( + `/create?onboarding=1&source=onboarding&useCase=${encodeURIComponent(activeUseCase || '')}&contentType=${createPreset.contentType}&dynamic=${createPreset.dynamic ? '1' : '0'}&title=${encodeURIComponent(createPreset.title)}`, + redirectTarget + ); + const completionText = step >= 8 ? 'Ready to scale' : `${8 - step} steps left`; + const destinationHref = getPostOnboardingDestination(redirectTarget); + + const summaryItems = [ + activeSource ? { label: 'Source', value: getSourceLabel(activeSource), icon: Compass } : null, + activeUseCase ? { label: 'Use case', value: getUseCaseLabel(activeUseCase), icon: QrCode } : null, + activeGoal ? { label: 'Goal', value: getGoalLabel(activeGoal), icon: Target } : null, + activeRole ? { label: 'Role', value: getRoleLabel(activeRole), icon: BriefcaseBusiness } : null, + companyName ? { label: 'Workspace', value: companyName, icon: Building2 } : null, + activeTeam ? { label: 'Team', value: getTeamSizeLabel(activeTeam), icon: Users } : null, + ].filter(Boolean) as Array<{ label: string; value: string; icon: LucideIcon }>; + + return ( +
+
+
+
+
+
+
+ +
+
+ + + + + QR MASTER + + + Step {step} of 8 + +
+ +
+
+
+ + Personalized setup +
+ +

+ Build a sharper first-run experience, not a generic setup wizard. +

+

+ Answer a few focused questions and we will shape the workspace, first QR preset, and next action around how you actually plan to use QR Master. +

+ +
+
+
+ +
+

Cleaner first build

+

We skip the dead-end defaults and move you into a stronger starting setup.

+
+
+
+ +
+

Sharper guidance

+

Your use case and goal drive the recommendations instead of a one-size-fits-all flow.

+
+
+
+ +
+

Faster activation

+

The goal is not to collect data. The goal is to get you to a real QR asset faster.

+
+
+ +
+
+
+

Progress

+

{progress}% complete

+
+
+ {completionText} +
+
+
+
+
+ +
+ {stepLabels.map((label, index) => { + const stepNumber = index + 1; + const isCurrent = stepNumber === step; + const isDone = stepNumber < step; + + return ( +
+
+ {isDone ? : stepNumber} +
+
+

{label}

+

{isCurrent ? 'Current step' : isDone ? 'Completed' : 'Upcoming'}

+
+
+ ); + })} +
+
+ + {summaryItems.length > 0 && ( +
+
+ + Your setup + + Live summary based on your answers so far. +
+
+ {summaryItems.map((item) => ( + + ))} +
+
+ )} +
+ +
+
+
+

{currentStep.eyebrow}

+

{currentStep.title}

+

{currentStep.description}

+
+
+ +
+
+ +
+ {step === 1 && ( +
+ + +
+ )} + + {step === 2 && ( +
+ + +
+ )} + + {step === 3 && ( +
+ + +
+ )} + + {step === 4 && ( +
+ + +
+ )} + + {step === 5 && ( +
+
+
+
+ +
+
+

Optional but useful

+

If you add workspace details now, the dashboard feels more organized later. You can still skip without blocking the flow.

+
+
+
+ +
+ setCompanyName(event.target.value)} + placeholder="Acme Studio" + className="h-12 rounded-2xl border-slate-200 px-4" + /> + setCompanyWebsite(event.target.value)} + placeholder="acme.com" + className="h-12 rounded-2xl border-slate-200 px-4" + /> +
+ +
+ + +
+
+ )} + + {step === 6 && ( +
+ + +
+ )} + + {step === 7 && ( +
+
+
+
+ +
+
+

{guidedHeadline}

+

+ We preselected a strong default: {createPreset.contentType}{' '} + with {createPreset.dynamic ? 'dynamic tracking' : 'static delivery'}. +

+
+
+
+ +
+
+

Use case

+

{getUseCaseLabel(activeUseCase || state.primaryUseCase)}

+
+
+

Primary goal

+

{getGoalLabel(activeGoal || state.primaryGoal)}

+
+
+ + + + + +
+ )} + + {step === 8 && ( +
+
+
+
+ +
+
+

You are set up for the next step.

+

+ {activeUseCase === 'marketing_campaign' || state.primaryUseCase === 'marketing_campaign' + ? 'Use the dashboard to manage scans, compare performance, and keep campaign assets organized.' + : activeUseCase === 'menu_pdf' || state.primaryUseCase === 'menu_pdf' + ? 'Open the dashboard to manage downloads, keep the destination current, and maintain the asset over time.' + : 'Head into the dashboard to manage the asset, download files, and keep the destination editable after printing.'} +

+
+
+
+ + + + + +
+ +
+
+ +
+

+ {destinationHref === '/dashboard' ? 'Manage and download' : 'Continue where you started'} +

+

+ {destinationHref === '/dashboard' + ? 'Open your QR library, handle downloads, and keep everything in one place.' + : 'Return to the protected page you originally tried to open.'} +

+
+ + + +
+
+ +
+

Create another QR code

+

Use the same momentum to build your next asset right away.

+
+ +
+
+ )} +
+
+
+
+
+ ); +} diff --git a/src/app/(main)/onboarding/page.tsx b/src/app/(main)/onboarding/page.tsx new file mode 100644 index 0000000..2e11e99 --- /dev/null +++ b/src/app/(main)/onboarding/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; +import OnboardingClient from './OnboardingClient'; + +export const metadata: Metadata = { + title: 'Onboarding | QR Master', + description: 'Set up your QR Master workspace and create your first QR code.', + robots: { + index: false, + follow: false, + }, +}; + +export default function OnboardingPage() { + return ; +} diff --git a/src/app/(main)/r/[slug]/route.ts b/src/app/(main)/r/[slug]/route.ts index 87625dd..b975803 100644 --- a/src/app/(main)/r/[slug]/route.ts +++ b/src/app/(main)/r/[slug]/route.ts @@ -1,6 +1,7 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/lib/db'; -import { hashIP } from '@/lib/hash'; +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { hashIP } from '@/lib/hash'; +import { triggerLifecycleScoring } from '@/lib/revops-server'; export async function GET( request: NextRequest, @@ -10,13 +11,14 @@ export async function GET( const { slug } = await params; // Fetch QR code by slug - const qrCode = await db.qRCode.findUnique({ - where: { slug }, - select: { - id: true, - content: true, - contentType: true, - }, + const qrCode = await db.qRCode.findUnique({ + where: { slug }, + select: { + id: true, + userId: true, + content: true, + contentType: true, + }, }); if (!qrCode) { @@ -24,7 +26,7 @@ export async function GET( } // Track scan (fire and forget) - trackScan(qrCode.id, request).catch(console.error); + trackScan(qrCode.id, qrCode.userId, request).catch(console.error); // Determine destination URL let destination = ''; @@ -121,7 +123,7 @@ export async function GET( } } -async function trackScan(qrId: string, request: NextRequest) { +async function trackScan(qrId: string, userId: string, request: NextRequest) { try { const userAgent = request.headers.get('user-agent') || ''; const referer = request.headers.get('referer') || ''; @@ -133,15 +135,30 @@ async function trackScan(qrId: string, request: NextRequest) { const dnt = request.headers.get('dnt'); if (dnt === '1') { // Respect Do Not Track - only increment counter - await db.qRScan.create({ - data: { - qrId, - ipHash: 'dnt', - isUnique: false, - }, - }); - return; - } + const scanTimestamp = new Date(); + await db.qRScan.create({ + data: { + qrId, + ipHash: 'dnt', + isUnique: false, + ts: scanTimestamp, + }, + }); + const activatedUsers = await db.user.updateMany({ + where: { + id: userId, + firstScanAt: null, + }, + data: { + firstScanAt: scanTimestamp, + activationAt: scanTimestamp, + }, + }); + if (activatedUsers.count > 0) { + triggerLifecycleScoring(userId, 'scan_recorded'); + } + return; + } // Hash IP for privacy const ipHash = hashIP(ip); @@ -222,22 +239,39 @@ async function trackScan(qrId: string, request: NextRequest) { const isUnique = !existingScan; // Create scan record - await db.qRScan.create({ - data: { - qrId, - ipHash, - userAgent: userAgent.substring(0, 255), - device, + const scanTimestamp = new Date(); + await db.qRScan.create({ + data: { + qrId, + ts: scanTimestamp, + ipHash, + userAgent: userAgent.substring(0, 255), + device, os, country, referrer: referer.substring(0, 255), utmSource, utmMedium, utmCampaign, - isUnique, - }, - }); - } catch (error) { + isUnique, + }, + }); + + const activatedUsers = await db.user.updateMany({ + where: { + id: userId, + firstScanAt: null, + }, + data: { + firstScanAt: scanTimestamp, + activationAt: scanTimestamp, + }, + }); + + if (activatedUsers.count > 0) { + triggerLifecycleScoring(userId, 'scan_recorded'); + } + } catch (error) { // Don't throw - this is fire and forget } } @@ -250,4 +284,4 @@ function ensureAbsoluteUrl(url: string): string { } // Default to https for web URLs return `https://${url}`; -} \ No newline at end of file +} diff --git a/src/components/dashboard/OnboardingChecklist.tsx b/src/components/dashboard/OnboardingChecklist.tsx new file mode 100644 index 0000000..bc08a24 --- /dev/null +++ b/src/components/dashboard/OnboardingChecklist.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { ArrowRight, Check, QrCode, Sparkles, X } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { Card, CardContent } from '@/components/ui/Card'; +import { + getChecklistItems, + ONBOARDING_CHECKLIST_DISMISS_KEY, +} from '@/lib/revops'; + +type OnboardingChecklistProps = { + state: { + signupSourceSelfReported?: string | null; + primaryUseCase?: string | null; + firstQrCreatedAt?: string | null; + firstDynamicQrAt?: string | null; + firstScanAt?: string | null; + activationAt?: string | null; + } | null; +}; + +export function OnboardingChecklist({ state }: OnboardingChecklistProps) { + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + setDismissed(localStorage.getItem(ONBOARDING_CHECKLIST_DISMISS_KEY) === '1'); + }, []); + + if (!state || state.firstScanAt || dismissed) { + return null; + } + + const items = getChecklistItems(state); + const completed = items.filter((item) => item.done).length; + const progress = Math.round((completed / items.length) * 100); + + return ( + +
+
+
+
+ + Activation path +
+

Get to your first result faster

+

+ Finish the onboarding checklist to unlock a cleaner dashboard state and move from setup into real usage. +

+
+
+ + {completed}/{items.length} done + + +
+
+ +
+
+
+
+ + +
+ {items.map((item) => ( +
+
+
+ {item.done ? : } +
+
+

+ {item.label} +

+

+ {item.done ? 'Completed and saved in your setup state.' : 'Still pending. Finish this to move closer to activation.'} +

+
+
+
+ ))} +
+ +
+ + + + +
+
+ + ); +} diff --git a/src/components/marketing/AIComingSoonBanner.tsx b/src/components/marketing/AIComingSoonBanner.tsx index 8a3aadd..05c1cc5 100644 --- a/src/components/marketing/AIComingSoonBanner.tsx +++ b/src/components/marketing/AIComingSoonBanner.tsx @@ -1,9 +1,10 @@ 'use client'; -import React, { useState } from 'react'; -import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react'; -import Link from 'next/link'; -import { motion } from 'framer-motion'; +import React, { useState } from 'react'; +import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react'; +import Link from 'next/link'; +import { motion } from 'framer-motion'; +import { trackEvent } from '@/components/PostHogProvider'; const AIComingSoonBanner = () => { const [email, setEmail] = useState(''); @@ -25,14 +26,21 @@ const AIComingSoonBanner = () => { body: JSON.stringify({ email }), }); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Failed to subscribe'); - } - - setSubmitted(true); - setEmail(''); + const data = await response.json(); + + if (!response.ok) { + const errorMessage = typeof data?.error === 'string' + ? data.error + : data?.details?.[0]?.message || 'Failed to subscribe'; + throw new Error(errorMessage); + } + + trackEvent('newsletter_subscribed', { + source: 'ai_coming_soon_banner', + already_subscribed: Boolean(data.alreadySubscribed), + }); + setSubmitted(true); + setEmail(''); } catch (err) { setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.'); } finally { diff --git a/src/lib/auth-flow.ts b/src/lib/auth-flow.ts new file mode 100644 index 0000000..5557c7d --- /dev/null +++ b/src/lib/auth-flow.ts @@ -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 { + 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: [