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

@@ -11,11 +11,11 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
name String? name String?
password String? password String?
image String? image String?
emailVerified DateTime? emailVerified DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -32,16 +32,56 @@ model User {
resetPasswordToken String? @unique resetPasswordToken String? @unique
resetPasswordExpires DateTime? resetPasswordExpires DateTime?
// Retention email tracking // Retention email tracking
activationNudgeSentAt DateTime? activationNudgeSentAt DateTime?
upgradeNudgeSentAt DateTime? upgradeNudgeSentAt DateTime?
thirtyDayNudgeSentAt DateTime? thirtyDayNudgeSentAt DateTime?
qrCodes QRCode[] // RevOps attribution
integrations Integration[] signupSource String?
accounts Account[] signupSourceSelfReported String?
sessions Session[] 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 { enum Plan {
FREE FREE
@@ -149,7 +189,7 @@ model QRScan {
@@index([qrId, ts]) @@index([qrId, ts])
} }
model Integration { model Integration {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
provider String provider String
@@ -158,8 +198,22 @@ model Integration {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model UserLifecycleLog {
id String @id @default(cuid())
userId String
fromStage String?
toStage String
fitScore Int @default(0)
intentScore Int @default(0)
leadScore Int @default(0)
reason String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model NewsletterSubscription { model NewsletterSubscription {
id String @id @default(cuid()) id String @id @default(cuid())

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { toPng } from 'html-to-image'; import { toPng } from 'html-to-image';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
@@ -11,12 +11,14 @@ import { Select } from '@/components/ui/Select';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { calculateContrast, cn } from '@/lib/utils'; import { calculateContrast, cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { import { trackEvent } from '@/components/PostHogProvider';
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
} from 'lucide-react'; import {
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon
} from 'lucide-react';
import Barcode from 'react-barcode'; import Barcode from 'react-barcode';
// Tooltip component for form field help // Tooltip component for form field help
@@ -99,9 +101,10 @@ function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string
return new XMLSerializer().serializeToString(cloned); return new XMLSerializer().serializeToString(cloned);
} }
export default function CreatePage() { export default function CreatePage() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const searchParams = useSearchParams();
const { t } = useTranslation();
const { fetchWithCsrf } = useCsrf(); const { fetchWithCsrf } = useCsrf();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -145,14 +148,14 @@ export default function CreatePage() {
const [excavate, setExcavate] = useState(true); const [excavate, setExcavate] = useState(true);
// QR preview // QR preview
const [qrDataUrl, setQrDataUrl] = useState(''); const [qrDataUrl, setQrDataUrl] = useState('');
// Check if user can customize colors (PRO+ only) // Check if user can customize colors (PRO+ only)
const canCustomizeColors = userPlan === 'PRO' || userPlan === 'BUSINESS'; const canCustomizeColors = userPlan === 'PRO' || userPlan === 'BUSINESS';
// Load user plan // Load user plan
useEffect(() => { useEffect(() => {
const fetchUserPlan = async () => { const fetchUserPlan = async () => {
try { try {
const response = await fetch('/api/user/plan'); const response = await fetch('/api/user/plan');
if (response.ok) { if (response.ok) {
@@ -163,8 +166,44 @@ export default function CreatePage() {
console.error('Error fetching user plan:', error); console.error('Error fetching user plan:', error);
} }
}; };
fetchUserPlan(); fetchUserPlan();
}, []); }, []);
useEffect(() => {
const queryContentType = searchParams.get('contentType');
const useCase = searchParams.get('useCase');
const titleParam = searchParams.get('title');
const isDynamicParam = searchParams.get('dynamic');
if (queryContentType) {
setContentType(queryContentType);
}
if (titleParam) {
setTitle(titleParam);
}
if (isDynamicParam) {
setIsDynamic(isDynamicParam === '1');
}
if (useCase === 'menu_pdf') {
setContent((prev: any) => ({ ...prev, fileUrl: prev.fileUrl || '' }));
} else if (useCase === 'contact_card') {
setContent((prev: any) => ({
...prev,
firstName: prev.firstName || '',
lastName: prev.lastName || '',
}));
} else if (useCase === 'barcode') {
setContent((prev: any) => ({
...prev,
format: prev.format || 'CODE128',
}));
} else if (queryContentType === 'URL') {
setContent((prev: any) => ({ ...prev, url: prev.url || '' }));
}
}, [searchParams]);
const contrast = calculateContrast(foregroundColor, backgroundColor); const contrast = calculateContrast(foregroundColor, backgroundColor);
const hasGoodContrast = contrast >= 4.5; const hasGoodContrast = contrast >= 4.5;
@@ -226,13 +265,19 @@ export default function CreatePage() {
const downloadQR = async (format: 'svg' | 'png') => { const downloadQR = async (format: 'svg' | 'png') => {
if (!qrRef.current) return; if (!qrRef.current) return;
try { try {
if (format === 'png') { if (format === 'png') {
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
const link = document.createElement('a'); const link = document.createElement('a');
link.download = `qrcode-${title || 'download'}.png`; link.download = `qrcode-${title || 'download'}.png`;
link.href = dataUrl; link.href = dataUrl;
link.click(); link.click();
} else { 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 // For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed
// Simplest is to check if we can export the SVG element directly but that misses the frame HTML. // Simplest is to check if we can export the SVG element directly but that misses the frame HTML.
// html-to-image can generate SVG too. // html-to-image can generate SVG too.
@@ -254,21 +299,34 @@ export default function CreatePage() {
: new XMLSerializer().serializeToString(svgElement); : new XMLSerializer().serializeToString(svgElement);
const blob = new Blob([svgData], { type: 'image/svg+xml' }); const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `qrcode-${title || 'download'}.svg`; a.download = `qrcode-${title || 'download'}.svg`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} trackEvent('qr_code_downloaded', {
} else { format: 'svg',
showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info'); content_type: contentType,
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); qr_type: isDynamic ? 'dynamic' : 'static',
const link = document.createElement('a'); plan: userPlan,
link.download = `qrcode-${title || 'download'}.png`; });
link.href = dataUrl; }
link.click(); } else {
} showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info');
} const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
const link = document.createElement('a');
link.download = `qrcode-${title || 'download'}.png`;
link.href = dataUrl;
link.click();
trackEvent('qr_code_downloaded', {
format: 'png',
content_type: contentType,
qr_type: isDynamic ? 'dynamic' : 'static',
plan: userPlan,
fallback_from: 'svg_with_frame',
});
}
}
} catch (err) { } catch (err) {
console.error('Error downloading QR code:', err); console.error('Error downloading QR code:', err);
showToast('Error downloading QR code', 'error'); showToast('Error downloading QR code', 'error');
@@ -354,18 +412,38 @@ export default function CreatePage() {
const responseData = await response.json(); const responseData = await response.json();
console.log('RESPONSE DATA:', responseData); console.log('RESPONSE DATA:', responseData);
if (response.ok) { if (response.ok) {
showToast(`QR Code "${title}" created successfully!`, 'success'); trackEvent('qr_code_created', {
content_type: contentType,
// Wait a moment so user sees the toast, then redirect qr_type: isDynamic ? 'dynamic' : 'static',
setTimeout(() => { plan: userPlan,
router.push('/dashboard'); has_logo: Boolean(logoUrl),
router.refresh(); frame_type: frameType,
}, 1000); });
} else {
console.error('Error creating QR code:', responseData); showToast(`QR Code "${title}" created successfully!`, 'success');
showToast(responseData.error || 'Error creating QR code', 'error');
} // Wait a moment so user sees the toast, then redirect
setTimeout(() => {
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
if (searchParams.get('onboarding') === '1') {
router.push(appendRedirectParam('/onboarding', redirectTarget, { step: '8' }));
} else {
router.push('/dashboard');
}
router.refresh();
}, 1000);
} else {
console.error('Error creating QR code:', responseData);
if (response.status === 403 && responseData.error === 'Limit reached') {
showToast(responseData.message || 'You have reached your plan limit.', 'error');
router.push('/pricing?reason=limit_reached');
return;
}
showToast(responseData.error || 'Error creating QR code', 'error');
}
} catch (error) { } catch (error) {
console.error('Error creating QR code:', error); console.error('Error creating QR code:', error);
showToast('Error creating QR code. Please try again.', 'error'); showToast('Error creating QR code. Please try again.', 'error');
@@ -1180,4 +1258,4 @@ export default function CreatePage() {
</form> </form>
</div> </div>
); );
} }

View File

@@ -7,12 +7,15 @@ import { StatsGrid } from '@/components/dashboard/StatsGrid';
import { QRCodeCard } from '@/components/dashboard/QRCodeCard'; import { QRCodeCard } from '@/components/dashboard/QRCodeCard';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
import { QrCode } from 'lucide-react'; import { QrCode } from 'lucide-react';
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
interface QRCodeData { interface QRCodeData {
id: string; id: string;
@@ -44,7 +47,8 @@ export default function DashboardPage() {
conversionRate: 0, conversionRate: 0,
uniqueScans: 0, uniqueScans: 0,
}); });
const [analyticsData, setAnalyticsData] = useState<any>(null); const [analyticsData, setAnalyticsData] = useState<any>(null);
const [onboardingState, setOnboardingState] = useState<any>(null);
const blogPosts = [ const blogPosts = [
@@ -117,12 +121,11 @@ export default function DashboardPage() {
// Store in localStorage for consistency // Store in localStorage for consistency
localStorage.setItem('user', JSON.stringify(user)); localStorage.setItem('user', JSON.stringify(user));
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider'); identifyUser(user.id, {
identifyUser(user.id, { email: user.email,
email: user.email, name: user.name,
name: user.name, plan: user.plan || 'FREE',
plan: user.plan || 'FREE', provider: 'google',
provider: 'google',
}); });
trackEvent(isNewUser ? 'user_signup' : 'user_login', { trackEvent(isNewUser ? 'user_signup' : 'user_login', {
@@ -143,25 +146,35 @@ export default function DashboardPage() {
}, [searchParams, router]); }, [searchParams, router]);
// Check for successful payment and verify session // Check for successful payment and verify session
useEffect(() => { useEffect(() => {
const success = searchParams.get('success'); const success = searchParams.get('success');
if (success === 'true') { const sessionId = searchParams.get('session_id');
const verifySession = async () => {
try { if (success === 'true' && sessionId) {
const response = await fetch('/api/stripe/verify-session', { const verifySession = async () => {
method: 'POST', try {
}); const response = await fetch('/api/stripe/verify-session', {
method: 'POST',
if (response.ok) { headers: {
const data = await response.json(); 'Content-Type': 'application/json',
setUserPlan(data.plan); },
setUpgradedPlan(data.plan); body: JSON.stringify({ sessionId }),
setShowUpgradeDialog(true); });
// Remove success parameter from URL
router.replace('/dashboard'); if (response.ok) {
} else { const data = await response.json();
console.error('Failed to verify session:', await response.text()); setUserPlan(data.plan);
} setUpgradedPlan(data.plan);
setShowUpgradeDialog(true);
trackEvent('upgrade_completed', {
plan: data.plan,
source: 'stripe_checkout',
});
// Remove success parameter from URL
router.replace('/dashboard');
} else {
console.error('Failed to verify session:', await response.text());
}
} catch (error) { } catch (error) {
console.error('Error verifying session:', error); console.error('Error verifying session:', error);
} }
@@ -212,13 +225,19 @@ export default function DashboardPage() {
setUserPlan(userData.plan || 'FREE'); setUserPlan(userData.plan || 'FREE');
} }
// Fetch analytics data for trends (last 30 days = month comparison) // Fetch analytics data for trends (last 30 days = month comparison)
const analyticsResponse = await fetch('/api/analytics/summary?range=30'); const analyticsResponse = await fetch('/api/analytics/summary?range=30');
if (analyticsResponse.ok) { if (analyticsResponse.ok) {
const analytics = await analyticsResponse.json(); const analytics = await analyticsResponse.json();
setAnalyticsData(analytics); setAnalyticsData(analytics);
} }
} catch (error) {
const onboardingResponse = await fetch('/api/onboarding');
if (onboardingResponse.ok) {
const onboardingData = await onboardingResponse.json();
setOnboardingState(onboardingData);
}
} catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
setQrCodes([]); setQrCodes([]);
setStats({ setStats({
@@ -341,9 +360,11 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
<StatsGrid <OnboardingChecklist state={onboardingState} />
stats={stats}
<StatsGrid
stats={stats}
trends={{ trends={{
totalScans: analyticsData?.summary.scansTrend, totalScans: analyticsData?.summary.scansTrend,
comparisonPeriod: analyticsData?.summary.comparisonPeriod || 'month' comparisonPeriod: analyticsData?.summary.comparisonPeriod || 'month'
@@ -393,8 +414,8 @@ export default function DashboardPage() {
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" /> <QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-700 mb-2">Create your first QR code</h3> <h3 className="text-lg font-semibold text-gray-700 mb-2">Create your first QR code</h3>
<p className="text-gray-500 mb-6 max-w-sm mx-auto"> <p className="text-gray-500 mb-6 max-w-sm mx-auto">
You have 3 free dynamic QR codes. They redirect wherever you want and track every scan. You have {FREE_DYNAMIC_QR_LIMIT} free dynamic QR codes. They redirect wherever you want and track every scan.
</p> </p>
<Link href="/create"> <Link href="/create">
<Button>Create QR Code it takes 90 seconds</Button> <Button>Create QR Code it takes 90 seconds</Button>
</Link> </Link>
@@ -521,4 +542,4 @@ export default function DashboardPage() {
</Dialog> </Dialog>
</div> </div>
); );
} }

View File

@@ -5,9 +5,10 @@ import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/Card'; import { Card, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
type LoginClientProps = { type LoginClientProps = {
showPageHeading?: boolean; showPageHeading?: boolean;
@@ -20,9 +21,10 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
const { fetchWithCsrf, loading: csrfLoading } = useCsrf(); const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -57,10 +59,12 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
console.error('PostHog tracking error:', error); console.error('PostHog tracking error:', error);
} }
// Check for redirect parameter // Check for redirect parameter
const redirectUrl = searchParams.get('redirect') || '/dashboard'; const redirectUrl = data.needsOnboarding
router.push(redirectUrl); ? appendRedirectParam('/onboarding', redirectTarget)
router.refresh(); : (redirectTarget || '/dashboard');
router.push(redirectUrl);
router.refresh();
} else { } else {
setError(data.error || 'Invalid email or password'); setError(data.error || 'Invalid email or password');
} }
@@ -71,10 +75,10 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
} }
}; };
const handleGoogleSignIn = () => { const handleGoogleSignIn = () => {
// Redirect to Google OAuth API route // Redirect to Google OAuth API route
window.location.href = '/api/auth/google'; window.location.href = appendRedirectParam('/api/auth/google', redirectTarget);
}; };
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
@@ -199,9 +203,9 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Don't have an account?{' '} Don't have an account?{' '}
<Link href="/signup" className="text-primary-600 hover:text-primary-700 font-medium"> <Link href={appendRedirectParam('/signup', redirectTarget)} className="text-primary-600 hover:text-primary-700 font-medium">
Sign up Sign up
</Link> </Link>
</p> </p>
</div> </div>
</CardContent> </CardContent>

View File

@@ -1,26 +1,29 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
export default function SignupClient() {
const router = useRouter(); export default function SignupClient() {
const { t } = useTranslation(); const router = useRouter();
const { fetchWithCsrf } = useCsrf(); const searchParams = useSearchParams();
const { t } = useTranslation();
const { fetchWithCsrf } = useCsrf();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -68,9 +71,9 @@ export default function SignupClient() {
console.error('PostHog tracking error:', error); console.error('PostHog tracking error:', error);
} }
// Redirect to dashboard // Redirect to onboarding
router.push('/dashboard'); router.push(appendRedirectParam('/onboarding', redirectTarget));
router.refresh(); router.refresh();
} else { } else {
setError(data.error || 'Failed to create account'); setError(data.error || 'Failed to create account');
} }
@@ -81,10 +84,10 @@ export default function SignupClient() {
} }
}; };
const handleGoogleSignIn = () => { const handleGoogleSignIn = () => {
// Redirect to Google OAuth API route // Redirect to Google OAuth API route
window.location.href = '/api/auth/google'; window.location.href = appendRedirectParam('/api/auth/google', redirectTarget);
}; };
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
@@ -234,11 +237,11 @@ export default function SignupClient() {
</form> </form>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Already have an account?{' '} Already have an account?{' '}
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium"> <Link href={appendRedirectParam('/login', redirectTarget)} className="text-primary-600 hover:text-primary-700 font-medium">
Sign in Sign in
</Link> </Link>
</p> </p>
</div> </div>
</CardContent> </CardContent>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,430 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import {
getGoalLabel,
getLifecycleStageLabel,
getRoleLabel,
getSourceLabel,
getTeamSizeLabel,
getUseCaseLabel,
} from '@/lib/revops';
import { db } from '@/lib/db';
import { getMetricSnapshot, getUpgradeCandidateBadges } from '@/lib/revops-server';
export const dynamic = 'force-dynamic';
type HydratedUser = {
id: string;
name: string | null;
email: string;
emailDomain: string | null;
plan: string;
lifecycleStage: string;
fitScore: number;
intentScore: number;
leadScore: number;
signupSource: string | null;
signupSourceSelfReported: string | null;
signupCampaign: string | null;
signupLandingPath: string | null;
primaryUseCase: string | null;
primaryGoal: string | null;
jobRole: string | null;
companyName: string | null;
companyWebsite: string | null;
teamSizeBucket: string | null;
createdAt: string;
firstQrCreatedAt: string | null;
activationAt: string | null;
firstDynamicQrAt: string | null;
qrCount: number;
dynamicQrCount: number;
scanCount: number;
contentTypeCount: number;
upgradeBadges: string[];
};
function hasAdminSession() {
const adminCookie = cookies().get('newsletter-admin');
return adminCookie?.value === 'authenticated';
}
function toIso(value: Date | null) {
return value ? value.toISOString() : null;
}
function safeDate(value: string | null) {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function applyUserFilters(users: HydratedUser[], request: NextRequest) {
const stage = request.nextUrl.searchParams.get('stage');
const source = request.nextUrl.searchParams.get('source');
const campaign = request.nextUrl.searchParams.get('campaign');
const landingPath = request.nextUrl.searchParams.get('landingPath');
const useCase = request.nextUrl.searchParams.get('useCase');
const goal = request.nextUrl.searchParams.get('goal');
const role = request.nextUrl.searchParams.get('role');
const teamSize = request.nextUrl.searchParams.get('teamSize');
const plan = request.nextUrl.searchParams.get('plan');
const search = request.nextUrl.searchParams.get('search')?.toLowerCase().trim();
const from = safeDate(request.nextUrl.searchParams.get('from'));
const to = safeDate(request.nextUrl.searchParams.get('to'));
return users.filter((user) => {
const createdAt = new Date(user.createdAt);
const matchesSearch = !search || [
user.name,
user.email,
user.companyName,
user.emailDomain,
].filter(Boolean).some((value) => value!.toLowerCase().includes(search));
return (
(!stage || user.lifecycleStage === stage) &&
(!source || user.signupSource === source) &&
(!campaign || user.signupCampaign === campaign) &&
(!landingPath || user.signupLandingPath === landingPath) &&
(!useCase || user.primaryUseCase === useCase) &&
(!goal || user.primaryGoal === goal) &&
(!role || user.jobRole === role) &&
(!teamSize || user.teamSizeBucket === teamSize) &&
(!plan || user.plan === plan) &&
(!from || createdAt >= from) &&
(!to || createdAt <= to) &&
matchesSearch
);
});
}
function sortUsers(users: HydratedUser[], sort: string) {
const sorted = [...users];
sorted.sort((a, b) => {
switch (sort) {
case 'createdAt_asc':
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case 'activationAt_desc':
return new Date(b.activationAt || 0).getTime() - new Date(a.activationAt || 0).getTime();
case 'leadScore_asc':
return a.leadScore - b.leadScore;
case 'fitScore_desc':
return b.fitScore - a.fitScore;
case 'intentScore_desc':
return b.intentScore - a.intentScore;
case 'leadScore_desc':
default:
return b.leadScore - a.leadScore || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
});
return sorted;
}
function buildGroupedRows(users: HydratedUser[], key: keyof HydratedUser) {
const rows = new Map<string, {
key: string;
signups: number;
firstQr: number;
activated: number;
hot: number;
upgradeCandidates: number;
paid: number;
}>();
users.forEach((user) => {
const rawValue = (user[key] as string | null) || 'unknown';
const row = rows.get(rawValue) || {
key: rawValue,
signups: 0,
firstQr: 0,
activated: 0,
hot: 0,
upgradeCandidates: 0,
paid: 0,
};
row.signups += 1;
if (user.firstQrCreatedAt) row.firstQr += 1;
if (user.activationAt) row.activated += 1;
if (user.lifecycleStage === 'hot') row.hot += 1;
if (user.lifecycleStage === 'upgrade_candidate') row.upgradeCandidates += 1;
if (user.lifecycleStage === 'paid') row.paid += 1;
rows.set(rawValue, row);
});
return Array.from(rows.values()).sort((a, b) => b.signups - a.signups);
}
function buildFunnel(users: HydratedUser[]) {
return {
signup: users.length,
sourceConfirmed: users.filter((user) => Boolean(user.signupSourceSelfReported)).length,
useCaseSelected: users.filter((user) => Boolean(user.primaryUseCase)).length,
goalSelected: users.filter((user) => Boolean(user.primaryGoal)).length,
profileCaptured: users.filter((user) => Boolean(user.jobRole && user.teamSizeBucket)).length,
firstQrCreated: users.filter((user) => Boolean(user.firstQrCreatedAt)).length,
firstDynamicQrCreated: users.filter((user) => Boolean(user.firstDynamicQrAt)).length,
activated: users.filter((user) => Boolean(user.activationAt)).length,
};
}
function buildLifecycleSummary(users: HydratedUser[]) {
return {
cold: users.filter((user) => user.lifecycleStage === 'cold').length,
activated: users.filter((user) => user.lifecycleStage === 'activated').length,
warm: users.filter((user) => user.lifecycleStage === 'warm').length,
hot: users.filter((user) => user.lifecycleStage === 'hot').length,
upgrade_candidate: users.filter((user) => user.lifecycleStage === 'upgrade_candidate').length,
paid: users.filter((user) => user.lifecycleStage === 'paid').length,
};
}
function buildCsv(rows: HydratedUser[]) {
const headers = [
'name',
'email',
'email_domain',
'plan',
'lifecycle_stage',
'fit_score',
'intent_score',
'lead_score',
'source',
'self_reported_source',
'campaign',
'landing_page',
'use_case',
'goal',
'role',
'company',
'team_size',
'created_at',
'first_qr_created_at',
'activation_at',
'qr_count',
'dynamic_qr_count',
'scan_count',
];
const escape = (value: string | number | null) => {
const normalized = value == null ? '' : String(value);
return `"${normalized.replace(/"/g, '""')}"`;
};
const lines = rows.map((row) => [
row.name,
row.email,
row.emailDomain,
row.plan,
row.lifecycleStage,
row.fitScore,
row.intentScore,
row.leadScore,
row.signupSource,
row.signupSourceSelfReported,
row.signupCampaign,
row.signupLandingPath,
row.primaryUseCase,
row.primaryGoal,
row.jobRole,
row.companyName,
row.teamSizeBucket,
row.createdAt,
row.firstQrCreatedAt,
row.activationAt,
row.qrCount,
row.dynamicQrCount,
row.scanCount,
].map(escape).join(','));
return [headers.join(','), ...lines].join('\n');
}
export async function GET(request: NextRequest) {
try {
if (!hasAdminSession()) {
return NextResponse.json({ error: 'Unauthorized - Admin login required' }, { status: 401 });
}
const rawUsers = await db.user.findMany({
select: {
id: true,
name: true,
email: true,
emailDomain: true,
plan: true,
lifecycleStage: true,
fitScore: true,
intentScore: true,
leadScore: true,
signupSource: true,
signupSourceSelfReported: true,
signupCampaign: true,
signupLandingPath: true,
primaryUseCase: true,
primaryGoal: true,
jobRole: true,
companyName: true,
companyWebsite: true,
teamSizeBucket: true,
createdAt: true,
firstQrCreatedAt: true,
firstDynamicQrAt: true,
activationAt: true,
qrCodes: {
select: {
type: true,
contentType: true,
createdAt: true,
_count: {
select: {
scans: true,
},
},
},
},
},
orderBy: {
createdAt: 'desc',
},
});
const users: HydratedUser[] = rawUsers.map((user) => {
const metrics = getMetricSnapshot(user.qrCodes);
return {
id: user.id,
name: user.name,
email: user.email,
emailDomain: user.emailDomain,
plan: user.plan,
lifecycleStage: user.lifecycleStage,
fitScore: user.fitScore,
intentScore: user.intentScore,
leadScore: user.leadScore,
signupSource: user.signupSource,
signupSourceSelfReported: user.signupSourceSelfReported,
signupCampaign: user.signupCampaign,
signupLandingPath: user.signupLandingPath,
primaryUseCase: user.primaryUseCase,
primaryGoal: user.primaryGoal,
jobRole: user.jobRole,
companyName: user.companyName,
companyWebsite: user.companyWebsite,
teamSizeBucket: user.teamSizeBucket,
createdAt: user.createdAt.toISOString(),
firstQrCreatedAt: toIso(user.firstQrCreatedAt),
activationAt: toIso(user.activationAt),
firstDynamicQrAt: toIso(user.firstDynamicQrAt),
qrCount: metrics.qrCount,
dynamicQrCount: metrics.dynamicQrCount,
scanCount: metrics.scanCount,
contentTypeCount: metrics.contentTypeCount,
upgradeBadges: getUpgradeCandidateBadges(user, metrics),
};
});
const filteredUsers = sortUsers(
applyUserFilters(users, request),
request.nextUrl.searchParams.get('sort') || 'leadScore_desc'
);
if (request.nextUrl.searchParams.get('format') === 'csv') {
const csv = buildCsv(filteredUsers);
return new NextResponse(csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="qrmaster-revops-export.csv"',
},
});
}
const page = Number(request.nextUrl.searchParams.get('page') || '1');
const pageSize = Number(request.nextUrl.searchParams.get('pageSize') || '25');
const total = filteredUsers.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const paginatedUsers = filteredUsers.slice((page - 1) * pageSize, page * pageSize);
const acquisitionBySource = buildGroupedRows(users, 'signupSource').map((row) => ({
...row,
label: getSourceLabel(row.key),
activationRate: row.signups ? Math.round((row.activated / row.signups) * 100) : 0,
}));
const acquisitionByCampaign = buildGroupedRows(users, 'signupCampaign');
const acquisitionByLandingPath = buildGroupedRows(users, 'signupLandingPath');
const funnel = buildFunnel(users);
const lifecycleSummary = buildLifecycleSummary(users);
const mismatchCount = users.filter(
(user) =>
user.signupSource &&
user.signupSourceSelfReported &&
user.signupSource !== user.signupSourceSelfReported
).length;
const upgradeCandidates = users
.filter((user) => user.plan === 'FREE' && user.lifecycleStage === 'upgrade_candidate')
.sort((a, b) => b.leadScore - a.leadScore)
.slice(0, 25);
const filterOptions = {
stages: ['cold', 'activated', 'warm', 'hot', 'upgrade_candidate', 'paid'],
sources: Array.from(new Set(users.map((user) => user.signupSource).filter((value): value is string => Boolean(value)))),
campaigns: Array.from(new Set(users.map((user) => user.signupCampaign).filter((value): value is string => Boolean(value)))),
landingPaths: Array.from(new Set(users.map((user) => user.signupLandingPath).filter((value): value is string => Boolean(value)))),
useCases: Array.from(new Set(users.map((user) => user.primaryUseCase).filter((value): value is string => Boolean(value)))),
goals: Array.from(new Set(users.map((user) => user.primaryGoal).filter((value): value is string => Boolean(value)))),
roles: Array.from(new Set(users.map((user) => user.jobRole).filter((value): value is string => Boolean(value)))),
teamSizes: Array.from(new Set(users.map((user) => user.teamSizeBucket).filter((value): value is string => Boolean(value)))),
plans: Array.from(new Set(users.map((user) => user.plan).filter((value): value is string => Boolean(value)))),
};
return NextResponse.json({
overview: {
totalUsers: users.length,
mismatchCount,
activatedUsers: funnel.activated,
paidUsers: lifecycleSummary.paid,
},
acquisition: {
bySource: acquisitionBySource,
byCampaign: acquisitionByCampaign.slice(0, 15),
byLandingPath: acquisitionByLandingPath.slice(0, 15),
},
funnel,
funnelBreakdowns: {
bySource: acquisitionBySource.slice(0, 10),
byUseCase: buildGroupedRows(users, 'primaryUseCase').map((row) => ({ ...row, label: getUseCaseLabel(row.key) })),
byRole: buildGroupedRows(users, 'jobRole').map((row) => ({ ...row, label: getRoleLabel(row.key) })),
byTeamSize: buildGroupedRows(users, 'teamSizeBucket').map((row) => ({ ...row, label: getTeamSizeLabel(row.key) })),
},
lifecycleSummary,
campaignSourceQuality: acquisitionBySource,
upgradeCandidates,
filterOptions,
segments: {
total,
page,
pageSize,
totalPages,
rows: paginatedUsers.map((user) => ({
...user,
lifecycleStageLabel: getLifecycleStageLabel(user.lifecycleStage),
signupSourceLabel: getSourceLabel(user.signupSource),
signupSourceSelfReportedLabel: getSourceLabel(user.signupSourceSelfReported),
primaryUseCaseLabel: getUseCaseLabel(user.primaryUseCase),
primaryGoalLabel: getGoalLabel(user.primaryGoal),
jobRoleLabel: getRoleLabel(user.jobRole),
teamSizeLabel: getTeamSizeLabel(user.teamSizeBucket),
})),
},
});
} catch (error) {
console.error('Error fetching RevOps dashboard data:', error);
return NextResponse.json({ error: 'Failed to fetch RevOps dashboard data' }, { status: 500 });
}
}

View File

@@ -1,14 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { cookies } from 'next/headers'; import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { getAuthCookieOptions } from '@/lib/cookieConfig'; import {
appendRedirectParam,
export async function GET(request: NextRequest) { GOOGLE_OAUTH_STATE_COOKIE_NAME,
const { searchParams } = new URL(request.url); POST_AUTH_REDIRECT_COOKIE_NAME,
const code = searchParams.get('code'); sanitizeRedirectPath,
} from '@/lib/auth-flow';
// If no code, redirect to Google OAuth import {
if (!code) { ATTRIBUTION_COOKIE_NAME,
getEmailDomain,
parseAttributionCookie,
shouldResumeOnboarding,
} from '@/lib/revops';
import { triggerLifecycleScoring } from '@/lib/revops-server';
const isProduction = process.env.NODE_ENV === 'production';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state');
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
const savedOauthState = request.cookies.get(GOOGLE_OAUTH_STATE_COOKIE_NAME)?.value;
const savedRedirect = sanitizeRedirectPath(request.cookies.get(POST_AUTH_REDIRECT_COOKIE_NAME)?.value);
// If no code, redirect to Google OAuth
if (!code) {
const googleClientId = process.env.GOOGLE_CLIENT_ID; const googleClientId = process.env.GOOGLE_CLIENT_ID;
if (!googleClientId) { if (!googleClientId) {
@@ -17,19 +35,56 @@ export async function GET(request: NextRequest) {
{ status: 500 } { status: 500 }
); );
} }
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`; const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
const scope = 'openid email profile'; const scope = 'openid email profile';
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}`; const oauthState = crypto.randomUUID();
return NextResponse.redirect(googleAuthUrl); const googleAuthUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
} googleAuthUrl.searchParams.set('client_id', googleClientId);
googleAuthUrl.searchParams.set('redirect_uri', redirectUri);
// Handle callback with code googleAuthUrl.searchParams.set('response_type', 'code');
try { googleAuthUrl.searchParams.set('scope', scope);
const googleClientId = process.env.GOOGLE_CLIENT_ID; googleAuthUrl.searchParams.set('state', oauthState);
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
const response = NextResponse.redirect(googleAuthUrl);
response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 10,
});
if (redirectTarget) {
response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 10,
});
} else {
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
}
return response;
}
// Handle callback with code
try {
if (!state || !savedOauthState || state !== savedOauthState) {
const invalidStateResponse = NextResponse.redirect(
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-state-invalid`
);
invalidStateResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
invalidStateResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
return invalidStateResponse;
}
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
if (!googleClientId || !googleClientSecret) { if (!googleClientId || !googleClientSecret) {
return NextResponse.json( return NextResponse.json(
@@ -50,9 +105,9 @@ export async function GET(request: NextRequest) {
code, code,
client_id: googleClientId, client_id: googleClientId,
client_secret: googleClientSecret, client_secret: googleClientSecret,
redirect_uri: redirectUri, redirect_uri: redirectUri,
grant_type: 'authorization_code', grant_type: 'authorization_code',
}), }),
}); });
if (!tokenResponse.ok) { if (!tokenResponse.ok) {
@@ -82,16 +137,27 @@ export async function GET(request: NextRequest) {
const isNewUser = !user; const isNewUser = !user;
// Create user if they don't exist // Create user if they don't exist
if (!user) { if (!user) {
user = await db.user.create({ const onboardingStartedAt = new Date();
data: { user = await db.user.create({
email: userInfo.email, data: {
name: userInfo.name || userInfo.email.split('@')[0], email: userInfo.email,
image: userInfo.picture, name: userInfo.name || userInfo.email.split('@')[0],
emailVerified: new Date(), // Google already verified the email image: userInfo.picture,
password: null, // OAuth users don't need a password emailVerified: new Date(), // Google already verified the email
}, password: null, // OAuth users don't need a password
}); onboardingStartedAt,
emailDomain: getEmailDomain(userInfo.email),
signupSource: firstTouch?.signupSource || null,
signupMedium: firstTouch?.signupMedium || null,
signupCampaign: firstTouch?.signupCampaign || null,
signupContent: firstTouch?.signupContent || null,
signupTerm: firstTouch?.signupTerm || null,
signupReferrer: firstTouch?.signupReferrer || null,
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
},
});
// Create Account entry for the OAuth provider // Create Account entry for the OAuth provider
await db.account.create({ await db.account.create({
@@ -144,22 +210,35 @@ export async function GET(request: NextRequest) {
id_token: tokens.id_token, id_token: tokens.id_token,
}, },
}); });
} }
} }
// Set authentication cookie triggerLifecycleScoring(user.id, isNewUser ? 'signup' : 'subscription_changed');
cookies().set('userId', user.id, getAuthCookieOptions());
const onboardingTarget = isNewUser || shouldResumeOnboarding(user)
// Redirect to dashboard with tracking params ? appendRedirectParam('/onboarding', savedRedirect, {
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`); authMethod: 'google',
redirectUrl.searchParams.set('authMethod', 'google'); isNewUser: isNewUser.toString(),
redirectUrl.searchParams.set('isNewUser', isNewUser.toString()); })
: (savedRedirect || appendRedirectParam('/dashboard', null, {
return NextResponse.redirect(redirectUrl.toString()); authMethod: 'google',
} catch (error) { isNewUser: isNewUser.toString(),
console.error('Google OAuth error:', error); }));
return NextResponse.redirect( const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`);
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
); const response = NextResponse.redirect(redirectUrl.toString());
} response.cookies.set('userId', user.id, getAuthCookieOptions());
} response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
return response;
} catch (error) {
console.error('Google OAuth error:', error);
const errorResponse = NextResponse.redirect(
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
);
errorResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
errorResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
return errorResponse;
}
}

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,14 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers'; import { db } from '@/lib/db';
import { db } from '@/lib/db';
import { z } from 'zod'; import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig'; import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signupSchema, validateRequest } from '@/lib/validationSchemas'; import { signupSchema, validateRequest } from '@/lib/validationSchemas';
import { sendWelcomeEmail } from '@/lib/email'; import { sendWelcomeEmail } from '@/lib/email';
import { sendConversionEvent } from '@/lib/meta'; import { sendConversionEvent } from '@/lib/meta';
import {
ATTRIBUTION_COOKIE_NAME,
getEmailDomain,
parseAttributionCookie,
} from '@/lib/revops';
import { triggerLifecycleScoring } from '@/lib/revops-server';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -67,14 +72,29 @@ export async function POST(request: NextRequest) {
// Hash password // Hash password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Create user const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
const user = await db.user.create({ const onboardingStartedAt = new Date();
data: {
name, // Create user
email, const user = await db.user.create({
password: hashedPassword, 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) // Send welcome email (fire-and-forget — never block signup)
try { try {
@@ -97,20 +117,22 @@ export async function POST(request: NextRequest) {
}).catch(console.error); }).catch(console.error);
// Create response // Create response
const response = NextResponse.json({ const response = NextResponse.json({
success: true, success: true,
user: { needsOnboarding: true,
id: user.id, user: {
name: user.name, id: user.id,
email: user.email, name: user.name,
plan: 'FREE', email: user.email,
plan: 'FREE',
}, },
}); });
// Set cookie for auto-login after signup // Set cookie for auto-login after signup
response.cookies.set('userId', user.id, getAuthCookieOptions()); response.cookies.set('userId', user.id, getAuthCookieOptions());
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
return response;
return response;
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return NextResponse.json( return NextResponse.json(
@@ -125,4 +147,4 @@ export async function POST(request: NextRequest) {
{ status: 500 } { status: 500 }
); );
} }
} }

View File

@@ -2,10 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig'; import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { loginSchema, validateRequest } from '@/lib/validationSchemas'; import { loginSchema, validateRequest } from '@/lib/validationSchemas';
import { shouldResumeOnboarding } from '@/lib/revops';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -50,9 +51,18 @@ export async function POST(request: NextRequest) {
const { email, password } = validation.data; const { email, password } = validation.data;
// Find user // Find user
const user = await db.user.findUnique({ const user = await db.user.findUnique({
where: { email }, where: { email },
}); select: {
id: true,
email: true,
name: true,
plan: true,
password: true,
onboardingStartedAt: true,
onboardingCompletedAt: true,
},
});
if (!user) { if (!user) {
return NextResponse.json( return NextResponse.json(
@@ -74,12 +84,13 @@ export async function POST(request: NextRequest) {
// Set cookie // Set cookie
cookies().set('userId', user.id, getAuthCookieOptions()); cookies().set('userId', user.id, getAuthCookieOptions());
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' } needsOnboarding: shouldResumeOnboarding(user),
}); user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
});
} catch (error) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
} }

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

@@ -1,10 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash'; import { generateSlug } from '@/lib/hash';
import { createQRSchema, validateRequest } from '@/lib/validationSchemas'; import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
import { csrfProtection } from '@/lib/csrf'; import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
import { triggerLifecycleScoring } from '@/lib/revops-server';
// GET /api/qrs - List user's QR codes // GET /api/qrs - List user's QR codes
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -47,12 +49,7 @@ export async function GET(request: NextRequest) {
} }
// Plan limits // Plan limits
const PLAN_LIMITS = { const PLAN_LIMITS = DYNAMIC_QR_LIMITS;
FREE: 3,
PRO: 50,
BUSINESS: 500,
ENTERPRISE: 99999,
};
// POST /api/qrs - Create a new QR code // POST /api/qrs - Create a new QR code
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -208,9 +205,9 @@ END:VCARD`;
const slug = generateSlug(body.title); const slug = generateSlug(body.title);
// Create QR code // Create QR code
const qrCode = await db.qRCode.create({ const qrCode = await db.qRCode.create({
data: { data: {
userId, userId,
title: body.title, title: body.title,
type: isStatic ? 'STATIC' : 'DYNAMIC', type: isStatic ? 'STATIC' : 'DYNAMIC',
contentType: body.contentType, contentType: body.contentType,
@@ -224,10 +221,12 @@ END:VCARD`;
}, },
slug, slug,
status: 'ACTIVE', status: 'ACTIVE',
}, },
}); });
return NextResponse.json(qrCode); triggerLifecycleScoring(userId, 'qr_created');
return NextResponse.json(qrCode);
} catch (error) { } catch (error) {
console.error('Error creating QR code:', error); console.error('Error creating QR code:', error);
return NextResponse.json( return NextResponse.json(
@@ -235,4 +234,4 @@ END:VCARD`;
{ status: 500 } { status: 500 }
); );
} }
} }

View File

@@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -53,32 +54,35 @@ export async function POST(request: NextRequest) {
// No active subscription // No active subscription
if (!user.stripeSubscriptionId) { if (!user.stripeSubscriptionId) {
// Just update plan to FREE if somehow plan is not FREE but no subscription // Just update plan to FREE if somehow plan is not FREE but no subscription
await db.user.update({ await db.user.update({
where: { id: userId }, where: { id: userId },
data: { data: {
plan: 'FREE', plan: 'FREE',
stripePriceId: null, stripePriceId: null,
stripeCurrentPeriodEnd: null, stripeCurrentPeriodEnd: null,
}, },
}); });
return NextResponse.json({ success: true }); await scoreUserLifecycle(userId, 'subscription_changed');
return NextResponse.json({ success: true });
} }
// Cancel the Stripe subscription // Cancel the Stripe subscription
await stripe.subscriptions.cancel(user.stripeSubscriptionId); await stripe.subscriptions.cancel(user.stripeSubscriptionId);
// Update user plan to FREE // Update user plan to FREE
await db.user.update({ await db.user.update({
where: { id: userId }, where: { id: userId },
data: { data: {
plan: 'FREE', plan: 'FREE',
stripeSubscriptionId: null, stripeSubscriptionId: null,
stripePriceId: null, stripePriceId: null,
stripeCurrentPeriodEnd: null, stripeCurrentPeriodEnd: null,
}, },
}); });
return NextResponse.json({ success: true }); await scoreUserLifecycle(userId, 'subscription_changed');
return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('Error canceling subscription:', error); console.error('Error canceling subscription:', error);
return NextResponse.json( return NextResponse.json(

View File

@@ -65,13 +65,29 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'User not found' }, { status: 404 }); return NextResponse.json({ error: 'User not found' }, { status: 404 });
} }
// Create or get Stripe customer // Create or get Stripe customer
let customerId = user.stripeCustomerId; let customerId = user.stripeCustomerId;
if (!customerId) { if (customerId) {
const customer = await stripe.customers.create({ try {
email: user.email, const existingCustomer = await stripe.customers.retrieve(customerId);
metadata: {
if ('deleted' in existingCustomer && existingCustomer.deleted) {
customerId = null;
}
} catch (error: any) {
if (error?.code === 'resource_missing' || error?.type === 'StripeInvalidRequestError') {
customerId = null;
} else {
throw error;
}
}
}
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: {
userId: user.id, userId: user.id,
}, },
}); });
@@ -79,30 +95,33 @@ export async function POST(request: NextRequest) {
customerId = customer.id; customerId = customer.id;
// Update user with Stripe customer ID // Update user with Stripe customer ID
await db.user.update({ await db.user.update({
where: { id: user.id }, where: { id: user.id },
data: { stripeCustomerId: customerId }, data: { stripeCustomerId: customerId },
}); });
} }
// Create Stripe Checkout Session const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin;
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId, // Create Stripe Checkout Session
mode: 'subscription', const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'], payment_method_types: ['card'],
line_items: [ line_items: [
{ {
price: priceId, price: priceId,
quantity: 1, quantity: 1,
}, },
], ],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`, success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`, cancel_url: `${appUrl}/pricing?canceled=true`,
metadata: { metadata: {
userId: user.id, userId: user.id,
plan, plan,
}, billingInterval,
}); },
});
return NextResponse.json({ url: checkoutSession.url }); return NextResponse.json({ url: checkoutSession.url });
} catch (error) { } catch (error) {

View File

@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { scoreUserLifecycle } from '@/lib/revops-server';
/** /**
* Manual sync endpoint to update user subscription from Stripe * Manual sync endpoint to update user subscription from Stripe
@@ -37,17 +38,19 @@ export async function POST(request: NextRequest) {
if (subscriptions.data.length === 0) { if (subscriptions.data.length === 0) {
// No active subscription - set to FREE // No active subscription - set to FREE
await db.user.update({ await db.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
stripeSubscriptionId: null, stripeSubscriptionId: null,
stripePriceId: null, stripePriceId: null,
stripeCurrentPeriodEnd: null, stripeCurrentPeriodEnd: null,
plan: 'FREE', plan: 'FREE',
}, },
}); });
return NextResponse.json({ await scoreUserLifecycle(user.id, 'subscription_changed');
return NextResponse.json({
success: true, success: true,
plan: 'FREE', plan: 'FREE',
message: 'No active subscription found, set to FREE plan', message: 'No active subscription found, set to FREE plan',
@@ -87,18 +90,20 @@ export async function POST(request: NextRequest) {
}); });
// Update user in database // Update user in database
await db.user.update({ await db.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
stripeSubscriptionId: subscription.id, stripeSubscriptionId: subscription.id,
stripePriceId: priceId, stripePriceId: priceId,
stripeCurrentPeriodEnd: currentPeriodEnd, stripeCurrentPeriodEnd: currentPeriodEnd,
plan: plan as any, plan: plan as any,
}, },
}); });
return NextResponse.json({ await scoreUserLifecycle(user.id, 'subscription_changed');
success: true,
return NextResponse.json({
success: true,
plan, plan,
subscriptionId: subscription.id, subscriptionId: subscription.id,
currentPeriodEnd, currentPeriodEnd,

View File

@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -20,26 +21,30 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'User not found' }, { status: 404 }); return NextResponse.json({ error: 'User not found' }, { status: 404 });
} }
if (!user.stripeCustomerId) { if (!user.stripeCustomerId) {
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 }); return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
} }
// Get the most recent checkout session for this customer const { sessionId } = await request.json().catch(() => ({ sessionId: null }));
const checkoutSessions = await stripe.checkout.sessions.list({
customer: user.stripeCustomerId, if (!sessionId || typeof sessionId !== 'string') {
limit: 1, return NextResponse.json({ error: 'Missing checkout session ID' }, { status: 400 });
}); }
if (checkoutSessions.data.length === 0) { const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
return NextResponse.json({ error: 'No checkout session found' }, { status: 404 });
} const sessionBelongsToUser =
checkoutSession.metadata?.userId === user.id ||
const checkoutSession = checkoutSessions.data[0]; checkoutSession.customer === user.stripeCustomerId;
// Only process if payment was successful if (!sessionBelongsToUser) {
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) { return NextResponse.json({ error: 'Checkout session does not belong to user' }, { status: 403 });
const subscriptionId = typeof checkoutSession.subscription === 'string' }
? checkoutSession.subscription
// Only process if payment was successful
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
const subscriptionId = typeof checkoutSession.subscription === 'string'
? checkoutSession.subscription
: checkoutSession.subscription.id; : checkoutSession.subscription.id;
// Retrieve the full subscription object // Retrieve the full subscription object
@@ -48,41 +53,32 @@ export async function POST(request: NextRequest) {
// Determine plan from metadata or price ID // Determine plan from metadata or price ID
const plan = checkoutSession.metadata?.plan || 'PRO'; const plan = checkoutSession.metadata?.plan || 'PRO';
// Debug log to see the subscription structure // Get current_period_end - Stripe returns it as a Unix timestamp
console.log('Full subscription object:', JSON.stringify(subscription, null, 2)); const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
// Get current_period_end - Stripe returns it as a Unix timestamp || subscription.billing_cycle_anchor;
// Try different possible field names
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000) ? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
console.log('Subscription data:', { // Update user in database
id: subscription.id, await db.user.update({
periodEndTimestamp, where: { id: user.id },
currentPeriodEnd, data: {
priceId: subscription.items?.data?.[0]?.price?.id, stripeSubscriptionId: subscription.id,
}); stripePriceId: subscription.items.data[0].price.id,
// Update user in database
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd, stripeCurrentPeriodEnd: currentPeriodEnd,
plan: plan as any, plan: plan as any,
}, },
}); });
return NextResponse.json({ await scoreUserLifecycle(user.id, 'subscription_changed');
success: true,
plan, return NextResponse.json({
subscriptionId: subscription.id, success: true,
plan,
subscriptionId: subscription.id,
}); });
} }

View File

@@ -1,9 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { sendConversionEvent } from '@/lib/meta'; import { sendConversionEvent } from '@/lib/meta';
import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const body = await request.text(); const body = await request.text();
@@ -50,17 +51,19 @@ export async function POST(request: NextRequest) {
? new Date(periodEndTimestamp * 1000) ? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
const updatedUser = await db.user.update({ const updatedUser = await db.user.update({
where: { where: {
stripeCustomerId: session.customer as string, stripeCustomerId: session.customer as string,
}, },
data: { data: {
stripeSubscriptionId: subscription.id, stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id, stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd, stripeCurrentPeriodEnd: currentPeriodEnd,
plan: (session.metadata?.plan || 'FREE') as any, plan: (session.metadata?.plan || 'FREE') as any,
}, },
}); });
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
// Meta CAPI — Purchase event // Meta CAPI — Purchase event
const amountCents = session.amount_total ?? 0; const amountCents = session.amount_total ?? 0;
@@ -92,34 +95,43 @@ export async function POST(request: NextRequest) {
? new Date(periodEndTimestamp * 1000) ? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.user.update({ await db.user.update({
where: { where: {
stripeSubscriptionId: subscription.id, stripeSubscriptionId: subscription.id,
}, },
data: { data: {
stripePriceId: subscription.items.data[0].price.id, stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd, stripeCurrentPeriodEnd: currentPeriodEnd,
}, },
}); });
break; const updated = await db.user.findUnique({
} where: { stripeSubscriptionId: subscription.id },
select: { id: true },
});
if (updated?.id) {
await scoreUserLifecycle(updated.id, 'subscription_changed');
}
break;
}
case 'customer.subscription.deleted': { case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription; const subscription = event.data.object as Stripe.Subscription;
await db.user.update({ const updatedUser = await db.user.update({
where: { where: {
stripeSubscriptionId: subscription.id, stripeSubscriptionId: subscription.id,
}, },
data: { data: {
stripeSubscriptionId: null, stripeSubscriptionId: null,
stripePriceId: null, stripePriceId: null,
stripeCurrentPeriodEnd: null, stripeCurrentPeriodEnd: null,
plan: 'FREE', plan: 'FREE',
}, },
}); });
break;
} await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
break;
}
} }
return NextResponse.json({ received: true }); return NextResponse.json({ received: true });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next';
import OnboardingClient from './OnboardingClient';
export const metadata: Metadata = {
title: 'Onboarding | QR Master',
description: 'Set up your QR Master workspace and create your first QR code.',
robots: {
index: false,
follow: false,
},
};
export default function OnboardingPage() {
return <OnboardingClient />;
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { hashIP } from '@/lib/hash'; import { hashIP } from '@/lib/hash';
import { triggerLifecycleScoring } from '@/lib/revops-server';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@@ -10,13 +11,14 @@ export async function GET(
const { slug } = await params; const { slug } = await params;
// Fetch QR code by slug // Fetch QR code by slug
const qrCode = await db.qRCode.findUnique({ const qrCode = await db.qRCode.findUnique({
where: { slug }, where: { slug },
select: { select: {
id: true, id: true,
content: true, userId: true,
contentType: true, content: true,
}, contentType: true,
},
}); });
if (!qrCode) { if (!qrCode) {
@@ -24,7 +26,7 @@ export async function GET(
} }
// Track scan (fire and forget) // Track scan (fire and forget)
trackScan(qrCode.id, request).catch(console.error); trackScan(qrCode.id, qrCode.userId, request).catch(console.error);
// Determine destination URL // Determine destination URL
let destination = ''; let destination = '';
@@ -121,7 +123,7 @@ export async function GET(
} }
} }
async function trackScan(qrId: string, request: NextRequest) { async function trackScan(qrId: string, userId: string, request: NextRequest) {
try { try {
const userAgent = request.headers.get('user-agent') || ''; const userAgent = request.headers.get('user-agent') || '';
const referer = request.headers.get('referer') || ''; const referer = request.headers.get('referer') || '';
@@ -133,15 +135,30 @@ async function trackScan(qrId: string, request: NextRequest) {
const dnt = request.headers.get('dnt'); const dnt = request.headers.get('dnt');
if (dnt === '1') { if (dnt === '1') {
// Respect Do Not Track - only increment counter // Respect Do Not Track - only increment counter
await db.qRScan.create({ const scanTimestamp = new Date();
data: { await db.qRScan.create({
qrId, data: {
ipHash: 'dnt', qrId,
isUnique: false, ipHash: 'dnt',
}, isUnique: false,
}); ts: scanTimestamp,
return; },
} });
const activatedUsers = await db.user.updateMany({
where: {
id: userId,
firstScanAt: null,
},
data: {
firstScanAt: scanTimestamp,
activationAt: scanTimestamp,
},
});
if (activatedUsers.count > 0) {
triggerLifecycleScoring(userId, 'scan_recorded');
}
return;
}
// Hash IP for privacy // Hash IP for privacy
const ipHash = hashIP(ip); const ipHash = hashIP(ip);
@@ -222,22 +239,39 @@ async function trackScan(qrId: string, request: NextRequest) {
const isUnique = !existingScan; const isUnique = !existingScan;
// Create scan record // Create scan record
await db.qRScan.create({ const scanTimestamp = new Date();
data: { await db.qRScan.create({
qrId, data: {
ipHash, qrId,
userAgent: userAgent.substring(0, 255), ts: scanTimestamp,
device, ipHash,
userAgent: userAgent.substring(0, 255),
device,
os, os,
country, country,
referrer: referer.substring(0, 255), referrer: referer.substring(0, 255),
utmSource, utmSource,
utmMedium, utmMedium,
utmCampaign, utmCampaign,
isUnique, isUnique,
}, },
}); });
} catch (error) {
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 // Don't throw - this is fire and forget
} }
} }
@@ -250,4 +284,4 @@ function ensureAbsoluteUrl(url: string): string {
} }
// Default to https for web URLs // Default to https for web URLs
return `https://${url}`; return `https://${url}`;
} }

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

@@ -1,9 +1,10 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react'; import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { trackEvent } from '@/components/PostHogProvider';
const AIComingSoonBanner = () => { const AIComingSoonBanner = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -25,14 +26,21 @@ const AIComingSoonBanner = () => {
body: JSON.stringify({ email }), body: JSON.stringify({ email }),
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || 'Failed to subscribe'); const errorMessage = typeof data?.error === 'string'
} ? data.error
: data?.details?.[0]?.message || 'Failed to subscribe';
setSubmitted(true); throw new Error(errorMessage);
setEmail(''); }
trackEvent('newsletter_subscribed', {
source: 'ai_coming_soon_banner',
already_subscribed: Boolean(data.alreadySubscribed),
});
setSubmitted(true);
setEmail('');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.'); setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
} finally { } finally {

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

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

View File

@@ -905,7 +905,7 @@ export async function sendActivationNudgeEmail(email: string, name: string) {
<td style="padding:40px 48px 0;"> <td style="padding:40px 48px 0;">
<p style="margin:0 0 8px;font-family:'DM Sans',-apple-system,sans-serif; <p style="margin:0 0 8px;font-family:'DM Sans',-apple-system,sans-serif;
font-size:16px;line-height:1.75;color:${clr.text};"> font-size:16px;line-height:1.75;color:${clr.text};">
Your 3 free dynamic QR codes are still there. Unused.<br> Your 3 free dynamic QR codes are still there. Unused.<br>
Dynamic means: one code, update the link anytime, every scan tracked. Dynamic means: one code, update the link anytime, every scan tracked.
</p> </p>
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif; <p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
@@ -1039,7 +1039,7 @@ export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount
<h1 style="margin:28px 0 0;font-family:'DM Serif Display',Georgia,serif; <h1 style="margin:28px 0 0;font-family:'DM Serif Display',Georgia,serif;
font-size:34px;font-weight:400;line-height:1.2;color:#FFFFFF;"> font-size:34px;font-weight:400;line-height:1.2;color:#FFFFFF;">
${qrCount} of 3 free codes used,<br> ${qrCount} of 3 free codes used,<br>
<em style="color:${clr.gold};">${firstName}.</em> <em style="color:${clr.gold};">${firstName}.</em>
</h1> </h1>

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

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

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

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

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

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

View File

@@ -1,7 +1,12 @@
import Stripe from 'stripe'; import Stripe from 'stripe';
import {
// Use a placeholder during build time, real key at runtime FREE_DYNAMIC_QR_LIMIT,
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build'; PRO_DYNAMIC_QR_LIMIT,
BUSINESS_DYNAMIC_QR_LIMIT,
} from '@/lib/plans';
// Use a placeholder during build time, real key at runtime
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
export const stripe = new Stripe(stripeKey, { export const stripe = new Stripe(stripeKey, {
apiVersion: '2025-10-29.clover', apiVersion: '2025-10-29.clover',
@@ -20,18 +25,18 @@ export const STRIPE_PLANS = {
name: 'Free / Starter', name: 'Free / Starter',
price: 0, price: 0,
currency: 'EUR', currency: 'EUR',
interval: 'month', interval: 'month',
features: [ features: [
'3 dynamische QR-Codes', `${FREE_DYNAMIC_QR_LIMIT} dynamische QR-Codes`,
'Basis-Tracking (Scans + Standort)', 'Basis-Tracking (Scans + Standort)',
'Einfache Designs', 'Einfache Designs',
'Unbegrenzte statische QR-Codes', 'Unbegrenzte statische QR-Codes',
], ],
limits: { limits: {
dynamicQRCodes: 3, dynamicQRCodes: FREE_DYNAMIC_QR_LIMIT,
staticQRCodes: -1, // unlimited staticQRCodes: -1, // unlimited
teamMembers: 1, teamMembers: 1,
}, },
priceId: null, // No Stripe price for free plan priceId: null, // No Stripe price for free plan
}, },
PRO: { PRO: {
@@ -46,12 +51,12 @@ export const STRIPE_PLANS = {
'Detailed Analytics (Date, Device, City)', 'Detailed Analytics (Date, Device, City)',
'CSV Export', 'CSV Export',
'SVG/PNG Download', 'SVG/PNG Download',
], ],
limits: { limits: {
dynamicQRCodes: 50, dynamicQRCodes: PRO_DYNAMIC_QR_LIMIT,
staticQRCodes: -1, staticQRCodes: -1,
teamMembers: 1, teamMembers: 1,
}, },
priceId: process.env.STRIPE_PRICE_ID_PRO_MONTHLY, priceId: process.env.STRIPE_PRICE_ID_PRO_MONTHLY,
priceIdYearly: process.env.STRIPE_PRICE_ID_PRO_YEARLY, priceIdYearly: process.env.STRIPE_PRICE_ID_PRO_YEARLY,
}, },
@@ -66,12 +71,12 @@ export const STRIPE_PLANS = {
'Everything from Pro', 'Everything from Pro',
'Bulk QR Generation (up to 1,000)', 'Bulk QR Generation (up to 1,000)',
'Priority Support', 'Priority Support',
], ],
limits: { limits: {
dynamicQRCodes: 500, dynamicQRCodes: BUSINESS_DYNAMIC_QR_LIMIT,
staticQRCodes: -1, staticQRCodes: -1,
teamMembers: 1, teamMembers: 1,
}, },
priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY, priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY,
priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY, priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY,
}, },

View File

@@ -115,12 +115,23 @@ export const resetPasswordSchema = z.object({
// Settings Schemas // Settings Schemas
// ========================================== // ==========================================
export const updateProfileSchema = z.object({ export const updateProfileSchema = z.object({
name: z.string() name: z.string()
.min(2, 'Name must be at least 2 characters') .min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters') .max(100, 'Name must be less than 100 characters')
.trim(), .trim(),
}); });
export const onboardingUpdateSchema = z.object({
signupSourceSelfReported: z.string().max(100).optional(),
primaryUseCase: z.string().max(100).optional(),
primaryGoal: z.string().max(100).optional(),
jobRole: z.string().max(100).optional(),
companyName: z.string().max(200).optional(),
companyWebsite: z.string().max(200).optional(),
teamSizeBucket: z.string().max(100).optional(),
markProfileComplete: z.boolean().optional(),
});
export const changePasswordSchema = z.object({ export const changePasswordSchema = z.object({
currentPassword: z.string() currentPassword: z.string()

View File

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