revops + onboarding

This commit is contained in:
Timo Knuth
2026-04-22 20:00:44 +02:00
parent ce724662d4
commit 7d2724b65d
37 changed files with 5073 additions and 1286 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 KiB

View File

@@ -0,0 +1,776 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>QR Code is paused</title>
<style>
:root {
--bg: #f5f7fb;
--bg-2: #edf2f8;
--panel: rgba(255, 255, 255, 0.72);
--panel-strong: rgba(255, 255, 255, 0.84);
--line: rgba(148, 163, 184, 0.24);
--text: #0f172a;
--muted: #667085;
--soft: #94a3b8;
--blue: #2563eb;
--blue-soft: rgba(37, 99, 235, 0.14);
--amber: #f59e0b;
--amber-soft: rgba(245, 158, 11, 0.14);
--shadow: 0 40px 120px rgba(15, 23, 42, 0.12);
--shadow-soft: 0 24px 70px rgba(148, 163, 184, 0.16);
--radius-xl: 34px;
--radius-lg: 24px;
--radius-md: 18px;
--ease: cubic-bezier(.22, 1, .36, 1);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.95), transparent 28%),
radial-gradient(circle at 85% 18%, rgba(37, 99, 235, 0.08), transparent 24%),
radial-gradient(circle at 50% 82%, rgba(245, 158, 11, 0.08), transparent 20%),
linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", sans-serif;
overflow-x: hidden;
}
body::before,
body::after {
content: "";
position: fixed;
inset: auto;
pointer-events: none;
filter: blur(80px);
opacity: 0.9;
z-index: 0;
}
body::before {
width: 24rem;
height: 24rem;
top: 6rem;
right: -6rem;
background: rgba(37, 99, 235, 0.13);
animation: drift 18s ease-in-out infinite alternate;
}
body::after {
width: 20rem;
height: 20rem;
bottom: 2rem;
left: -4rem;
background: rgba(245, 158, 11, 0.09);
animation: drift 22s ease-in-out infinite alternate-reverse;
}
.grain {
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.06;
z-index: 0;
background-image:
linear-gradient(rgba(15, 23, 42, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(15, 23, 42, 0.08) 1px, transparent 1px);
background-size: 4px 4px;
mix-blend-mode: soft-light;
}
.shell {
position: relative;
z-index: 1;
min-height: 100vh;
padding: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.frame {
width: min(1320px, 100%);
min-height: min(860px, calc(100vh - 56px));
padding: 28px;
border-radius: 42px;
position: relative;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.36));
border: 1px solid rgba(255, 255, 255, 0.8);
box-shadow: var(--shadow);
backdrop-filter: blur(24px);
overflow: hidden;
}
.frame::before,
.frame::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
}
.frame::before {
background:
linear-gradient(120deg, rgba(255, 255, 255, 0.55), transparent 26%),
radial-gradient(circle at 76% 24%, rgba(37, 99, 235, 0.08), transparent 18%);
mix-blend-mode: screen;
}
.frame::after {
inset: 18px;
border-radius: 30px;
border: 1px solid rgba(255, 255, 255, 0.72);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 34px;
position: relative;
z-index: 1;
}
.wordmark {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.62);
border: 1px solid rgba(255, 255, 255, 0.82);
box-shadow: 0 12px 40px rgba(148, 163, 184, 0.14);
color: var(--text);
font-size: 14px;
letter-spacing: 0.02em;
font-weight: 600;
backdrop-filter: blur(18px);
}
.wordmark-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: linear-gradient(180deg, #6ea8ff, #2563eb);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
}
.availability {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.56);
border: 1px solid rgba(255, 255, 255, 0.76);
color: var(--soft);
font-size: 13px;
backdrop-filter: blur(18px);
}
.availability span {
width: 8px;
height: 8px;
border-radius: 50%;
background: linear-gradient(180deg, #f6bf54, #f59e0b);
box-shadow: 0 0 0 5px rgba(245, 158, 11, 0.12);
animation: pulse 2.8s ease-in-out infinite;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 0.94fr) minmax(360px, 1.06fr);
gap: 42px;
align-items: center;
min-height: calc(100% - 78px);
position: relative;
z-index: 1;
}
.copy {
max-width: 540px;
padding: 10px 6px 10px 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.64);
border: 1px solid rgba(255, 255, 255, 0.86);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(18px);
margin-bottom: 24px;
}
.badge-icon {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 50%;
background: linear-gradient(180deg, rgba(245, 158, 11, 0.18), rgba(245, 158, 11, 0.08));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
}
.pause-bars {
width: 10px;
height: 12px;
position: relative;
}
.pause-bars::before,
.pause-bars::after {
content: "";
position: absolute;
top: 0;
width: 3px;
height: 12px;
border-radius: 999px;
background: var(--amber);
box-shadow: 0 0 16px rgba(245, 158, 11, 0.18);
}
.pause-bars::before { left: 1px; }
.pause-bars::after { right: 1px; }
.badge-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.badge-copy strong {
font-size: 13px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--soft);
font-weight: 600;
}
.badge-copy span {
font-size: 14px;
color: var(--text);
font-weight: 600;
}
h1 {
margin: 0;
font-size: clamp(3.5rem, 5vw, 5.5rem);
line-height: 0.94;
letter-spacing: -0.055em;
font-weight: 700;
max-width: 10ch;
}
.lede {
margin: 24px 0 0;
font-size: clamp(1.08rem, 1.9vw, 1.28rem);
line-height: 1.55;
color: var(--muted);
max-width: 47ch;
letter-spacing: -0.01em;
}
.subcopy {
margin: 14px 0 0;
color: var(--soft);
font-size: 15px;
line-height: 1.7;
max-width: 50ch;
}
.actions {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
margin-top: 32px;
}
.button,
.link-button {
appearance: none;
border: 0;
text-decoration: none;
cursor: pointer;
transition:
transform 220ms var(--ease),
box-shadow 220ms var(--ease),
background-color 220ms var(--ease),
border-color 220ms var(--ease),
color 220ms var(--ease);
}
.button {
padding: 16px 22px;
border-radius: 999px;
background: linear-gradient(180deg, #2f6fff, #2563eb);
color: #fff;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
box-shadow:
0 14px 40px rgba(37, 99, 235, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.button:hover,
.button:focus-visible {
transform: translateY(-1px);
box-shadow:
0 20px 48px rgba(37, 99, 235, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
.link-button {
padding: 16px 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.8);
color: var(--text);
font-size: 15px;
font-weight: 600;
box-shadow: 0 10px 30px rgba(148, 163, 184, 0.14);
backdrop-filter: blur(18px);
}
.link-button:hover,
.link-button:focus-visible {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.7);
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 34px;
}
.meta-card {
min-width: 185px;
padding: 16px 18px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.48);
border: 1px solid rgba(255, 255, 255, 0.76);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(18px);
}
.meta-card .eyebrow {
display: block;
margin-bottom: 8px;
color: var(--soft);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
font-weight: 700;
}
.meta-card strong {
display: block;
font-size: 17px;
letter-spacing: -0.03em;
margin-bottom: 5px;
}
.meta-card p {
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.55;
}
.visual {
position: relative;
min-height: 630px;
display: grid;
place-items: center;
isolation: isolate;
perspective: 1200px;
}
.visual::before,
.visual::after {
content: "";
position: absolute;
border-radius: 50%;
pointer-events: none;
filter: blur(30px);
transition: transform 240ms var(--ease);
}
.visual::before {
width: 460px;
height: 460px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0) 72%);
z-index: 0;
}
.visual::after {
width: 320px;
height: 320px;
background: radial-gradient(circle, rgba(37, 99, 235, 0.14) 0%, rgba(37, 99, 235, 0) 72%);
top: 14%;
right: 14%;
z-index: 0;
}
.orbital-ring {
position: absolute;
width: 480px;
height: 480px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow:
0 0 0 18px rgba(255, 255, 255, 0.12),
inset 0 0 40px rgba(255, 255, 255, 0.24);
opacity: 0.9;
z-index: 1;
animation: ring 14s linear infinite;
}
.glass-panel {
position: absolute;
inset: 12% 8%;
border-radius: var(--radius-xl);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.26), rgba(255, 255, 255, 0.08));
border: 1px solid rgba(255, 255, 255, 0.38);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
transform: translate3d(0, 0, 0) rotateX(10deg);
z-index: 1;
}
.hero-art {
position: relative;
width: min(100%, 720px);
padding: 44px;
border-radius: 42px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0.14));
border: 1px solid rgba(255, 255, 255, 0.62);
box-shadow:
0 32px 100px rgba(148, 163, 184, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.48);
backdrop-filter: blur(30px);
z-index: 2;
transform-style: preserve-3d;
transition: transform 240ms var(--ease);
animation: float 7s ease-in-out infinite;
}
.hero-art::before,
.hero-art::after {
content: "";
position: absolute;
inset: 16px;
border-radius: 30px;
pointer-events: none;
}
.hero-art::before {
border: 1px solid rgba(255, 255, 255, 0.5);
}
.hero-art::after {
background:
linear-gradient(140deg, rgba(255, 255, 255, 0.26), transparent 28%),
linear-gradient(320deg, rgba(245, 158, 11, 0.08), transparent 34%);
mix-blend-mode: screen;
}
.hero-art img {
display: block;
width: 100%;
height: auto;
border-radius: 28px;
transform: translateZ(40px);
filter: saturate(1.02) contrast(1.02);
box-shadow: 0 30px 90px rgba(148, 163, 184, 0.22);
user-select: none;
-webkit-user-drag: none;
}
.scan-caption {
position: absolute;
left: 50%;
bottom: 28px;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.88);
box-shadow: 0 12px 40px rgba(148, 163, 184, 0.18);
font-size: 13px;
color: var(--muted);
backdrop-filter: blur(18px);
z-index: 3;
white-space: nowrap;
}
.scan-caption strong {
color: var(--text);
font-weight: 600;
}
.scan-caption .beam {
width: 34px;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(245, 158, 11, 0), rgba(245, 158, 11, 1), rgba(245, 158, 11, 0));
box-shadow: 0 0 16px rgba(245, 158, 11, 0.45);
}
.footer-note {
margin-top: 18px;
color: var(--soft);
font-size: 12px;
letter-spacing: 0.02em;
}
@keyframes float {
0%, 100% { transform: translate3d(0, 0, 0) rotateX(0deg) rotateY(0deg); }
50% { transform: translate3d(0, -10px, 0) rotateX(1.5deg) rotateY(-1.5deg); }
}
@keyframes drift {
0% { transform: translate3d(0, 0, 0) scale(1); }
100% { transform: translate3d(-18px, 14px, 0) scale(1.08); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 0.85; }
50% { transform: scale(1.18); opacity: 1; }
}
@keyframes ring {
from { transform: rotate(0deg) scale(1); }
50% { transform: rotate(180deg) scale(1.02); }
to { transform: rotate(360deg) scale(1); }
}
@media (max-width: 1080px) {
.shell {
padding: 18px;
}
.frame {
min-height: auto;
padding: 22px;
}
.hero {
grid-template-columns: 1fr;
gap: 24px;
}
.copy {
max-width: 100%;
}
.visual {
min-height: 520px;
order: -1;
}
.hero-art {
max-width: 640px;
}
}
@media (max-width: 720px) {
.shell {
padding: 10px;
}
.frame {
padding: 16px;
border-radius: 28px;
}
.topbar {
margin-bottom: 18px;
}
.availability {
display: none;
}
h1 {
font-size: clamp(2.8rem, 12vw, 4rem);
max-width: 12ch;
}
.lede {
font-size: 1rem;
}
.actions,
.meta {
gap: 10px;
}
.button,
.link-button {
width: 100%;
justify-content: center;
text-align: center;
}
.visual {
min-height: 380px;
}
.hero-art {
padding: 18px;
border-radius: 24px;
}
.hero-art::before,
.hero-art::after {
inset: 10px;
border-radius: 18px;
}
.hero-art img {
border-radius: 16px;
}
.orbital-ring {
width: 300px;
height: 300px;
}
.scan-caption {
bottom: 14px;
max-width: calc(100% - 30px);
white-space: normal;
text-align: center;
line-height: 1.45;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}
</style>
</head>
<body>
<div class="grain" aria-hidden="true"></div>
<main class="shell">
<section class="frame">
<header class="topbar">
<div class="wordmark">
<span class="wordmark-dot" aria-hidden="true"></span>
QR Master
</div>
<div class="availability">
<span aria-hidden="true"></span>
Scanning temporarily unavailable
</div>
</header>
<div class="hero">
<div class="copy">
<div class="badge">
<div class="badge-icon" aria-hidden="true">
<div class="pause-bars"></div>
</div>
<div class="badge-copy">
<strong>Status</strong>
<span>Paused by owner</span>
</div>
</div>
<h1>QR Code is paused</h1>
<p class="lede">
This QR code has been temporarily disabled by its owner, so scanning is currently unavailable.
</p>
<p class="subcopy">
Please try again later or contact the owner for the active link. Paused codes should feel intentional and trustworthy, not broken.
</p>
<div class="actions">
<a class="button" href="#">Go to QR Master</a>
<a class="link-button" href="#">Need help?</a>
</div>
<div class="meta">
<article class="meta-card">
<span class="eyebrow">Redirect</span>
<strong>Temporarily disabled</strong>
<p>No destination opens while this code remains paused.</p>
</article>
<article class="meta-card">
<span class="eyebrow">Tracking</span>
<strong>Scan logging stopped</strong>
<p>Paused scans should not continue into analytics.</p>
</article>
</div>
<p class="footer-note">Preview concept for a standalone paused-state page.</p>
</div>
<div class="visual" id="visualStage">
<div class="orbital-ring" aria-hidden="true"></div>
<div class="glass-panel" aria-hidden="true"></div>
<figure class="hero-art" id="heroArt">
<img src="./paused-qr-hero-cinematic.png" alt="Glass-like QR tile floating in a cinematic studio environment">
</figure>
<div class="scan-caption">
<strong>Paused</strong>
<span class="beam" aria-hidden="true"></span>
The scan was intentionally interrupted
</div>
</div>
</div>
</section>
</main>
<script>
const heroArt = document.getElementById('heroArt');
const visualStage = document.getElementById('visualStage');
if (heroArt && visualStage && window.matchMedia('(prefers-reduced-motion: no-preference)').matches) {
visualStage.addEventListener('pointermove', (event) => {
const bounds = visualStage.getBoundingClientRect();
const x = (event.clientX - bounds.left) / bounds.width - 0.5;
const y = (event.clientY - bounds.top) / bounds.height - 0.5;
const rotateX = y * -10;
const rotateY = x * 12;
const translateX = x * 12;
const translateY = y * 8;
heroArt.style.transform =
`translate3d(${translateX}px, ${translateY}px, 0) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
});
visualStage.addEventListener('pointerleave', () => {
heroArt.style.transform = '';
});
}
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -37,10 +37,50 @@ model User {
upgradeNudgeSentAt DateTime? upgradeNudgeSentAt DateTime?
thirtyDayNudgeSentAt 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[] qrCodes QRCode[]
integrations Integration[] integrations Integration[]
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
lifecycleLogs UserLifecycleLog[]
} }
enum Plan { enum Plan {
@@ -161,6 +201,20 @@ model Integration {
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 { model NewsletterSubscription {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { toPng } from 'html-to-image'; import { toPng } from 'html-to-image';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
@@ -14,6 +14,8 @@ import { calculateContrast, cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { trackEvent } from '@/components/PostHogProvider';
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
import { import {
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon
} from 'lucide-react'; } from 'lucide-react';
@@ -101,6 +103,7 @@ function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string
export default function CreatePage() { export default function CreatePage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const { fetchWithCsrf } = useCsrf(); const { fetchWithCsrf } = useCsrf();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -166,6 +169,42 @@ export default function CreatePage() {
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 contrast = calculateContrast(foregroundColor, backgroundColor);
const hasGoodContrast = contrast >= 4.5; const hasGoodContrast = contrast >= 4.5;
@@ -232,6 +271,12 @@ export default function CreatePage() {
link.download = `qrcode-${title || 'download'}.png`; link.download = `qrcode-${title || 'download'}.png`;
link.href = dataUrl; link.href = dataUrl;
link.click(); link.click();
trackEvent('qr_code_downloaded', {
format: 'png',
content_type: contentType,
qr_type: isDynamic ? 'dynamic' : 'static',
plan: userPlan,
});
} else { } else {
// For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed // 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. // Simplest is to check if we can export the SVG element directly but that misses the frame HTML.
@@ -259,6 +304,12 @@ export default function CreatePage() {
a.download = `qrcode-${title || 'download'}.svg`; a.download = `qrcode-${title || 'download'}.svg`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
trackEvent('qr_code_downloaded', {
format: 'svg',
content_type: contentType,
qr_type: isDynamic ? 'dynamic' : 'static',
plan: userPlan,
});
} }
} else { } else {
showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info'); showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info');
@@ -267,6 +318,13 @@ export default function CreatePage() {
link.download = `qrcode-${title || 'download'}.png`; link.download = `qrcode-${title || 'download'}.png`;
link.href = dataUrl; link.href = dataUrl;
link.click(); 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) { } catch (err) {
@@ -355,15 +413,35 @@ export default function CreatePage() {
console.log('RESPONSE DATA:', responseData); console.log('RESPONSE DATA:', responseData);
if (response.ok) { 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'); showToast(`QR Code "${title}" created successfully!`, 'success');
// Wait a moment so user sees the toast, then redirect // Wait a moment so user sees the toast, then redirect
setTimeout(() => { setTimeout(() => {
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
if (searchParams.get('onboarding') === '1') {
router.push(appendRedirectParam('/onboarding', redirectTarget, { step: '8' }));
} else {
router.push('/dashboard'); router.push('/dashboard');
}
router.refresh(); router.refresh();
}, 1000); }, 1000);
} else { } else {
console.error('Error creating QR code:', responseData); 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'); showToast(responseData.error || 'Error creating QR code', 'error');
} }
} catch (error) { } catch (error) {

View File

@@ -13,6 +13,9 @@ import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
import { QrCode } from 'lucide-react'; 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 { interface QRCodeData {
id: string; id: string;
@@ -45,6 +48,7 @@ export default function DashboardPage() {
uniqueScans: 0, uniqueScans: 0,
}); });
const [analyticsData, setAnalyticsData] = useState<any>(null); const [analyticsData, setAnalyticsData] = useState<any>(null);
const [onboardingState, setOnboardingState] = useState<any>(null);
const blogPosts = [ const blogPosts = [
@@ -117,7 +121,6 @@ export default function DashboardPage() {
// Store in localStorage for consistency // Store in localStorage for consistency
localStorage.setItem('user', JSON.stringify(user)); localStorage.setItem('user', JSON.stringify(user));
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
identifyUser(user.id, { identifyUser(user.id, {
email: user.email, email: user.email,
name: user.name, name: user.name,
@@ -145,11 +148,17 @@ export default function DashboardPage() {
// Check for successful payment and verify session // Check for successful payment and verify session
useEffect(() => { useEffect(() => {
const success = searchParams.get('success'); const success = searchParams.get('success');
if (success === 'true') { const sessionId = searchParams.get('session_id');
if (success === 'true' && sessionId) {
const verifySession = async () => { const verifySession = async () => {
try { try {
const response = await fetch('/api/stripe/verify-session', { const response = await fetch('/api/stripe/verify-session', {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ sessionId }),
}); });
if (response.ok) { if (response.ok) {
@@ -157,6 +166,10 @@ export default function DashboardPage() {
setUserPlan(data.plan); setUserPlan(data.plan);
setUpgradedPlan(data.plan); setUpgradedPlan(data.plan);
setShowUpgradeDialog(true); setShowUpgradeDialog(true);
trackEvent('upgrade_completed', {
plan: data.plan,
source: 'stripe_checkout',
});
// Remove success parameter from URL // Remove success parameter from URL
router.replace('/dashboard'); router.replace('/dashboard');
} else { } else {
@@ -218,6 +231,12 @@ export default function DashboardPage() {
const analytics = await analyticsResponse.json(); const analytics = await analyticsResponse.json();
setAnalyticsData(analytics); setAnalyticsData(analytics);
} }
const onboardingResponse = await fetch('/api/onboarding');
if (onboardingResponse.ok) {
const onboardingData = await onboardingResponse.json();
setOnboardingState(onboardingData);
}
} catch (error) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
setQrCodes([]); setQrCodes([]);
@@ -342,6 +361,8 @@ export default function DashboardPage() {
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
<OnboardingChecklist state={onboardingState} />
<StatsGrid <StatsGrid
stats={stats} stats={stats}
trends={{ trends={{
@@ -393,7 +414,7 @@ export default function DashboardPage() {
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" /> <QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-700 mb-2">Create your first QR code</h3> <h3 className="text-lg font-semibold text-gray-700 mb-2">Create your first QR code</h3>
<p className="text-gray-500 mb-6 max-w-sm mx-auto"> <p className="text-gray-500 mb-6 max-w-sm mx-auto">
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.
</p> </p>
<Link href="/create"> <Link href="/create">
<Button>Create QR Code it takes 90 seconds</Button> <Button>Create QR Code it takes 90 seconds</Button>

View File

@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
type LoginClientProps = { type LoginClientProps = {
showPageHeading?: boolean; showPageHeading?: boolean;
@@ -23,6 +24,7 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -58,7 +60,9 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
} }
// Check for redirect parameter // Check for redirect parameter
const redirectUrl = searchParams.get('redirect') || '/dashboard'; const redirectUrl = data.needsOnboarding
? appendRedirectParam('/onboarding', redirectTarget)
: (redirectTarget || '/dashboard');
router.push(redirectUrl); router.push(redirectUrl);
router.refresh(); router.refresh();
} else { } else {
@@ -73,7 +77,7 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
const handleGoogleSignIn = () => { const handleGoogleSignIn = () => {
// Redirect to Google OAuth API route // Redirect to Google OAuth API route
window.location.href = '/api/auth/google'; window.location.href = appendRedirectParam('/api/auth/google', redirectTarget);
}; };
return ( return (
@@ -199,7 +203,7 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Don't have an account?{' '} Don't have an account?{' '}
<Link href="/signup" className="text-primary-600 hover:text-primary-700 font-medium"> <Link href={appendRedirectParam('/signup', redirectTarget)} className="text-primary-600 hover:text-primary-700 font-medium">
Sign up Sign up
</Link> </Link>
</p> </p>

View File

@@ -1,16 +1,18 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
export default function SignupClient() { export default function SignupClient() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const { fetchWithCsrf } = useCsrf(); const { fetchWithCsrf } = useCsrf();
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -21,6 +23,7 @@ export default function SignupClient() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -68,8 +71,8 @@ export default function SignupClient() {
console.error('PostHog tracking error:', error); console.error('PostHog tracking error:', error);
} }
// Redirect to dashboard // Redirect to onboarding
router.push('/dashboard'); router.push(appendRedirectParam('/onboarding', redirectTarget));
router.refresh(); router.refresh();
} else { } else {
setError(data.error || 'Failed to create account'); setError(data.error || 'Failed to create account');
@@ -83,7 +86,7 @@ export default function SignupClient() {
const handleGoogleSignIn = () => { const handleGoogleSignIn = () => {
// Redirect to Google OAuth API route // Redirect to Google OAuth API route
window.location.href = '/api/auth/google'; window.location.href = appendRedirectParam('/api/auth/google', redirectTarget);
}; };
return ( return (
@@ -236,7 +239,7 @@ export default function SignupClient() {
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Already have an account?{' '} Already have an account?{' '}
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium"> <Link href={appendRedirectParam('/login', redirectTarget)} className="text-primary-600 hover:text-primary-700 font-medium">
Sign in Sign in
</Link> </Link>
</p> </p>

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@ import { showToast } from '@/components/ui/Toast';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { BillingToggle } from '@/components/ui/BillingToggle'; import { BillingToggle } from '@/components/ui/BillingToggle';
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto'; import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
import { trackEvent } from '@/components/PostHogProvider';
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
export default function PricingPage() { export default function PricingPage() {
const router = useRouter(); const router = useRouter();
@@ -40,6 +42,13 @@ export default function PricingPage() {
setLoading(plan); setLoading(plan);
try { try {
trackEvent('upgrade_clicked', {
plan,
billing_interval: billingPeriod,
source: 'pricing_page',
current_plan: currentPlan,
});
const response = await fetch('/api/stripe/create-checkout-session', { const response = await fetch('/api/stripe/create-checkout-session', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -52,14 +61,15 @@ export default function PricingPage() {
}); });
if (!response.ok) { 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(); const { url } = await response.json();
window.location.href = url; window.location.href = url;
} catch (error) { } catch (error: any) {
console.error('Error creating checkout session:', error); 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); setLoading(null);
} }
}; };
@@ -132,7 +142,7 @@ export default function PricingPage() {
period: 'forever', period: 'forever',
showDiscount: false, showDiscount: false,
features: [ features: [
'3 active dynamic QR codes (8 types available)', `${FREE_DYNAMIC_QR_LIMIT} active dynamic QR codes (8 types available)`,
'Unlimited static QR codes', 'Unlimited static QR codes',
'Basic scan tracking', 'Basic scan tracking',
'Standard QR design templates', 'Standard QR design templates',

View File

@@ -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<string, {
key: string;
signups: number;
firstQr: number;
activated: number;
hot: number;
upgradeCandidates: number;
paid: number;
}>();
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 });
}
}

View File

@@ -1,11 +1,29 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { cookies } from 'next/headers';
import { getAuthCookieOptions } from '@/lib/cookieConfig'; 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) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const code = searchParams.get('code'); 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 no code, redirect to Google OAuth
if (!code) { if (!code) {
@@ -20,14 +38,51 @@ export async function GET(request: NextRequest) {
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`; const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
const scope = 'openid email profile'; const scope = 'openid email profile';
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
const oauthState = crypto.randomUUID();
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}`; 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);
return NextResponse.redirect(googleAuthUrl); 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 // Handle callback with code
try { 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 googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET; const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
@@ -83,6 +138,7 @@ export async function GET(request: NextRequest) {
// Create user if they don't exist // Create user if they don't exist
if (!user) { if (!user) {
const onboardingStartedAt = new Date();
user = await db.user.create({ user = await db.user.create({
data: { data: {
email: userInfo.email, email: userInfo.email,
@@ -90,6 +146,16 @@ export async function GET(request: NextRequest) {
image: userInfo.picture, image: userInfo.picture,
emailVerified: new Date(), // Google already verified the email emailVerified: new Date(), // Google already verified the email
password: null, // OAuth users don't need a password 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,
}, },
}); });
@@ -147,19 +213,32 @@ export async function GET(request: NextRequest) {
} }
} }
// Set authentication cookie triggerLifecycleScoring(user.id, isNewUser ? 'signup' : 'subscription_changed');
cookies().set('userId', user.id, getAuthCookieOptions());
// Redirect to dashboard with tracking params const onboardingTarget = isNewUser || shouldResumeOnboarding(user)
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`); ? appendRedirectParam('/onboarding', savedRedirect, {
redirectUrl.searchParams.set('authMethod', 'google'); authMethod: 'google',
redirectUrl.searchParams.set('isNewUser', isNewUser.toString()); isNewUser: isNewUser.toString(),
})
: (savedRedirect || appendRedirectParam('/dashboard', null, {
authMethod: 'google',
isNewUser: isNewUser.toString(),
}));
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`);
return NextResponse.redirect(redirectUrl.toString()); 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) { } catch (error) {
console.error('Google OAuth error:', error); console.error('Google OAuth error:', error);
return NextResponse.redirect( const errorResponse = NextResponse.redirect(
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed` `${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;
} }
} }

View File

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

View File

@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { z } from 'zod'; import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
@@ -9,6 +8,12 @@ import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signupSchema, validateRequest } from '@/lib/validationSchemas'; import { signupSchema, validateRequest } from '@/lib/validationSchemas';
import { sendWelcomeEmail } from '@/lib/email'; import { sendWelcomeEmail } from '@/lib/email';
import { sendConversionEvent } from '@/lib/meta'; 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) { export async function POST(request: NextRequest) {
try { try {
@@ -67,15 +72,30 @@ export async function POST(request: NextRequest) {
// Hash password // Hash password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
const onboardingStartedAt = new Date();
// Create user // Create user
const user = await db.user.create({ const user = await db.user.create({
data: { data: {
name, name,
email, email,
password: hashedPassword, 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) // Send welcome email (fire-and-forget — never block signup)
try { try {
await sendWelcomeEmail(user.email, user.name ?? 'there'); await sendWelcomeEmail(user.email, user.name ?? 'there');
@@ -99,6 +119,7 @@ export async function POST(request: NextRequest) {
// Create response // Create response
const response = NextResponse.json({ const response = NextResponse.json({
success: true, success: true,
needsOnboarding: true,
user: { user: {
id: user.id, id: user.id,
name: user.name, name: user.name,
@@ -109,6 +130,7 @@ export async function POST(request: NextRequest) {
// Set cookie for auto-login after signup // Set cookie for auto-login after signup
response.cookies.set('userId', user.id, getAuthCookieOptions()); response.cookies.set('userId', user.id, getAuthCookieOptions());
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
return response; return response;
} catch (error) { } catch (error) {

View File

@@ -6,6 +6,7 @@ import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig'; import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { loginSchema, validateRequest } from '@/lib/validationSchemas'; import { loginSchema, validateRequest } from '@/lib/validationSchemas';
import { shouldResumeOnboarding } from '@/lib/revops';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -52,6 +53,15 @@ export async function POST(request: NextRequest) {
// Find user // Find user
const user = await db.user.findUnique({ const user = await db.user.findUnique({
where: { email }, where: { email },
select: {
id: true,
email: true,
name: true,
plan: true,
password: true,
onboardingStartedAt: true,
onboardingCompletedAt: true,
},
}); });
if (!user) { if (!user) {
@@ -76,6 +86,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
needsOnboarding: shouldResumeOnboarding(user),
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' } user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
}); });
} catch (error) { } catch (error) {

View File

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

View File

@@ -5,6 +5,8 @@ import { generateSlug } from '@/lib/hash';
import { createQRSchema, validateRequest } from '@/lib/validationSchemas'; import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; 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 // GET /api/qrs - List user's QR codes
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -47,12 +49,7 @@ export async function GET(request: NextRequest) {
} }
// Plan limits // Plan limits
const PLAN_LIMITS = { const PLAN_LIMITS = DYNAMIC_QR_LIMITS;
FREE: 3,
PRO: 50,
BUSINESS: 500,
ENTERPRISE: 99999,
};
// POST /api/qrs - Create a new QR code // POST /api/qrs - Create a new QR code
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -227,6 +224,8 @@ END:VCARD`;
}, },
}); });
triggerLifecycleScoring(userId, 'qr_created');
return NextResponse.json(qrCode); return NextResponse.json(qrCode);
} catch (error) { } catch (error) {
console.error('Error creating QR code:', error); console.error('Error creating QR code:', error);

View File

@@ -3,6 +3,7 @@ import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -61,6 +62,7 @@ export async function POST(request: NextRequest) {
stripeCurrentPeriodEnd: null, stripeCurrentPeriodEnd: null,
}, },
}); });
await scoreUserLifecycle(userId, 'subscription_changed');
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
@@ -78,6 +80,8 @@ export async function POST(request: NextRequest) {
}, },
}); });
await scoreUserLifecycle(userId, 'subscription_changed');
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('Error canceling subscription:', error); console.error('Error canceling subscription:', error);

