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?
thirtyDayNudgeSentAt DateTime?
qrCodes QRCode[]
integrations Integration[]
accounts Account[]
sessions Session[]
// RevOps attribution
signupSource String?
signupSourceSelfReported String?
signupMedium String?
signupCampaign String?
signupContent String?
signupTerm String?
signupReferrer String?
signupLandingPath String?
signupFirstSeenAt DateTime?
emailDomain String?
// Onboarding and qualification
primaryUseCase String?
primaryGoal String?
jobRole String?
companyName String?
companyWebsite String?
teamSizeBucket String?
onboardingStartedAt DateTime?
sourceConfirmedAt DateTime?
useCaseSelectedAt DateTime?
goalSelectedAt DateTime?
profileCompletedAt DateTime?
firstQrCreatedAt DateTime?
firstDynamicQrAt DateTime?
firstStaticQrAt DateTime?
firstScanAt DateTime?
activationAt DateTime?
onboardingCompletedAt DateTime?
// RevOps scoring
fitScore Int @default(0)
intentScore Int @default(0)
leadScore Int @default(0)
lifecycleStage String @default("cold")
lastQualifiedAt DateTime?
lastScoredAt DateTime?
qrCodes QRCode[]
integrations Integration[]
accounts Account[]
sessions Session[]
lifecycleLogs UserLifecycleLog[]
}
enum Plan {
@@ -161,6 +201,20 @@ model Integration {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model UserLifecycleLog {
id String @id @default(cuid())
userId String
fromStage String?
toStage String
fitScore Int @default(0)
intentScore Int @default(0)
leadScore Int @default(0)
reason String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model NewsletterSubscription {
id String @id @default(cuid())
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 Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { QRCodeSVG } from 'qrcode.react';
import { toPng } from 'html-to-image';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
@@ -14,6 +14,8 @@ import { calculateContrast, cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast';
import { trackEvent } from '@/components/PostHogProvider';
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
import {
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon
} from 'lucide-react';
@@ -101,6 +103,7 @@ function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string
export default function CreatePage() {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
const { fetchWithCsrf } = useCsrf();
const [loading, setLoading] = useState(false);
@@ -166,6 +169,42 @@ export default function CreatePage() {
fetchUserPlan();
}, []);
useEffect(() => {
const queryContentType = searchParams.get('contentType');
const useCase = searchParams.get('useCase');
const titleParam = searchParams.get('title');
const isDynamicParam = searchParams.get('dynamic');
if (queryContentType) {
setContentType(queryContentType);
}
if (titleParam) {
setTitle(titleParam);
}
if (isDynamicParam) {
setIsDynamic(isDynamicParam === '1');
}
if (useCase === 'menu_pdf') {
setContent((prev: any) => ({ ...prev, fileUrl: prev.fileUrl || '' }));
} else if (useCase === 'contact_card') {
setContent((prev: any) => ({
...prev,
firstName: prev.firstName || '',
lastName: prev.lastName || '',
}));
} else if (useCase === 'barcode') {
setContent((prev: any) => ({
...prev,
format: prev.format || 'CODE128',
}));
} else if (queryContentType === 'URL') {
setContent((prev: any) => ({ ...prev, url: prev.url || '' }));
}
}, [searchParams]);
const contrast = calculateContrast(foregroundColor, backgroundColor);
const hasGoodContrast = contrast >= 4.5;
@@ -232,6 +271,12 @@ export default function CreatePage() {
link.download = `qrcode-${title || 'download'}.png`;
link.href = dataUrl;
link.click();
trackEvent('qr_code_downloaded', {
format: 'png',
content_type: contentType,
qr_type: isDynamic ? 'dynamic' : 'static',
plan: userPlan,
});
} else {
// For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed
// Simplest is to check if we can export the SVG element directly but that misses the frame HTML.
@@ -259,6 +304,12 @@ export default function CreatePage() {
a.download = `qrcode-${title || 'download'}.svg`;
a.click();
URL.revokeObjectURL(url);
trackEvent('qr_code_downloaded', {
format: 'svg',
content_type: contentType,
qr_type: isDynamic ? 'dynamic' : 'static',
plan: userPlan,
});
}
} else {
showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info');
@@ -267,6 +318,13 @@ export default function CreatePage() {
link.download = `qrcode-${title || 'download'}.png`;
link.href = dataUrl;
link.click();
trackEvent('qr_code_downloaded', {
format: 'png',
content_type: contentType,
qr_type: isDynamic ? 'dynamic' : 'static',
plan: userPlan,
fallback_from: 'svg_with_frame',
});
}
}
} catch (err) {
@@ -355,15 +413,35 @@ export default function CreatePage() {
console.log('RESPONSE DATA:', responseData);
if (response.ok) {
trackEvent('qr_code_created', {
content_type: contentType,
qr_type: isDynamic ? 'dynamic' : 'static',
plan: userPlan,
has_logo: Boolean(logoUrl),
frame_type: frameType,
});
showToast(`QR Code "${title}" created successfully!`, 'success');
// Wait a moment so user sees the toast, then redirect
setTimeout(() => {
router.push('/dashboard');
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
if (searchParams.get('onboarding') === '1') {
router.push(appendRedirectParam('/onboarding', redirectTarget, { step: '8' }));
} else {
router.push('/dashboard');
}
router.refresh();
}, 1000);
} else {
console.error('Error creating QR code:', responseData);
if (response.status === 403 && responseData.error === 'Limit reached') {
showToast(responseData.message || 'You have reached your plan limit.', 'error');
router.push('/pricing?reason=limit_reached');
return;
}
showToast(responseData.error || 'Error creating QR code', 'error');
}
} catch (error) {

View File

@@ -13,6 +13,9 @@ import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
import { QrCode } from 'lucide-react';
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
interface QRCodeData {
id: string;
@@ -45,6 +48,7 @@ export default function DashboardPage() {
uniqueScans: 0,
});
const [analyticsData, setAnalyticsData] = useState<any>(null);
const [onboardingState, setOnboardingState] = useState<any>(null);
const blogPosts = [
@@ -117,7 +121,6 @@ export default function DashboardPage() {
// Store in localStorage for consistency
localStorage.setItem('user', JSON.stringify(user));
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
identifyUser(user.id, {
email: user.email,
name: user.name,
@@ -145,11 +148,17 @@ export default function DashboardPage() {
// Check for successful payment and verify session
useEffect(() => {
const success = searchParams.get('success');
if (success === 'true') {
const sessionId = searchParams.get('session_id');
if (success === 'true' && sessionId) {
const verifySession = async () => {
try {
const response = await fetch('/api/stripe/verify-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ sessionId }),
});
if (response.ok) {
@@ -157,6 +166,10 @@ export default function DashboardPage() {
setUserPlan(data.plan);
setUpgradedPlan(data.plan);
setShowUpgradeDialog(true);
trackEvent('upgrade_completed', {
plan: data.plan,
source: 'stripe_checkout',
});
// Remove success parameter from URL
router.replace('/dashboard');
} else {
@@ -218,6 +231,12 @@ export default function DashboardPage() {
const analytics = await analyticsResponse.json();
setAnalyticsData(analytics);
}
const onboardingResponse = await fetch('/api/onboarding');
if (onboardingResponse.ok) {
const onboardingData = await onboardingResponse.json();
setOnboardingState(onboardingData);
}
} catch (error) {
console.error('Error fetching data:', error);
setQrCodes([]);
@@ -342,6 +361,8 @@ export default function DashboardPage() {
</div>
{/* Stats Grid */}
<OnboardingChecklist state={onboardingState} />
<StatsGrid
stats={stats}
trends={{
@@ -393,7 +414,7 @@ export default function DashboardPage() {
<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>
<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>
<Link href="/create">
<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 { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf';
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
type LoginClientProps = {
showPageHeading?: boolean;
@@ -23,6 +24,7 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -58,7 +60,9 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
}
// Check for redirect parameter
const redirectUrl = searchParams.get('redirect') || '/dashboard';
const redirectUrl = data.needsOnboarding
? appendRedirectParam('/onboarding', redirectTarget)
: (redirectTarget || '/dashboard');
router.push(redirectUrl);
router.refresh();
} else {
@@ -73,7 +77,7 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
const handleGoogleSignIn = () => {
// Redirect to Google OAuth API route
window.location.href = '/api/auth/google';
window.location.href = appendRedirectParam('/api/auth/google', redirectTarget);
};
return (
@@ -199,7 +203,7 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
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
</Link>
</p>

View File

@@ -1,16 +1,18 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf';
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
export default function SignupClient() {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
const { fetchWithCsrf } = useCsrf();
const [name, setName] = useState('');
@@ -21,6 +23,7 @@ export default function SignupClient() {
const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -68,8 +71,8 @@ export default function SignupClient() {
console.error('PostHog tracking error:', error);
}
// Redirect to dashboard
router.push('/dashboard');
// Redirect to onboarding
router.push(appendRedirectParam('/onboarding', redirectTarget));
router.refresh();
} else {
setError(data.error || 'Failed to create account');
@@ -83,7 +86,7 @@ export default function SignupClient() {
const handleGoogleSignIn = () => {
// Redirect to Google OAuth API route
window.location.href = '/api/auth/google';
window.location.href = appendRedirectParam('/api/auth/google', redirectTarget);
};
return (
@@ -236,7 +239,7 @@ export default function SignupClient() {
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
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
</Link>
</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 { BillingToggle } from '@/components/ui/BillingToggle';
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
import { trackEvent } from '@/components/PostHogProvider';
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
export default function PricingPage() {
const router = useRouter();
@@ -40,6 +42,13 @@ export default function PricingPage() {
setLoading(plan);
try {
trackEvent('upgrade_clicked', {
plan,
billing_interval: billingPeriod,
source: 'pricing_page',
current_plan: currentPlan,
});
const response = await fetch('/api/stripe/create-checkout-session', {
method: 'POST',
headers: {
@@ -52,14 +61,15 @@ export default function PricingPage() {
});
if (!response.ok) {
throw new Error('Failed to create checkout session');
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.error || 'Failed to create checkout session');
}
const { url } = await response.json();
window.location.href = url;
} catch (error) {
} catch (error: any) {
console.error('Error creating checkout session:', error);
showToast('Failed to start checkout. Please try again.', 'error');
showToast(error?.message || 'Failed to start checkout. Please try again.', 'error');
setLoading(null);
}
};
@@ -132,7 +142,7 @@ export default function PricingPage() {
period: 'forever',
showDiscount: false,
features: [
'3 active dynamic QR codes (8 types available)',
`${FREE_DYNAMIC_QR_LIMIT} active dynamic QR codes (8 types available)`,
'Unlimited static QR codes',
'Basic scan tracking',
'Standard QR design templates',

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 { db } from '@/lib/db';
import { cookies } from 'next/headers';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import {
appendRedirectParam,
GOOGLE_OAUTH_STATE_COOKIE_NAME,
POST_AUTH_REDIRECT_COOKIE_NAME,
sanitizeRedirectPath,
} from '@/lib/auth-flow';
import {
ATTRIBUTION_COOKIE_NAME,
getEmailDomain,
parseAttributionCookie,
shouldResumeOnboarding,
} from '@/lib/revops';
import { triggerLifecycleScoring } from '@/lib/revops-server';
const isProduction = process.env.NODE_ENV === 'production';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state');
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
const savedOauthState = request.cookies.get(GOOGLE_OAUTH_STATE_COOKIE_NAME)?.value;
const savedRedirect = sanitizeRedirectPath(request.cookies.get(POST_AUTH_REDIRECT_COOKIE_NAME)?.value);
// If no code, redirect to Google OAuth
if (!code) {
@@ -20,14 +38,51 @@ export async function GET(request: NextRequest) {
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
const scope = 'openid email profile';
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
const oauthState = crypto.randomUUID();
const googleAuthUrl = `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
try {
if (!state || !savedOauthState || state !== savedOauthState) {
const invalidStateResponse = NextResponse.redirect(
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-state-invalid`
);
invalidStateResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
invalidStateResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
return invalidStateResponse;
}
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
@@ -83,6 +138,7 @@ export async function GET(request: NextRequest) {
// Create user if they don't exist
if (!user) {
const onboardingStartedAt = new Date();
user = await db.user.create({
data: {
email: userInfo.email,
@@ -90,6 +146,16 @@ export async function GET(request: NextRequest) {
image: userInfo.picture,
emailVerified: new Date(), // Google already verified the email
password: null, // OAuth users don't need a password
onboardingStartedAt,
emailDomain: getEmailDomain(userInfo.email),
signupSource: firstTouch?.signupSource || null,
signupMedium: firstTouch?.signupMedium || null,
signupCampaign: firstTouch?.signupCampaign || null,
signupContent: firstTouch?.signupContent || null,
signupTerm: firstTouch?.signupTerm || null,
signupReferrer: firstTouch?.signupReferrer || null,
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
},
});
@@ -147,19 +213,32 @@ export async function GET(request: NextRequest) {
}
}
// Set authentication cookie
cookies().set('userId', user.id, getAuthCookieOptions());
triggerLifecycleScoring(user.id, isNewUser ? 'signup' : 'subscription_changed');
// Redirect to dashboard with tracking params
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`);
redirectUrl.searchParams.set('authMethod', 'google');
redirectUrl.searchParams.set('isNewUser', isNewUser.toString());
const onboardingTarget = isNewUser || shouldResumeOnboarding(user)
? appendRedirectParam('/onboarding', savedRedirect, {
authMethod: 'google',
isNewUser: isNewUser.toString(),
})
: (savedRedirect || appendRedirectParam('/dashboard', null, {
authMethod: 'google',
isNewUser: isNewUser.toString(),
}));
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`);
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) {
console.error('Google OAuth error:', error);
return NextResponse.redirect(
const errorResponse = NextResponse.redirect(
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
);
errorResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
errorResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
return errorResponse;
}
}

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

View File

@@ -6,6 +6,7 @@ import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
import { shouldResumeOnboarding } from '@/lib/revops';
export async function POST(request: NextRequest) {
try {
@@ -52,6 +53,15 @@ export async function POST(request: NextRequest) {
// Find user
const user = await db.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
plan: true,
password: true,
onboardingStartedAt: true,
onboardingCompletedAt: true,
},
});
if (!user) {
@@ -76,6 +86,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
needsOnboarding: shouldResumeOnboarding(user),
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
});
} 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 { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
import { triggerLifecycleScoring } from '@/lib/revops-server';
// GET /api/qrs - List user's QR codes
export async function GET(request: NextRequest) {
@@ -47,12 +49,7 @@ export async function GET(request: NextRequest) {
}
// Plan limits
const PLAN_LIMITS = {
FREE: 3,
PRO: 50,
BUSINESS: 500,
ENTERPRISE: 99999,
};
const PLAN_LIMITS = DYNAMIC_QR_LIMITS;
// POST /api/qrs - Create a new QR code
export async function POST(request: NextRequest) {
@@ -227,6 +224,8 @@ END:VCARD`;
},
});
triggerLifecycleScoring(userId, 'qr_created');
return NextResponse.json(qrCode);
} catch (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 { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) {
try {
@@ -61,6 +62,7 @@ export async function POST(request: NextRequest) {
stripeCurrentPeriodEnd: null,
},
});
await scoreUserLifecycle(userId, 'subscription_changed');
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 });
} catch (error) {
console.error('Error canceling subscription:', error);

View File

@@ -68,6 +68,22 @@ export async function POST(request: NextRequest) {
// Create or get Stripe customer
let customerId = user.stripeCustomerId;
if (customerId) {
try {
const existingCustomer = await stripe.customers.retrieve(customerId);
if ('deleted' in existingCustomer && existingCustomer.deleted) {
customerId = null;
}
} catch (error: any) {
if (error?.code === 'resource_missing' || error?.type === 'StripeInvalidRequestError') {
customerId = null;
} else {
throw error;
}
}
}
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
@@ -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
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
@@ -96,11 +114,12 @@ export async function POST(request: NextRequest) {
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appUrl}/pricing?canceled=true`,
metadata: {
userId: user.id,
plan,
billingInterval,
},
});

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import Stripe from 'stripe';
import { sendConversionEvent } from '@/lib/meta';
import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) {
const body = await request.text();
@@ -62,6 +63,8 @@ export async function POST(request: NextRequest) {
},
});
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
// Meta CAPI — Purchase event
const amountCents = session.amount_total ?? 0;
sendConversionEvent({
@@ -101,13 +104,20 @@ export async function POST(request: NextRequest) {
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;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await db.user.update({
const updatedUser = await db.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
@@ -118,6 +128,8 @@ export async function POST(request: NextRequest) {
plan: 'FREE',
},
});
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
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 { db } from '@/lib/db';
import { hashIP } from '@/lib/hash';
import { triggerLifecycleScoring } from '@/lib/revops-server';
export async function GET(
request: NextRequest,
@@ -14,6 +15,7 @@ export async function GET(
where: { slug },
select: {
id: true,
userId: true,
content: true,
contentType: true,
},
@@ -24,7 +26,7 @@ export async function GET(
}
// Track scan (fire and forget)
trackScan(qrCode.id, request).catch(console.error);
trackScan(qrCode.id, qrCode.userId, request).catch(console.error);
// Determine destination URL
let destination = '';
@@ -121,7 +123,7 @@ export async function GET(
}
}
async function trackScan(qrId: string, request: NextRequest) {
async function trackScan(qrId: string, userId: string, request: NextRequest) {
try {
const userAgent = request.headers.get('user-agent') || '';
const referer = request.headers.get('referer') || '';
@@ -133,13 +135,28 @@ async function trackScan(qrId: string, request: NextRequest) {
const dnt = request.headers.get('dnt');
if (dnt === '1') {
// Respect Do Not Track - only increment counter
const scanTimestamp = new Date();
await db.qRScan.create({
data: {
qrId,
ipHash: 'dnt',
isUnique: false,
ts: scanTimestamp,
},
});
const activatedUsers = await db.user.updateMany({
where: {
id: userId,
firstScanAt: null,
},
data: {
firstScanAt: scanTimestamp,
activationAt: scanTimestamp,
},
});
if (activatedUsers.count > 0) {
triggerLifecycleScoring(userId, 'scan_recorded');
}
return;
}
@@ -222,9 +239,11 @@ async function trackScan(qrId: string, request: NextRequest) {
const isUnique = !existingScan;
// Create scan record
const scanTimestamp = new Date();
await db.qRScan.create({
data: {
qrId,
ts: scanTimestamp,
ipHash,
userAgent: userAgent.substring(0, 255),
device,
@@ -237,6 +256,21 @@ async function trackScan(qrId: string, request: NextRequest) {
isUnique,
},
});
const activatedUsers = await db.user.updateMany({
where: {
id: userId,
firstScanAt: null,
},
data: {
firstScanAt: scanTimestamp,
activationAt: scanTimestamp,
},
});
if (activatedUsers.count > 0) {
triggerLifecycleScoring(userId, 'scan_recorded');
}
} catch (error) {
// Don't throw - this is fire and forget
}

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 Link from 'next/link';
import { motion } from 'framer-motion';
import { trackEvent } from '@/components/PostHogProvider';
const AIComingSoonBanner = () => {
const [email, setEmail] = useState('');
@@ -28,9 +29,16 @@ const AIComingSoonBanner = () => {
const data = await response.json();
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);
setEmail('');
} 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 {
FREE_DYNAMIC_QR_LIMIT,
PRO_DYNAMIC_QR_LIMIT,
BUSINESS_DYNAMIC_QR_LIMIT,
} from '@/lib/plans';
// Use a placeholder during build time, real key at runtime
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
@@ -22,13 +27,13 @@ export const STRIPE_PLANS = {
currency: 'EUR',
interval: 'month',
features: [
'3 dynamische QR-Codes',
`${FREE_DYNAMIC_QR_LIMIT} dynamische QR-Codes`,
'Basis-Tracking (Scans + Standort)',
'Einfache Designs',
'Unbegrenzte statische QR-Codes',
],
limits: {
dynamicQRCodes: 3,
dynamicQRCodes: FREE_DYNAMIC_QR_LIMIT,
staticQRCodes: -1, // unlimited
teamMembers: 1,
},
@@ -48,7 +53,7 @@ export const STRIPE_PLANS = {
'SVG/PNG Download',
],
limits: {
dynamicQRCodes: 50,
dynamicQRCodes: PRO_DYNAMIC_QR_LIMIT,
staticQRCodes: -1,
teamMembers: 1,
},
@@ -68,7 +73,7 @@ export const STRIPE_PLANS = {
'Priority Support',
],
limits: {
dynamicQRCodes: 500,
dynamicQRCodes: BUSINESS_DYNAMIC_QR_LIMIT,
staticQRCodes: -1,
teamMembers: 1,
},

View File

@@ -122,6 +122,17 @@ export const updateProfileSchema = z.object({
.trim(),
});
export const onboardingUpdateSchema = z.object({
signupSourceSelfReported: z.string().max(100).optional(),
primaryUseCase: z.string().max(100).optional(),
primaryGoal: z.string().max(100).optional(),
jobRole: z.string().max(100).optional(),
companyName: z.string().max(200).optional(),
companyWebsite: z.string().max(200).optional(),
teamSizeBucket: z.string().max(100).optional(),
markProfileComplete: z.boolean().optional(),
});
export const changePasswordSchema = z.object({
currentPassword: z.string()
.min(1, 'Current password is required'),

View File

@@ -1,21 +1,61 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import {
ATTRIBUTION_COOKIE_NAME,
buildAttributionSnapshot,
serializeAttributionCookie,
} from '@/lib/revops';
const isProduction = process.env.NODE_ENV === 'production';
function attachAttributionCookie(req: NextRequest, response: NextResponse) {
if (req.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value) {
return response;
}
const path = req.nextUrl.pathname;
if (path.startsWith('/api/') || path.startsWith('/_next') || path.startsWith('/r/') || path.includes('.')) {
return response;
}
const snapshot = buildAttributionSnapshot({
utmSource: req.nextUrl.searchParams.get('utm_source'),
utmMedium: req.nextUrl.searchParams.get('utm_medium'),
utmCampaign: req.nextUrl.searchParams.get('utm_campaign'),
utmContent: req.nextUrl.searchParams.get('utm_content'),
utmTerm: req.nextUrl.searchParams.get('utm_term'),
referrer: req.headers.get('referer'),
landingPath: path,
firstSeenAt: new Date(),
});
response.cookies.set(ATTRIBUTION_COOKIE_NAME, serializeAttributionCookie(snapshot), {
httpOnly: false,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 90,
});
return response;
}
export function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
// 301 Redirects for /guide -> /learn to avoid duplicate content and consolidate authority
if (path === '/guide/tracking-analytics') {
return NextResponse.redirect(new URL('/learn/tracking', req.url), 301);
return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/tracking', req.url), 301));
}
if (path === '/guide/bulk-qr-code-generation') {
return NextResponse.redirect(new URL('/learn/developer', req.url), 301);
return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/developer', req.url), 301));
}
if (path === '/guide/qr-code-best-practices') {
return NextResponse.redirect(new URL('/learn/basics', req.url), 301);
return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/basics', req.url), 301));
}
if (path === '/create-qr') {
return NextResponse.redirect(new URL('/dynamic-qr-code-generator', req.url), 301);
return attachAttributionCookie(req, NextResponse.redirect(new URL('/dynamic-qr-code-generator', req.url), 301));
}
// Public routes that don't require authentication
@@ -70,22 +110,22 @@ export function middleware(req: NextRequest) {
// Allow API routes
if (path.startsWith('/api/')) {
return NextResponse.next();
return attachAttributionCookie(req, NextResponse.next());
}
// Allow redirect routes (QR code redirects)
if (path.startsWith('/r/')) {
return NextResponse.next();
return attachAttributionCookie(req, NextResponse.next());
}
// Allow static files
if (path.includes('.') || path.startsWith('/_next')) {
return NextResponse.next();
return attachAttributionCookie(req, NextResponse.next());
}
// Allow public paths
if (isPublicPath) {
return NextResponse.next();
return attachAttributionCookie(req, NextResponse.next());
}
// For protected routes, check for userId cookie
@@ -94,11 +134,13 @@ export function middleware(req: NextRequest) {
if (!userId) {
// Not authenticated - redirect to signup
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
return NextResponse.next();
return attachAttributionCookie(req, NextResponse.next());
}
export const config = {