View File

@@ -68,6 +68,22 @@ export async function POST(request: NextRequest) {
// Create or get Stripe customer // Create or get Stripe customer
let customerId = user.stripeCustomerId; 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) { if (!customerId) {
const customer = await stripe.customers.create({ const customer = await stripe.customers.create({
email: user.email, email: user.email,
@@ -85,6 +101,8 @@ export async function POST(request: NextRequest) {
}); });
} }
const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin;
// Create Stripe Checkout Session // Create Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({ const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId, customer: customerId,
@@ -96,11 +114,12 @@ export async function POST(request: NextRequest) {
quantity: 1, quantity: 1,
}, },
], ],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`, success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`, cancel_url: `${appUrl}/pricing?canceled=true`,
metadata: { metadata: {
userId: user.id, userId: user.id,
plan, plan,
billingInterval,
}, },
}); });

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { scoreUserLifecycle } from '@/lib/revops-server';
/** /**
* Manual sync endpoint to update user subscription from Stripe * Manual sync endpoint to update user subscription from Stripe
@@ -47,6 +48,8 @@ export async function POST(request: NextRequest) {
}, },
}); });
await scoreUserLifecycle(user.id, 'subscription_changed');
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
plan: 'FREE', plan: 'FREE',
@@ -97,6 +100,8 @@ export async function POST(request: NextRequest) {
}, },
}); });
await scoreUserLifecycle(user.id, 'subscription_changed');
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
plan, plan,

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -24,17 +25,21 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 }); return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
} }
// Get the most recent checkout session for this customer const { sessionId } = await request.json().catch(() => ({ sessionId: null }));
const checkoutSessions = await stripe.checkout.sessions.list({
customer: user.stripeCustomerId,
limit: 1,
});
if (checkoutSessions.data.length === 0) { if (!sessionId || typeof sessionId !== 'string') {
return NextResponse.json({ error: 'No checkout session found' }, { status: 404 }); return NextResponse.json({ error: 'Missing checkout session ID' }, { status: 400 });
} }
const checkoutSession = checkoutSessions.data[0]; 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 // Only process if payment was successful
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) { if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
@@ -48,11 +53,7 @@ export async function POST(request: NextRequest) {
// Determine plan from metadata or price ID // Determine plan from metadata or price ID
const plan = checkoutSession.metadata?.plan || 'PRO'; 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 // Get current_period_end - Stripe returns it as a Unix timestamp
// Try different possible field names
const periodEndTimestamp = subscription.current_period_end const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd || subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor; || subscription.billing_cycle_anchor;
@@ -61,13 +62,6 @@ export async function POST(request: NextRequest) {
? new Date(periodEndTimestamp * 1000) ? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now : 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 // Update user in database
await db.user.update({ await db.user.update({
where: { id: user.id }, where: { id: user.id },
@@ -79,6 +73,8 @@ export async function POST(request: NextRequest) {
}, },
}); });
await scoreUserLifecycle(user.id, 'subscription_changed');
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
plan, plan,

View File

@@ -4,6 +4,7 @@ import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { sendConversionEvent } from '@/lib/meta'; import { sendConversionEvent } from '@/lib/meta';
import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const body = await request.text(); const body = await request.text();
@@ -62,6 +63,8 @@ export async function POST(request: NextRequest) {
}, },
}); });
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
// Meta CAPI — Purchase event // Meta CAPI — Purchase event
const amountCents = session.amount_total ?? 0; const amountCents = session.amount_total ?? 0;
sendConversionEvent({ sendConversionEvent({
@@ -101,13 +104,20 @@ export async function POST(request: NextRequest) {
stripeCurrentPeriodEnd: currentPeriodEnd, stripeCurrentPeriodEnd: currentPeriodEnd,
}, },
}); });
const updated = await db.user.findUnique({
where: { stripeSubscriptionId: subscription.id },
select: { id: true },
});
if (updated?.id) {
await scoreUserLifecycle(updated.id, 'subscription_changed');
}
break; break;
} }
case 'customer.subscription.deleted': { case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription; const subscription = event.data.object as Stripe.Subscription;
await db.user.update({ const updatedUser = await db.user.update({
where: { where: {
stripeSubscriptionId: subscription.id, stripeSubscriptionId: subscription.id,
}, },
@@ -118,6 +128,8 @@ export async function POST(request: NextRequest) {
plan: 'FREE', plan: 'FREE',
}, },
}); });
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
break; break;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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 <OnboardingClient />;
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { hashIP } from '@/lib/hash'; import { hashIP } from '@/lib/hash';
import { triggerLifecycleScoring } from '@/lib/revops-server';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@@ -14,6 +15,7 @@ export async function GET(
where: { slug }, where: { slug },
select: { select: {
id: true, id: true,
userId: true,
content: true, content: true,
contentType: true, contentType: true,
}, },
@@ -24,7 +26,7 @@ export async function GET(
} }
// Track scan (fire and forget) // Track scan (fire and forget)
trackScan(qrCode.id, request).catch(console.error); trackScan(qrCode.id, qrCode.userId, request).catch(console.error);
// Determine destination URL // Determine destination URL
let destination = ''; 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 { try {
const userAgent = request.headers.get('user-agent') || ''; const userAgent = request.headers.get('user-agent') || '';
const referer = request.headers.get('referer') || ''; const referer = request.headers.get('referer') || '';
@@ -133,13 +135,28 @@ async function trackScan(qrId: string, request: NextRequest) {
const dnt = request.headers.get('dnt'); const dnt = request.headers.get('dnt');
if (dnt === '1') { if (dnt === '1') {
// Respect Do Not Track - only increment counter // Respect Do Not Track - only increment counter
const scanTimestamp = new Date();
await db.qRScan.create({ await db.qRScan.create({
data: { data: {
qrId, qrId,
ipHash: 'dnt', ipHash: 'dnt',
isUnique: false, 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; return;
} }
@@ -222,9 +239,11 @@ async function trackScan(qrId: string, request: NextRequest) {
const isUnique = !existingScan; const isUnique = !existingScan;
// Create scan record // Create scan record
const scanTimestamp = new Date();
await db.qRScan.create({ await db.qRScan.create({
data: { data: {
qrId, qrId,
ts: scanTimestamp,
ipHash, ipHash,
userAgent: userAgent.substring(0, 255), userAgent: userAgent.substring(0, 255),
device, device,
@@ -237,6 +256,21 @@ async function trackScan(qrId: string, request: NextRequest) {
isUnique, 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) { } catch (error) {
// Don't throw - this is fire and forget // Don't throw - this is fire and forget
} }

View File

@@ -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 (
<Card className="overflow-hidden rounded-[28px] border border-slate-200 bg-gradient-to-br from-white via-slate-50 to-blue-50 p-0 shadow-lg shadow-slate-200/60">
<div className="border-b border-slate-200/80 bg-slate-950 px-6 py-6 text-white">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-2xl">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-white/80">
<Sparkles className="h-3.5 w-3.5" />
Activation path
</div>
<h3 className="mt-4 text-2xl font-semibold tracking-tight text-white">Get to your first result faster</h3>
<p className="mt-3 text-sm leading-7 text-slate-300">
Finish the onboarding checklist to unlock a cleaner dashboard state and move from setup into real usage.
</p>
</div>
<div className="flex items-center gap-3">
<Badge className="border border-white/10 bg-white/10 px-3 py-1 text-white" variant="default">
{completed}/{items.length} done
</Badge>
<button
type="button"
aria-label="Dismiss onboarding checklist"
className="flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/10 text-white/80 transition hover:text-white"
onClick={() => {
localStorage.setItem(ONBOARDING_CHECKLIST_DISMISS_KEY, '1');
setDismissed(true);
}}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-5 h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-gradient-to-r from-primary-400 via-cyan-300 to-emerald-300 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<CardContent className="space-y-5 p-6">
<div className="grid gap-3 md:grid-cols-2">
{items.map((item) => (
<div
key={item.id}
className={`rounded-[22px] border p-4 transition-colors ${
item.done
? 'border-emerald-200 bg-emerald-50'
: 'border-slate-200 bg-white'
}`}
>
<div className="flex items-start gap-3">
<div
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${
item.done ? 'bg-emerald-500 text-slate-950' : 'bg-slate-100 text-slate-500'
}`}
>
{item.done ? <Check className="h-4 w-4" /> : <QrCode className="h-4 w-4" />}
</div>
<div className="min-w-0">
<p className={`text-sm font-semibold ${item.done ? 'text-emerald-900' : 'text-slate-900'}`}>
{item.label}
</p>
<p className="mt-1 text-sm leading-6 text-slate-600">
{item.done ? 'Completed and saved in your setup state.' : 'Still pending. Finish this to move closer to activation.'}
</p>
</div>
</div>
</div>
))}
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Link href="/onboarding" className="block">
<Button className="h-11 rounded-2xl px-5 text-sm font-semibold">
Continue onboarding
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
<Button
variant="outline"
className="h-11 rounded-2xl border-slate-300 px-5 text-sm font-semibold"
onClick={() => {
localStorage.setItem(ONBOARDING_CHECKLIST_DISMISS_KEY, '1');
setDismissed(true);
}}
>
Dismiss
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -4,6 +4,7 @@ import React, { useState } from 'react';
import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react'; import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { trackEvent } from '@/components/PostHogProvider';
const AIComingSoonBanner = () => { const AIComingSoonBanner = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -28,9 +29,16 @@ const AIComingSoonBanner = () => {
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || 'Failed to subscribe'); 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); setSubmitted(true);
setEmail(''); setEmail('');
} catch (err) { } catch (err) {

47
src/lib/auth-flow.ts Normal file
View File

@@ -0,0 +1,47 @@
export const GOOGLE_OAUTH_STATE_COOKIE_NAME = 'qrmaster_google_oauth_state';
export const POST_AUTH_REDIRECT_COOKIE_NAME = 'qrmaster_post_auth_redirect';
export function sanitizeRedirectPath(value?: string | null): string | null {
if (!value) {
return null;
}
const trimmed = value.trim();
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) {
return null;
}
if (trimmed.startsWith('/api/') || trimmed.startsWith('/login') || trimmed.startsWith('/signup')) {
return null;
}
return trimmed;
}
export function appendRedirectParam(
path: string,
redirectPath?: string | null,
extraParams?: Record<string, string | null | undefined>
): string {
const [pathname, existingQuery = ''] = path.split('?');
const searchParams = new URLSearchParams(existingQuery);
const safeRedirect = sanitizeRedirectPath(redirectPath);
if (safeRedirect) {
searchParams.set('redirect', safeRedirect);
}
Object.entries(extraParams || {}).forEach(([key, value]) => {
if (value) {
searchParams.set(key, value);
}
});
const query = searchParams.toString();
return query ? `${pathname}?${query}` : pathname;
}
export function getPostOnboardingDestination(redirectPath?: string | null): string {
return sanitizeRedirectPath(redirectPath) || '/dashboard';
}

10
src/lib/plans.ts Normal file
View File

@@ -0,0 +1,10 @@
export const FREE_DYNAMIC_QR_LIMIT = 3;
export const PRO_DYNAMIC_QR_LIMIT = 50;
export const BUSINESS_DYNAMIC_QR_LIMIT = 500;
export const DYNAMIC_QR_LIMITS = {
FREE: FREE_DYNAMIC_QR_LIMIT,
PRO: PRO_DYNAMIC_QR_LIMIT,
BUSINESS: BUSINESS_DYNAMIC_QR_LIMIT,
ENTERPRISE: 99999,
} as const;

361
src/lib/revops-server.ts Normal file
View File

@@ -0,0 +1,361 @@
import { db } from '@/lib/db';
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
import {
getEmailDomain,
isFreemailDomain,
LifecycleStage,
normalizeSource,
} from '@/lib/revops';
type ScoreReason =
| 'signup'
| 'onboarding_update'
| 'qr_created'
| 'scan_recorded'
| 'subscription_changed';
type UserForScoring = {
id: string;
email: string;
plan: string;
primaryUseCase: string | null;
primaryGoal: string | null;
jobRole: string | null;
companyName: string | null;
teamSizeBucket: string | null;
firstQrCreatedAt: Date | null;
firstDynamicQrAt: Date | null;
firstStaticQrAt: Date | null;
firstScanAt: Date | null;
activationAt: Date | null;
onboardingCompletedAt: Date | null;
lastQualifiedAt: Date | null;
lifecycleStage: string;
};
type UserMetricSnapshot = {
qrCount: number;
dynamicQrCount: number;
contentTypeCount: number;
businessishTypeCount: number;
scanCount: number;
firstQrCreatedAt: Date | null;
firstDynamicQrAt: Date | null;
firstStaticQrAt: Date | null;
};
export function triggerLifecycleScoring(userId: string, reason: ScoreReason) {
void scoreUserLifecycle(userId, reason).catch((error) => {
console.error(`Lifecycle scoring failed for ${userId} (${reason}):`, error);
});
}
export async function scoreUserLifecycle(userId: string, reason: ScoreReason) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
plan: true,
primaryUseCase: true,
primaryGoal: true,
jobRole: true,
companyName: true,
teamSizeBucket: true,
firstQrCreatedAt: true,
firstDynamicQrAt: true,
firstStaticQrAt: true,
firstScanAt: true,
activationAt: true,
onboardingCompletedAt: true,
lastQualifiedAt: true,
lifecycleStage: true,
},
});
if (!user) {
return null;
}
const qrCodes = await db.qRCode.findMany({
where: { userId },
select: {
id: true,
type: true,
contentType: true,
createdAt: true,
_count: {
select: {
scans: true,
},
},
},
});
const firstScan = await db.qRScan.findFirst({
where: {
qr: {
userId,
},
},
orderBy: {
ts: 'asc',
},
select: {
ts: true,
},
});
const metrics = getMetricSnapshot(qrCodes);
const computedTimestamps = {
firstQrCreatedAt: user.firstQrCreatedAt ?? metrics.firstQrCreatedAt,
firstDynamicQrAt: user.firstDynamicQrAt ?? metrics.firstDynamicQrAt,
firstStaticQrAt: user.firstStaticQrAt ?? metrics.firstStaticQrAt,
firstScanAt: user.firstScanAt ?? firstScan?.ts ?? null,
activationAt: user.activationAt ?? user.firstScanAt ?? firstScan?.ts ?? null,
onboardingCompletedAt:
user.onboardingCompletedAt ?? metrics.firstQrCreatedAt,
};
const fitScore = calculateFitScore(user);
const intentScore = calculateIntentScore({
...computedTimestamps,
...metrics,
});
const leadScore = fitScore + intentScore;
const nextStage = resolveLifecycleStage({
plan: user.plan,
leadScore,
activationAt: computedTimestamps.activationAt,
});
const shouldRefreshQualifiedAt = nextStage === 'paid' || nextStage === 'hot' || nextStage === 'upgrade_candidate';
const updatedUser = await db.user.update({
where: { id: userId },
data: {
emailDomain: getEmailDomain(user.email),
firstQrCreatedAt: computedTimestamps.firstQrCreatedAt,
firstDynamicQrAt: computedTimestamps.firstDynamicQrAt,
firstStaticQrAt: computedTimestamps.firstStaticQrAt,
firstScanAt: computedTimestamps.firstScanAt,
activationAt: computedTimestamps.activationAt,
onboardingCompletedAt: computedTimestamps.onboardingCompletedAt,
fitScore,
intentScore,
leadScore,
lifecycleStage: nextStage,
lastScoredAt: new Date(),
lastQualifiedAt: shouldRefreshQualifiedAt ? new Date() : user.lastQualifiedAt,
},
select: {
id: true,
lifecycleStage: true,
fitScore: true,
intentScore: true,
leadScore: true,
firstQrCreatedAt: true,
firstDynamicQrAt: true,
firstScanAt: true,
activationAt: true,
},
});
if (user.lifecycleStage !== nextStage) {
await db.userLifecycleLog.create({
data: {
userId,
fromStage: user.lifecycleStage,
toStage: nextStage,
fitScore,
intentScore,
leadScore,
reason,
},
});
}
return updatedUser;
}
export async function getOnboardingState(userId: string) {
return db.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
plan: true,
signupSource: true,
signupSourceSelfReported: true,
signupCampaign: true,
signupLandingPath: true,
primaryUseCase: true,
primaryGoal: true,
jobRole: true,
companyName: true,
companyWebsite: true,
teamSizeBucket: true,
onboardingStartedAt: true,
sourceConfirmedAt: true,
useCaseSelectedAt: true,
goalSelectedAt: true,
profileCompletedAt: true,
firstQrCreatedAt: true,
firstDynamicQrAt: true,
firstStaticQrAt: true,
firstScanAt: true,
activationAt: true,
onboardingCompletedAt: true,
lifecycleStage: true,
fitScore: true,
intentScore: true,
leadScore: true,
},
});
}
export function getMetricSnapshot(
qrCodes: Array<{
type: 'STATIC' | 'DYNAMIC';
contentType: string;
createdAt: Date;
_count: { scans: number };
}>
): UserMetricSnapshot {
const sorted = [...qrCodes].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
const dynamicOnly = sorted.filter((qr) => qr.type === 'DYNAMIC');
const staticOnly = sorted.filter((qr) => qr.type === 'STATIC');
const businessish = sorted.filter((qr) =>
['BARCODE', 'PDF', 'VCARD', 'COUPON', 'FEEDBACK'].includes(qr.contentType)
);
return {
qrCount: sorted.length,
dynamicQrCount: dynamicOnly.length,
contentTypeCount: new Set(sorted.map((qr) => qr.contentType)).size,
businessishTypeCount: businessish.length,
scanCount: sorted.reduce((sum, qr) => sum + qr._count.scans, 0),
firstQrCreatedAt: sorted[0]?.createdAt ?? null,
firstDynamicQrAt: dynamicOnly[0]?.createdAt ?? null,
firstStaticQrAt: staticOnly[0]?.createdAt ?? null,
};
}
export function calculateFitScore(user: Pick<UserForScoring, 'email' | 'primaryUseCase' | 'primaryGoal' | 'jobRole' | 'companyName' | 'teamSizeBucket'>): number {
const emailDomain = getEmailDomain(user.email);
let score = 0;
if (emailDomain) {
score += isFreemailDomain(emailDomain) ? -15 : 20;
}
if (['marketing_campaign', 'bulk_qr', 'menu_pdf', 'barcode'].includes(user.primaryUseCase ?? '')) {
score += 10;
}
if (['track_printed_campaigns', 'generate_leads', 'manage_multiple_qr_codes'].includes(user.primaryGoal ?? '')) {
score += 10;
}
if (['founder_owner', 'marketing_manager', 'agency_freelancer', 'operations'].includes(user.jobRole ?? '')) {
score += 10;
}
if (user.companyName?.trim()) {
score += 5;
}
if (['6_20', '21_100', '100_plus'].includes(user.teamSizeBucket ?? '')) {
score += 10;
}
return score;
}
export function calculateIntentScore(input: {
firstQrCreatedAt: Date | null;
firstDynamicQrAt: Date | null;
qrCount: number;
scanCount: number;
businessishTypeCount: number;
contentTypeCount: number;
}): number {
let score = 0;
score += input.firstQrCreatedAt ? 20 : -10;
score += input.firstDynamicQrAt ? 20 : 0;
score += input.qrCount >= 3 ? 15 : 0;
score += input.scanCount > 0 ? 10 : 0;
score += input.businessishTypeCount > 0 ? 10 : 0;
score += input.contentTypeCount >= 2 ? 10 : 0;
return score;
}
export function resolveLifecycleStage(input: {
plan: string;
leadScore: number;
activationAt: Date | null;
}): LifecycleStage {
if (input.plan === 'PRO' || input.plan === 'BUSINESS') {
return 'paid';
}
if (input.leadScore >= 70) {
return 'upgrade_candidate';
}
if (input.leadScore >= 55) {
return 'hot';
}
if (input.leadScore >= 30) {
return 'warm';
}
if (input.activationAt) {
return 'activated';
}
return 'cold';
}
export function getUpgradeCandidateBadges(user: {
email?: string | null;
primaryUseCase?: string | null;
primaryGoal?: string | null;
}, metrics: {
dynamicQrCount: number;
qrCount: number;
scanCount: number;
}): string[] {
const emailDomain = getEmailDomain(user.email);
const badges: string[] = [];
if (emailDomain && !isFreemailDomain(emailDomain)) {
badges.push('business domain');
}
if (metrics.dynamicQrCount > 0) {
badges.push('dynamic usage');
}
if (metrics.qrCount >= 3) {
badges.push('3+ QRs');
}
if (metrics.scanCount > 0) {
badges.push('scans detected');
}
if (
user.primaryUseCase === 'marketing_campaign' ||
user.primaryGoal === 'track_printed_campaigns' ||
user.primaryGoal === 'generate_leads'
) {
badges.push('marketing campaign intent');
}
if (metrics.dynamicQrCount >= Math.max(1, FREE_DYNAMIC_QR_LIMIT - 1)) {
badges.push('near free plan limit');
}
return badges;
}
export function normalizeTrackedSource(source?: string | null, referrer?: string | null, landingPath?: string | null) {
return normalizeSource({
utmSource: source,
referrer,
landingPath,
});
}

368
src/lib/revops.ts Normal file
View File

@@ -0,0 +1,368 @@
export const ATTRIBUTION_COOKIE_NAME = 'qrmaster_first_touch';
export const ONBOARDING_CHECKLIST_DISMISS_KEY = 'qrmaster_onboarding_checklist_dismissed';
export type LifecycleStage =
| 'cold'
| 'activated'
| 'warm'
| 'hot'
| 'upgrade_candidate'
| 'paid';
export type SignupSourceOption = {
value: string;
label: string;
};
export type OnboardingOption = {
value: string;
label: string;
description?: string;
};
export type AttributionSnapshot = {
signupSource?: string | null;
signupMedium?: string | null;
signupCampaign?: string | null;
signupContent?: string | null;
signupTerm?: string | null;
signupReferrer?: string | null;
signupLandingPath?: string | null;
signupFirstSeenAt?: string | null;
};
const FREEMAIL_DOMAINS = new Set([
'gmail.com',
'yahoo.com',
'hotmail.com',
'outlook.com',
'icloud.com',
'gmx.de',
'gmx.net',
'web.de',
'mail.com',
'aol.com',
'proton.me',
'protonmail.com',
]);
export const SIGNUP_SOURCE_OPTIONS: SignupSourceOption[] = [
{ value: 'google_search', label: 'Google Search' },
{ value: 'instagram', label: 'Instagram' },
{ value: 'facebook', label: 'Facebook' },
{ value: 'tiktok', label: 'TikTok' },
{ value: 'linkedin', label: 'LinkedIn' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'blog_article', label: 'Blog or article' },
{ value: 'friend_colleague', label: 'Friend or colleague' },
{ value: 'direct', label: 'Direct / I typed the URL' },
{ value: 'other', label: 'Other' },
];
export const PRIMARY_USE_CASE_OPTIONS: OnboardingOption[] = [
{ value: 'website_qr', label: 'Website QR Code' },
{ value: 'menu_pdf', label: 'Menu or PDF QR Code' },
{ value: 'contact_card', label: 'Contact Card QR Code' },
{ value: 'wifi_qr', label: 'WiFi QR Code' },
{ value: 'marketing_campaign', label: 'Marketing Campaign QR Code' },
{ value: 'barcode', label: 'Barcode' },
{ value: 'bulk_qr', label: 'Bulk QR Codes' },
{ value: 'something_else', label: 'Something else' },
];
export const PRIMARY_GOAL_OPTIONS: OnboardingOption[] = [
{ value: 'drive_website_traffic', label: 'Drive website traffic' },
{ value: 'track_printed_campaigns', label: 'Track printed campaigns' },
{ value: 'share_contact_details', label: 'Share contact details' },
{ value: 'replace_printed_menus', label: 'Replace printed menus' },
{ value: 'generate_leads', label: 'Generate leads' },
{ value: 'label_products', label: 'Label products' },
{ value: 'manage_multiple_qr_codes', label: 'Manage multiple QR codes' },
{ value: 'something_else', label: 'Something else' },
];
export const JOB_ROLE_OPTIONS: OnboardingOption[] = [
{ value: 'founder_owner', label: 'Founder / Owner' },
{ value: 'marketing_manager', label: 'Marketing Manager' },
{ value: 'operations', label: 'Operations' },
{ value: 'agency_freelancer', label: 'Agency / Freelancer' },
{ value: 'it_technical', label: 'IT / Technical' },
{ value: 'sales', label: 'Sales' },
{ value: 'designer', label: 'Designer' },
{ value: 'other', label: 'Other' },
];
export const TEAM_SIZE_OPTIONS: OnboardingOption[] = [
{ value: 'just_me', label: 'Just me' },
{ value: '2_5', label: '25' },
{ value: '6_20', label: '620' },
{ value: '21_100', label: '21100' },
{ value: '100_plus', label: '100+' },
];
export function serializeAttributionCookie(snapshot: AttributionSnapshot): string {
return JSON.stringify(snapshot);
}
export function parseAttributionCookie(value?: string | null): AttributionSnapshot | null {
if (!value) {
return null;
}
try {
return JSON.parse(value) as AttributionSnapshot;
} catch {
return null;
}
}
export function getEmailDomain(email?: string | null): string | null {
if (!email || !email.includes('@')) {
return null;
}
return email.split('@')[1]?.trim().toLowerCase() || null;
}
export function isFreemailDomain(domain?: string | null): boolean {
return Boolean(domain && FREEMAIL_DOMAINS.has(domain.toLowerCase()));
}
export function getSourceLabel(value?: string | null): string {
const option = SIGNUP_SOURCE_OPTIONS.find((item) => item.value === value);
return option?.label || 'Unknown';
}
export function getUseCaseLabel(value?: string | null): string {
const option = PRIMARY_USE_CASE_OPTIONS.find((item) => item.value === value);
return option?.label || 'Unknown';
}
export function getGoalLabel(value?: string | null): string {
const option = PRIMARY_GOAL_OPTIONS.find((item) => item.value === value);
return option?.label || 'Unknown';
}
export function getRoleLabel(value?: string | null): string {
const option = JOB_ROLE_OPTIONS.find((item) => item.value === value);
return option?.label || 'Unknown';
}
export function getTeamSizeLabel(value?: string | null): string {
const option = TEAM_SIZE_OPTIONS.find((item) => item.value === value);
return option?.label || 'Unknown';
}
export function normalizeSource(input: {
utmSource?: string | null;
referrer?: string | null;
landingPath?: string | null;
}): string {
const utmSource = input.utmSource?.toLowerCase().trim();
const referrer = input.referrer?.toLowerCase().trim();
const landingPath = input.landingPath?.toLowerCase().trim();
if (utmSource) {
if (utmSource.includes('google')) return 'google_search';
if (utmSource.includes('instagram')) return 'instagram';
if (utmSource.includes('facebook') || utmSource.includes('meta')) return 'facebook';
if (utmSource.includes('tiktok')) return 'tiktok';
if (utmSource.includes('linkedin')) return 'linkedin';
if (utmSource.includes('youtube')) return 'youtube';
if (utmSource.includes('blog') || utmSource.includes('article') || utmSource.includes('seo')) return 'blog_article';
}
if (referrer) {
if (referrer.includes('google.')) return 'google_search';
if (referrer.includes('instagram.')) return 'instagram';
if (referrer.includes('facebook.') || referrer.includes('fb.com')) return 'facebook';
if (referrer.includes('tiktok.')) return 'tiktok';
if (referrer.includes('linkedin.')) return 'linkedin';
if (referrer.includes('youtube.')) return 'youtube';
if (referrer.includes('medium.com') || referrer.includes('substack.com') || referrer.includes('/blog')) return 'blog_article';
}
if (landingPath && landingPath.startsWith('/blog')) {
return 'blog_article';
}
return 'direct';
}
export function buildAttributionSnapshot(params: {
utmSource?: string | null;
utmMedium?: string | null;
utmCampaign?: string | null;
utmContent?: string | null;
utmTerm?: string | null;
referrer?: string | null;
landingPath?: string | null;
firstSeenAt?: Date | string | null;
}): AttributionSnapshot {
const signupFirstSeenAt =
params.firstSeenAt instanceof Date
? params.firstSeenAt.toISOString()
: params.firstSeenAt || new Date().toISOString();
return {
signupSource: normalizeSource({
utmSource: params.utmSource,
referrer: params.referrer,
landingPath: params.landingPath,
}),
signupMedium: params.utmMedium || null,
signupCampaign: params.utmCampaign || null,
signupContent: params.utmContent || null,
signupTerm: params.utmTerm || null,
signupReferrer: params.referrer || null,
signupLandingPath: params.landingPath || null,
signupFirstSeenAt,
};
}
export function shouldResumeOnboarding(user: {
onboardingStartedAt?: Date | null;
onboardingCompletedAt?: Date | null;
} | null): boolean {
if (!user) {
return false;
}
return Boolean(user.onboardingStartedAt && !user.onboardingCompletedAt);
}
export function getLifecycleStageLabel(stage?: string | null): string {
switch (stage) {
case 'activated':
return 'Activated';
case 'warm':
return 'Warm';
case 'hot':
return 'Hot';
case 'upgrade_candidate':
return 'Upgrade Candidate';
case 'paid':
return 'Paid';
default:
return 'Cold';
}
}
export function getOnboardingHeadlineForUseCase(useCase?: string | null): string {
switch (useCase) {
case 'marketing_campaign':
return 'Create your first campaign QR code';
case 'menu_pdf':
return 'Create your first menu QR code';
case 'contact_card':
return 'Create your first contact QR code';
case 'barcode':
return 'Create your first barcode';
case 'bulk_qr':
return 'Set up your first business-ready QR';
default:
return 'Create your first QR code';
}
}
export function getCreatePresetForUseCase(useCase?: string | null): {
contentType: string;
dynamic: boolean;
title: string;
content: Record<string, string>;
} {
switch (useCase) {
case 'menu_pdf':
return {
contentType: 'PDF',
dynamic: true,
title: 'Restaurant menu',
content: {},
};
case 'contact_card':
return {
contentType: 'VCARD',
dynamic: true,
title: 'Business contact card',
content: {},
};
case 'barcode':
return {
contentType: 'BARCODE',
dynamic: false,
title: 'Product barcode',
content: { format: 'CODE128' },
};
case 'bulk_qr':
return {
contentType: 'URL',
dynamic: true,
title: 'Bulk campaign starter',
content: { url: '' },
};
case 'wifi_qr':
return {
contentType: 'TEXT',
dynamic: false,
title: 'WiFi access QR',
content: { text: 'WIFI:T:WPA;S:MyNetwork;P:password;;' },
};
case 'website_qr':
return {
contentType: 'URL',
dynamic: true,
title: 'Website QR',
content: { url: '' },
};
case 'marketing_campaign':
default:
return {
contentType: 'URL',
dynamic: true,
title: 'Campaign landing page',
content: { url: '' },
};
}
}
export function getChecklistItems(user: {
signupSourceSelfReported?: string | null;
primaryUseCase?: string | null;
firstQrCreatedAt?: string | Date | null;
firstDynamicQrAt?: string | Date | null;
firstScanAt?: string | Date | null;
activationAt?: string | Date | null;
}): Array<{ id: string; label: string; done: boolean }> {
return [
{
id: 'source',
label: 'Confirm how you found QR Master',
done: Boolean(user.signupSourceSelfReported),
},
{
id: 'use-case',
label: 'Choose your use case',
done: Boolean(user.primaryUseCase),
},
{
id: 'first-qr',
label: 'Create your first QR code',
done: Boolean(user.firstQrCreatedAt),
},
{
id: 'first-dynamic',
label: 'Create your first dynamic QR code',
done: Boolean(user.firstDynamicQrAt),
},
{
id: 'download',
label: 'Download your QR code',
done: false,
},
{
id: 'scan',
label: 'Get your first scan',
done: Boolean(user.firstScanAt),
},
];
}

View File

@@ -1,4 +1,9 @@
import Stripe from 'stripe'; 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 // Use a placeholder during build time, real key at runtime
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build'; const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
@@ -22,13 +27,13 @@ export const STRIPE_PLANS = {
currency: 'EUR', currency: 'EUR',
interval: 'month', interval: 'month',
features: [ features: [
'3 dynamische QR-Codes', `${FREE_DYNAMIC_QR_LIMIT} dynamische QR-Codes`,
'Basis-Tracking (Scans + Standort)', 'Basis-Tracking (Scans + Standort)',
'Einfache Designs', 'Einfache Designs',
'Unbegrenzte statische QR-Codes', 'Unbegrenzte statische QR-Codes',
], ],
limits: { limits: {
dynamicQRCodes: 3, dynamicQRCodes: FREE_DYNAMIC_QR_LIMIT,
staticQRCodes: -1, // unlimited staticQRCodes: -1, // unlimited
teamMembers: 1, teamMembers: 1,
}, },
@@ -48,7 +53,7 @@ export const STRIPE_PLANS = {
'SVG/PNG Download', 'SVG/PNG Download',
], ],
limits: { limits: {
dynamicQRCodes: 50, dynamicQRCodes: PRO_DYNAMIC_QR_LIMIT,
staticQRCodes: -1, staticQRCodes: -1,
teamMembers: 1, teamMembers: 1,
}, },
@@ -68,7 +73,7 @@ export const STRIPE_PLANS = {
'Priority Support', 'Priority Support',
], ],
limits: { limits: {
dynamicQRCodes: 500, dynamicQRCodes: BUSINESS_DYNAMIC_QR_LIMIT,
staticQRCodes: -1, staticQRCodes: -1,
teamMembers: 1, teamMembers: 1,
}, },

View File

@@ -122,6 +122,17 @@ export const updateProfileSchema = z.object({
.trim(), .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({ export const changePasswordSchema = z.object({
currentPassword: z.string() currentPassword: z.string()
.min(1, 'Current password is required'), .min(1, 'Current password is required'),

View File

@@ -1,21 +1,61 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import type { NextRequest } 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) { export function middleware(req: NextRequest) {
const path = req.nextUrl.pathname; const path = req.nextUrl.pathname;
// 301 Redirects for /guide -> /learn to avoid duplicate content and consolidate authority // 301 Redirects for /guide -> /learn to avoid duplicate content and consolidate authority
if (path === '/guide/tracking-analytics') { if (path === '/guide/tracking-analytics') {
return NextResponse.redirect(new URL('/learn/tracking', req.url), 301); return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/tracking', req.url), 301));
} }
if (path === '/guide/bulk-qr-code-generation') { if (path === '/guide/bulk-qr-code-generation') {
return NextResponse.redirect(new URL('/learn/developer', req.url), 301); return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/developer', req.url), 301));
} }
if (path === '/guide/qr-code-best-practices') { if (path === '/guide/qr-code-best-practices') {
return NextResponse.redirect(new URL('/learn/basics', req.url), 301); return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/basics', req.url), 301));
} }
if (path === '/create-qr') { if (path === '/create-qr') {
return NextResponse.redirect(new URL('/dynamic-qr-code-generator', req.url), 301); return attachAttributionCookie(req, NextResponse.redirect(new URL('/dynamic-qr-code-generator', req.url), 301));
} }
// Public routes that don't require authentication // Public routes that don't require authentication
@@ -70,22 +110,22 @@ export function middleware(req: NextRequest) {
// Allow API routes // Allow API routes
if (path.startsWith('/api/')) { if (path.startsWith('/api/')) {
return NextResponse.next(); return attachAttributionCookie(req, NextResponse.next());
} }
// Allow redirect routes (QR code redirects) // Allow redirect routes (QR code redirects)
if (path.startsWith('/r/')) { if (path.startsWith('/r/')) {
return NextResponse.next(); return attachAttributionCookie(req, NextResponse.next());
} }
// Allow static files // Allow static files
if (path.includes('.') || path.startsWith('/_next')) { if (path.includes('.') || path.startsWith('/_next')) {
return NextResponse.next(); return attachAttributionCookie(req, NextResponse.next());
} }
// Allow public paths // Allow public paths
if (isPublicPath) { if (isPublicPath) {
return NextResponse.next(); return attachAttributionCookie(req, NextResponse.next());
} }
// For protected routes, check for userId cookie // For protected routes, check for userId cookie
@@ -94,11 +134,13 @@ export function middleware(req: NextRequest) {
if (!userId) { if (!userId) {
// Not authenticated - redirect to signup // Not authenticated - redirect to signup
const signupUrl = new URL('/signup', req.url); const signupUrl = new URL('/signup', req.url);
return NextResponse.redirect(signupUrl); const redirectTarget = `${path}${req.nextUrl.search}`;
signupUrl.searchParams.set('redirect', redirectTarget);
return attachAttributionCookie(req, NextResponse.redirect(signupUrl));
} }
// Authenticated - allow access // Authenticated - allow access
return NextResponse.next(); return attachAttributionCookie(req, NextResponse.next());
} }
export const config = { export const config = {