revops + onboarding
This commit is contained in:
BIN
output/imagegen/onboarding-mockup-v1.png
Normal file
BIN
output/imagegen/onboarding-mockup-v1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1014 KiB |
776
output/paused-qr-preview/index.html
Normal file
776
output/paused-qr-preview/index.html
Normal 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>
|
||||
BIN
output/paused-qr-preview/paused-qr-hero-cinematic.png
Normal file
BIN
output/paused-qr-preview/paused-qr-hero-cinematic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -37,10 +37,50 @@ model User {
|
||||
upgradeNudgeSentAt DateTime?
|
||||
thirtyDayNudgeSentAt DateTime?
|
||||
|
||||
qrCodes QRCode[]
|
||||
integrations Integration[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
// RevOps attribution
|
||||
signupSource String?
|
||||
signupSourceSelfReported String?
|
||||
signupMedium String?
|
||||
signupCampaign String?
|
||||
signupContent String?
|
||||
signupTerm String?
|
||||
signupReferrer String?
|
||||
signupLandingPath String?
|
||||
signupFirstSeenAt DateTime?
|
||||
emailDomain String?
|
||||
|
||||
// Onboarding and qualification
|
||||
primaryUseCase String?
|
||||
primaryGoal String?
|
||||
jobRole String?
|
||||
companyName String?
|
||||
companyWebsite String?
|
||||
teamSizeBucket String?
|
||||
onboardingStartedAt DateTime?
|
||||
sourceConfirmedAt DateTime?
|
||||
useCaseSelectedAt DateTime?
|
||||
goalSelectedAt DateTime?
|
||||
profileCompletedAt DateTime?
|
||||
firstQrCreatedAt DateTime?
|
||||
firstDynamicQrAt DateTime?
|
||||
firstStaticQrAt DateTime?
|
||||
firstScanAt DateTime?
|
||||
activationAt DateTime?
|
||||
onboardingCompletedAt DateTime?
|
||||
|
||||
// RevOps scoring
|
||||
fitScore Int @default(0)
|
||||
intentScore Int @default(0)
|
||||
leadScore Int @default(0)
|
||||
lifecycleStage String @default("cold")
|
||||
lastQualifiedAt DateTime?
|
||||
lastScoredAt DateTime?
|
||||
|
||||
qrCodes QRCode[]
|
||||
integrations Integration[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
lifecycleLogs UserLifecycleLog[]
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
@@ -161,6 +201,20 @@ model Integration {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model UserLifecycleLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
fromStage String?
|
||||
toStage String
|
||||
fitScore Int @default(0)
|
||||
intentScore Int @default(0)
|
||||
leadScore Int @default(0)
|
||||
reason String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model NewsletterSubscription {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
|
||||
BIN
public/Screenshot 2026-04-22 123347.png
Normal file
BIN
public/Screenshot 2026-04-22 123347.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
public/marketing/qrmaster-hero-generated-v1.png
Normal file
BIN
public/marketing/qrmaster-hero-generated-v1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
@@ -14,6 +14,8 @@ import { calculateContrast, cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { trackEvent } from '@/components/PostHogProvider';
|
||||
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
|
||||
import {
|
||||
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon
|
||||
} from 'lucide-react';
|
||||
@@ -101,6 +103,7 @@ function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string
|
||||
|
||||
export default function CreatePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -166,6 +169,42 @@ export default function CreatePage() {
|
||||
fetchUserPlan();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const queryContentType = searchParams.get('contentType');
|
||||
const useCase = searchParams.get('useCase');
|
||||
const titleParam = searchParams.get('title');
|
||||
const isDynamicParam = searchParams.get('dynamic');
|
||||
|
||||
if (queryContentType) {
|
||||
setContentType(queryContentType);
|
||||
}
|
||||
|
||||
if (titleParam) {
|
||||
setTitle(titleParam);
|
||||
}
|
||||
|
||||
if (isDynamicParam) {
|
||||
setIsDynamic(isDynamicParam === '1');
|
||||
}
|
||||
|
||||
if (useCase === 'menu_pdf') {
|
||||
setContent((prev: any) => ({ ...prev, fileUrl: prev.fileUrl || '' }));
|
||||
} else if (useCase === 'contact_card') {
|
||||
setContent((prev: any) => ({
|
||||
...prev,
|
||||
firstName: prev.firstName || '',
|
||||
lastName: prev.lastName || '',
|
||||
}));
|
||||
} else if (useCase === 'barcode') {
|
||||
setContent((prev: any) => ({
|
||||
...prev,
|
||||
format: prev.format || 'CODE128',
|
||||
}));
|
||||
} else if (queryContentType === 'URL') {
|
||||
setContent((prev: any) => ({ ...prev, url: prev.url || '' }));
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const contrast = calculateContrast(foregroundColor, backgroundColor);
|
||||
const hasGoodContrast = contrast >= 4.5;
|
||||
|
||||
@@ -232,6 +271,12 @@ export default function CreatePage() {
|
||||
link.download = `qrcode-${title || 'download'}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
trackEvent('qr_code_downloaded', {
|
||||
format: 'png',
|
||||
content_type: contentType,
|
||||
qr_type: isDynamic ? 'dynamic' : 'static',
|
||||
plan: userPlan,
|
||||
});
|
||||
} else {
|
||||
// For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed
|
||||
// Simplest is to check if we can export the SVG element directly but that misses the frame HTML.
|
||||
@@ -259,6 +304,12 @@ export default function CreatePage() {
|
||||
a.download = `qrcode-${title || 'download'}.svg`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
trackEvent('qr_code_downloaded', {
|
||||
format: 'svg',
|
||||
content_type: contentType,
|
||||
qr_type: isDynamic ? 'dynamic' : 'static',
|
||||
plan: userPlan,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info');
|
||||
@@ -267,6 +318,13 @@ export default function CreatePage() {
|
||||
link.download = `qrcode-${title || 'download'}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
trackEvent('qr_code_downloaded', {
|
||||
format: 'png',
|
||||
content_type: contentType,
|
||||
qr_type: isDynamic ? 'dynamic' : 'static',
|
||||
plan: userPlan,
|
||||
fallback_from: 'svg_with_frame',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -355,15 +413,35 @@ export default function CreatePage() {
|
||||
console.log('RESPONSE DATA:', responseData);
|
||||
|
||||
if (response.ok) {
|
||||
trackEvent('qr_code_created', {
|
||||
content_type: contentType,
|
||||
qr_type: isDynamic ? 'dynamic' : 'static',
|
||||
plan: userPlan,
|
||||
has_logo: Boolean(logoUrl),
|
||||
frame_type: frameType,
|
||||
});
|
||||
|
||||
showToast(`QR Code "${title}" created successfully!`, 'success');
|
||||
|
||||
// Wait a moment so user sees the toast, then redirect
|
||||
setTimeout(() => {
|
||||
router.push('/dashboard');
|
||||
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
|
||||
if (searchParams.get('onboarding') === '1') {
|
||||
router.push(appendRedirectParam('/onboarding', redirectTarget, { step: '8' }));
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
router.refresh();
|
||||
}, 1000);
|
||||
} else {
|
||||
console.error('Error creating QR code:', responseData);
|
||||
|
||||
if (response.status === 403 && responseData.error === 'Limit reached') {
|
||||
showToast(responseData.message || 'You have reached your plan limit.', 'error');
|
||||
router.push('/pricing?reason=limit_reached');
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(responseData.error || 'Error creating QR code', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -13,6 +13,9 @@ import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
||||
import { QrCode } from 'lucide-react';
|
||||
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
|
||||
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
|
||||
|
||||
interface QRCodeData {
|
||||
id: string;
|
||||
@@ -45,6 +48,7 @@ export default function DashboardPage() {
|
||||
uniqueScans: 0,
|
||||
});
|
||||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||||
const [onboardingState, setOnboardingState] = useState<any>(null);
|
||||
|
||||
|
||||
const blogPosts = [
|
||||
@@ -117,7 +121,6 @@ export default function DashboardPage() {
|
||||
// Store in localStorage for consistency
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
|
||||
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
|
||||
identifyUser(user.id, {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
@@ -145,11 +148,17 @@ export default function DashboardPage() {
|
||||
// Check for successful payment and verify session
|
||||
useEffect(() => {
|
||||
const success = searchParams.get('success');
|
||||
if (success === 'true') {
|
||||
const sessionId = searchParams.get('session_id');
|
||||
|
||||
if (success === 'true' && sessionId) {
|
||||
const verifySession = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/stripe/verify-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -157,6 +166,10 @@ export default function DashboardPage() {
|
||||
setUserPlan(data.plan);
|
||||
setUpgradedPlan(data.plan);
|
||||
setShowUpgradeDialog(true);
|
||||
trackEvent('upgrade_completed', {
|
||||
plan: data.plan,
|
||||
source: 'stripe_checkout',
|
||||
});
|
||||
// Remove success parameter from URL
|
||||
router.replace('/dashboard');
|
||||
} else {
|
||||
@@ -218,6 +231,12 @@ export default function DashboardPage() {
|
||||
const analytics = await analyticsResponse.json();
|
||||
setAnalyticsData(analytics);
|
||||
}
|
||||
|
||||
const onboardingResponse = await fetch('/api/onboarding');
|
||||
if (onboardingResponse.ok) {
|
||||
const onboardingData = await onboardingResponse.json();
|
||||
setOnboardingState(onboardingData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
setQrCodes([]);
|
||||
@@ -342,6 +361,8 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<OnboardingChecklist state={onboardingState} />
|
||||
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
trends={{
|
||||
@@ -393,7 +414,7 @@ export default function DashboardPage() {
|
||||
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">Create your first QR code</h3>
|
||||
<p className="text-gray-500 mb-6 max-w-sm mx-auto">
|
||||
You have 3 free dynamic QR codes. They redirect wherever you want and track every scan.
|
||||
You have {FREE_DYNAMIC_QR_LIMIT} free dynamic QR codes. They redirect wherever you want and track every scan.
|
||||
</p>
|
||||
<Link href="/create">
|
||||
<Button>Create QR Code — it takes 90 seconds</Button>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
|
||||
|
||||
type LoginClientProps = {
|
||||
showPageHeading?: boolean;
|
||||
@@ -23,6 +24,7 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -58,7 +60,9 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
|
||||
}
|
||||
|
||||
// Check for redirect parameter
|
||||
const redirectUrl = searchParams.get('redirect') || '/dashboard';
|
||||
const redirectUrl = data.needsOnboarding
|
||||
? appendRedirectParam('/onboarding', redirectTarget)
|
||||
: (redirectTarget || '/dashboard');
|
||||
router.push(redirectUrl);
|
||||
router.refresh();
|
||||
} else {
|
||||
@@ -73,7 +77,7 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
// Redirect to Google OAuth API route
|
||||
window.location.href = '/api/auth/google';
|
||||
window.location.href = appendRedirectParam('/api/auth/google', redirectTarget);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -199,7 +203,7 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
<Link href={appendRedirectParam('/signup', redirectTarget)} className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
|
||||
|
||||
export default function SignupClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [name, setName] = useState('');
|
||||
@@ -21,6 +23,7 @@ export default function SignupClient() {
|
||||
const [error, setError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -68,8 +71,8 @@ export default function SignupClient() {
|
||||
console.error('PostHog tracking error:', error);
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
router.push('/dashboard');
|
||||
// Redirect to onboarding
|
||||
router.push(appendRedirectParam('/onboarding', redirectTarget));
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(data.error || 'Failed to create account');
|
||||
@@ -83,7 +86,7 @@ export default function SignupClient() {
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
// Redirect to Google OAuth API route
|
||||
window.location.href = '/api/auth/google';
|
||||
window.location.href = appendRedirectParam('/api/auth/google', redirectTarget);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -236,7 +239,7 @@ export default function SignupClient() {
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
<Link href={appendRedirectParam('/login', redirectTarget)} className="text-primary-600 hover:text-primary-700 font-medium">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ import { showToast } from '@/components/ui/Toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { BillingToggle } from '@/components/ui/BillingToggle';
|
||||
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
|
||||
import { trackEvent } from '@/components/PostHogProvider';
|
||||
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||
|
||||
export default function PricingPage() {
|
||||
const router = useRouter();
|
||||
@@ -40,6 +42,13 @@ export default function PricingPage() {
|
||||
setLoading(plan);
|
||||
|
||||
try {
|
||||
trackEvent('upgrade_clicked', {
|
||||
plan,
|
||||
billing_interval: billingPeriod,
|
||||
source: 'pricing_page',
|
||||
current_plan: currentPlan,
|
||||
});
|
||||
|
||||
const response = await fetch('/api/stripe/create-checkout-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -52,14 +61,15 @@ export default function PricingPage() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create checkout session');
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.error || 'Failed to create checkout session');
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Error creating checkout session:', error);
|
||||
showToast('Failed to start checkout. Please try again.', 'error');
|
||||
showToast(error?.message || 'Failed to start checkout. Please try again.', 'error');
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
@@ -132,7 +142,7 @@ export default function PricingPage() {
|
||||
period: 'forever',
|
||||
showDiscount: false,
|
||||
features: [
|
||||
'3 active dynamic QR codes (8 types available)',
|
||||
`${FREE_DYNAMIC_QR_LIMIT} active dynamic QR codes (8 types available)`,
|
||||
'Unlimited static QR codes',
|
||||
'Basic scan tracking',
|
||||
'Standard QR design templates',
|
||||
|
||||
430
src/app/(main)/api/admin/revops/route.ts
Normal file
430
src/app/(main)/api/admin/revops/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
import {
|
||||
appendRedirectParam,
|
||||
GOOGLE_OAUTH_STATE_COOKIE_NAME,
|
||||
POST_AUTH_REDIRECT_COOKIE_NAME,
|
||||
sanitizeRedirectPath,
|
||||
} from '@/lib/auth-flow';
|
||||
import {
|
||||
ATTRIBUTION_COOKIE_NAME,
|
||||
getEmailDomain,
|
||||
parseAttributionCookie,
|
||||
shouldResumeOnboarding,
|
||||
} from '@/lib/revops';
|
||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get('code');
|
||||
const state = searchParams.get('state');
|
||||
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
|
||||
const savedOauthState = request.cookies.get(GOOGLE_OAUTH_STATE_COOKIE_NAME)?.value;
|
||||
const savedRedirect = sanitizeRedirectPath(request.cookies.get(POST_AUTH_REDIRECT_COOKIE_NAME)?.value);
|
||||
|
||||
// If no code, redirect to Google OAuth
|
||||
if (!code) {
|
||||
@@ -20,14 +38,51 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
||||
const scope = 'openid email profile';
|
||||
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
|
||||
const oauthState = crypto.randomUUID();
|
||||
|
||||
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}`;
|
||||
const googleAuthUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
googleAuthUrl.searchParams.set('client_id', googleClientId);
|
||||
googleAuthUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
googleAuthUrl.searchParams.set('response_type', 'code');
|
||||
googleAuthUrl.searchParams.set('scope', scope);
|
||||
googleAuthUrl.searchParams.set('state', oauthState);
|
||||
|
||||
return NextResponse.redirect(googleAuthUrl);
|
||||
const response = NextResponse.redirect(googleAuthUrl);
|
||||
response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
|
||||
if (redirectTarget) {
|
||||
response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
} else {
|
||||
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Handle callback with code
|
||||
try {
|
||||
if (!state || !savedOauthState || state !== savedOauthState) {
|
||||
const invalidStateResponse = NextResponse.redirect(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-state-invalid`
|
||||
);
|
||||
invalidStateResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
||||
invalidStateResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||
return invalidStateResponse;
|
||||
}
|
||||
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
@@ -83,6 +138,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Create user if they don't exist
|
||||
if (!user) {
|
||||
const onboardingStartedAt = new Date();
|
||||
user = await db.user.create({
|
||||
data: {
|
||||
email: userInfo.email,
|
||||
@@ -90,6 +146,16 @@ export async function GET(request: NextRequest) {
|
||||
image: userInfo.picture,
|
||||
emailVerified: new Date(), // Google already verified the email
|
||||
password: null, // OAuth users don't need a password
|
||||
onboardingStartedAt,
|
||||
emailDomain: getEmailDomain(userInfo.email),
|
||||
signupSource: firstTouch?.signupSource || null,
|
||||
signupMedium: firstTouch?.signupMedium || null,
|
||||
signupCampaign: firstTouch?.signupCampaign || null,
|
||||
signupContent: firstTouch?.signupContent || null,
|
||||
signupTerm: firstTouch?.signupTerm || null,
|
||||
signupReferrer: firstTouch?.signupReferrer || null,
|
||||
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
|
||||
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -147,19 +213,32 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Set authentication cookie
|
||||
cookies().set('userId', user.id, getAuthCookieOptions());
|
||||
triggerLifecycleScoring(user.id, isNewUser ? 'signup' : 'subscription_changed');
|
||||
|
||||
// Redirect to dashboard with tracking params
|
||||
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`);
|
||||
redirectUrl.searchParams.set('authMethod', 'google');
|
||||
redirectUrl.searchParams.set('isNewUser', isNewUser.toString());
|
||||
const onboardingTarget = isNewUser || shouldResumeOnboarding(user)
|
||||
? appendRedirectParam('/onboarding', savedRedirect, {
|
||||
authMethod: 'google',
|
||||
isNewUser: isNewUser.toString(),
|
||||
})
|
||||
: (savedRedirect || appendRedirectParam('/dashboard', null, {
|
||||
authMethod: 'google',
|
||||
isNewUser: isNewUser.toString(),
|
||||
}));
|
||||
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`);
|
||||
|
||||
return NextResponse.redirect(redirectUrl.toString());
|
||||
const response = NextResponse.redirect(redirectUrl.toString());
|
||||
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
||||
response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
||||
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Google OAuth error:', error);
|
||||
return NextResponse.redirect(
|
||||
const errorResponse = NextResponse.redirect(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
|
||||
);
|
||||
errorResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
||||
errorResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
30
src/app/(main)/api/auth/logout/route.ts
Normal file
30
src/app/(main)/api/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { z } from 'zod';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
@@ -9,6 +8,12 @@ import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { sendWelcomeEmail } from '@/lib/email';
|
||||
import { sendConversionEvent } from '@/lib/meta';
|
||||
import {
|
||||
ATTRIBUTION_COOKIE_NAME,
|
||||
getEmailDomain,
|
||||
parseAttributionCookie,
|
||||
} from '@/lib/revops';
|
||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -67,15 +72,30 @@ export async function POST(request: NextRequest) {
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
|
||||
const onboardingStartedAt = new Date();
|
||||
|
||||
// Create user
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
onboardingStartedAt,
|
||||
emailDomain: getEmailDomain(email),
|
||||
signupSource: firstTouch?.signupSource || null,
|
||||
signupMedium: firstTouch?.signupMedium || null,
|
||||
signupCampaign: firstTouch?.signupCampaign || null,
|
||||
signupContent: firstTouch?.signupContent || null,
|
||||
signupTerm: firstTouch?.signupTerm || null,
|
||||
signupReferrer: firstTouch?.signupReferrer || null,
|
||||
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
|
||||
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
|
||||
},
|
||||
});
|
||||
|
||||
triggerLifecycleScoring(user.id, 'signup');
|
||||
|
||||
// Send welcome email (fire-and-forget — never block signup)
|
||||
try {
|
||||
await sendWelcomeEmail(user.email, user.name ?? 'there');
|
||||
@@ -99,6 +119,7 @@ export async function POST(request: NextRequest) {
|
||||
// Create response
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
needsOnboarding: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -109,6 +130,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Set cookie for auto-login after signup
|
||||
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
||||
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { shouldResumeOnboarding } from '@/lib/revops';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -52,6 +53,15 @@ export async function POST(request: NextRequest) {
|
||||
// Find user
|
||||
const user = await db.user.findUnique({
|
||||
where: { email },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
password: true,
|
||||
onboardingStartedAt: true,
|
||||
onboardingCompletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -76,6 +86,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
needsOnboarding: shouldResumeOnboarding(user),
|
||||
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
119
src/app/(main)/api/onboarding/route.ts
Normal file
119
src/app/(main)/api/onboarding/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { generateSlug } from '@/lib/hash';
|
||||
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
|
||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||
|
||||
// GET /api/qrs - List user's QR codes
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -47,12 +49,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Plan limits
|
||||
const PLAN_LIMITS = {
|
||||
FREE: 3,
|
||||
PRO: 50,
|
||||
BUSINESS: 500,
|
||||
ENTERPRISE: 99999,
|
||||
};
|
||||
const PLAN_LIMITS = DYNAMIC_QR_LIMITS;
|
||||
|
||||
// POST /api/qrs - Create a new QR code
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -227,6 +224,8 @@ END:VCARD`;
|
||||
},
|
||||
});
|
||||
|
||||
triggerLifecycleScoring(userId, 'qr_created');
|
||||
|
||||
return NextResponse.json(qrCode);
|
||||
} catch (error) {
|
||||
console.error('Error creating QR code:', error);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -61,6 +62,7 @@ export async function POST(request: NextRequest) {
|
||||
stripeCurrentPeriodEnd: null,
|
||||
},
|
||||
});
|
||||
await scoreUserLifecycle(userId, 'subscription_changed');
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
@@ -78,6 +80,8 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(userId, 'subscription_changed');
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error canceling subscription:', error);
|
||||
|
||||
@@ -68,6 +68,22 @@ export async function POST(request: NextRequest) {
|
||||
// Create or get Stripe customer
|
||||
let customerId = user.stripeCustomerId;
|
||||
|
||||
if (customerId) {
|
||||
try {
|
||||
const existingCustomer = await stripe.customers.retrieve(customerId);
|
||||
|
||||
if ('deleted' in existingCustomer && existingCustomer.deleted) {
|
||||
customerId = null;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'resource_missing' || error?.type === 'StripeInvalidRequestError') {
|
||||
customerId = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
@@ -85,6 +101,8 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin;
|
||||
|
||||
// Create Stripe Checkout Session
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
@@ -96,11 +114,12 @@ export async function POST(request: NextRequest) {
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
|
||||
success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${appUrl}/pricing?canceled=true`,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
plan,
|
||||
billingInterval,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||
|
||||
/**
|
||||
* Manual sync endpoint to update user subscription from Stripe
|
||||
@@ -47,6 +48,8 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(user.id, 'subscription_changed');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan: 'FREE',
|
||||
@@ -97,6 +100,8 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(user.id, 'subscription_changed');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -24,17 +25,21 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the most recent checkout session for this customer
|
||||
const checkoutSessions = await stripe.checkout.sessions.list({
|
||||
customer: user.stripeCustomerId,
|
||||
limit: 1,
|
||||
});
|
||||
const { sessionId } = await request.json().catch(() => ({ sessionId: null }));
|
||||
|
||||
if (checkoutSessions.data.length === 0) {
|
||||
return NextResponse.json({ error: 'No checkout session found' }, { status: 404 });
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing checkout session ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const checkoutSession = checkoutSessions.data[0];
|
||||
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
|
||||
const sessionBelongsToUser =
|
||||
checkoutSession.metadata?.userId === user.id ||
|
||||
checkoutSession.customer === user.stripeCustomerId;
|
||||
|
||||
if (!sessionBelongsToUser) {
|
||||
return NextResponse.json({ error: 'Checkout session does not belong to user' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Only process if payment was successful
|
||||
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
|
||||
@@ -48,11 +53,7 @@ export async function POST(request: NextRequest) {
|
||||
// Determine plan from metadata or price ID
|
||||
const plan = checkoutSession.metadata?.plan || 'PRO';
|
||||
|
||||
// Debug log to see the subscription structure
|
||||
console.log('Full subscription object:', JSON.stringify(subscription, null, 2));
|
||||
|
||||
// Get current_period_end - Stripe returns it as a Unix timestamp
|
||||
// Try different possible field names
|
||||
const periodEndTimestamp = subscription.current_period_end
|
||||
|| subscription.currentPeriodEnd
|
||||
|| subscription.billing_cycle_anchor;
|
||||
@@ -61,13 +62,6 @@ export async function POST(request: NextRequest) {
|
||||
? new Date(periodEndTimestamp * 1000)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
|
||||
|
||||
console.log('Subscription data:', {
|
||||
id: subscription.id,
|
||||
periodEndTimestamp,
|
||||
currentPeriodEnd,
|
||||
priceId: subscription.items?.data?.[0]?.price?.id,
|
||||
});
|
||||
|
||||
// Update user in database
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
@@ -79,6 +73,8 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(user.id, 'subscription_changed');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plan,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { stripe } from '@/lib/stripe';
|
||||
import { db } from '@/lib/db';
|
||||
import Stripe from 'stripe';
|
||||
import { sendConversionEvent } from '@/lib/meta';
|
||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.text();
|
||||
@@ -62,6 +63,8 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
|
||||
|
||||
// Meta CAPI — Purchase event
|
||||
const amountCents = session.amount_total ?? 0;
|
||||
sendConversionEvent({
|
||||
@@ -101,13 +104,20 @@ export async function POST(request: NextRequest) {
|
||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||
},
|
||||
});
|
||||
const updated = await db.user.findUnique({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (updated?.id) {
|
||||
await scoreUserLifecycle(updated.id, 'subscription_changed');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
await db.user.update({
|
||||
const updatedUser = await db.user.update({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
},
|
||||
@@ -118,6 +128,8 @@ export async function POST(request: NextRequest) {
|
||||
plan: 'FREE',
|
||||
},
|
||||
});
|
||||
|
||||
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
1006
src/app/(main)/onboarding/OnboardingClient.tsx
Normal file
1006
src/app/(main)/onboarding/OnboardingClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
15
src/app/(main)/onboarding/page.tsx
Normal file
15
src/app/(main)/onboarding/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { hashIP } from '@/lib/hash';
|
||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -14,6 +15,7 @@ export async function GET(
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
content: true,
|
||||
contentType: true,
|
||||
},
|
||||
@@ -24,7 +26,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
// Track scan (fire and forget)
|
||||
trackScan(qrCode.id, request).catch(console.error);
|
||||
trackScan(qrCode.id, qrCode.userId, request).catch(console.error);
|
||||
|
||||
// Determine destination URL
|
||||
let destination = '';
|
||||
@@ -121,7 +123,7 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
async function trackScan(qrId: string, request: NextRequest) {
|
||||
async function trackScan(qrId: string, userId: string, request: NextRequest) {
|
||||
try {
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const referer = request.headers.get('referer') || '';
|
||||
@@ -133,13 +135,28 @@ async function trackScan(qrId: string, request: NextRequest) {
|
||||
const dnt = request.headers.get('dnt');
|
||||
if (dnt === '1') {
|
||||
// Respect Do Not Track - only increment counter
|
||||
const scanTimestamp = new Date();
|
||||
await db.qRScan.create({
|
||||
data: {
|
||||
qrId,
|
||||
ipHash: 'dnt',
|
||||
isUnique: false,
|
||||
ts: scanTimestamp,
|
||||
},
|
||||
});
|
||||
const activatedUsers = await db.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
firstScanAt: null,
|
||||
},
|
||||
data: {
|
||||
firstScanAt: scanTimestamp,
|
||||
activationAt: scanTimestamp,
|
||||
},
|
||||
});
|
||||
if (activatedUsers.count > 0) {
|
||||
triggerLifecycleScoring(userId, 'scan_recorded');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -222,9 +239,11 @@ async function trackScan(qrId: string, request: NextRequest) {
|
||||
const isUnique = !existingScan;
|
||||
|
||||
// Create scan record
|
||||
const scanTimestamp = new Date();
|
||||
await db.qRScan.create({
|
||||
data: {
|
||||
qrId,
|
||||
ts: scanTimestamp,
|
||||
ipHash,
|
||||
userAgent: userAgent.substring(0, 255),
|
||||
device,
|
||||
@@ -237,6 +256,21 @@ async function trackScan(qrId: string, request: NextRequest) {
|
||||
isUnique,
|
||||
},
|
||||
});
|
||||
|
||||
const activatedUsers = await db.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
firstScanAt: null,
|
||||
},
|
||||
data: {
|
||||
firstScanAt: scanTimestamp,
|
||||
activationAt: scanTimestamp,
|
||||
},
|
||||
});
|
||||
|
||||
if (activatedUsers.count > 0) {
|
||||
triggerLifecycleScoring(userId, 'scan_recorded');
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't throw - this is fire and forget
|
||||
}
|
||||
|
||||
133
src/components/dashboard/OnboardingChecklist.tsx
Normal file
133
src/components/dashboard/OnboardingChecklist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import React, { useState } from 'react';
|
||||
import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { trackEvent } from '@/components/PostHogProvider';
|
||||
|
||||
const AIComingSoonBanner = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -28,9 +29,16 @@ const AIComingSoonBanner = () => {
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to subscribe');
|
||||
const errorMessage = typeof data?.error === 'string'
|
||||
? data.error
|
||||
: data?.details?.[0]?.message || 'Failed to subscribe';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
trackEvent('newsletter_subscribed', {
|
||||
source: 'ai_coming_soon_banner',
|
||||
already_subscribed: Boolean(data.alreadySubscribed),
|
||||
});
|
||||
setSubmitted(true);
|
||||
setEmail('');
|
||||
} catch (err) {
|
||||
|
||||
47
src/lib/auth-flow.ts
Normal file
47
src/lib/auth-flow.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const GOOGLE_OAUTH_STATE_COOKIE_NAME = 'qrmaster_google_oauth_state';
|
||||
export const POST_AUTH_REDIRECT_COOKIE_NAME = 'qrmaster_post_auth_redirect';
|
||||
|
||||
export function sanitizeRedirectPath(value?: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('/api/') || trimmed.startsWith('/login') || trimmed.startsWith('/signup')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function appendRedirectParam(
|
||||
path: string,
|
||||
redirectPath?: string | null,
|
||||
extraParams?: Record<string, string | null | undefined>
|
||||
): string {
|
||||
const [pathname, existingQuery = ''] = path.split('?');
|
||||
const searchParams = new URLSearchParams(existingQuery);
|
||||
const safeRedirect = sanitizeRedirectPath(redirectPath);
|
||||
|
||||
if (safeRedirect) {
|
||||
searchParams.set('redirect', safeRedirect);
|
||||
}
|
||||
|
||||
Object.entries(extraParams || {}).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const query = searchParams.toString();
|
||||
return query ? `${pathname}?${query}` : pathname;
|
||||
}
|
||||
|
||||
export function getPostOnboardingDestination(redirectPath?: string | null): string {
|
||||
return sanitizeRedirectPath(redirectPath) || '/dashboard';
|
||||
}
|
||||
10
src/lib/plans.ts
Normal file
10
src/lib/plans.ts
Normal 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
361
src/lib/revops-server.ts
Normal 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
368
src/lib/revops.ts
Normal 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: '2–5' },
|
||||
{ value: '6_20', label: '6–20' },
|
||||
{ value: '21_100', label: '21–100' },
|
||||
{ value: '100_plus', label: '100+' },
|
||||
];
|
||||
|
||||
export function serializeAttributionCookie(snapshot: AttributionSnapshot): string {
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
export function parseAttributionCookie(value?: string | null): AttributionSnapshot | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as AttributionSnapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getEmailDomain(email?: string | null): string | null {
|
||||
if (!email || !email.includes('@')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return email.split('@')[1]?.trim().toLowerCase() || null;
|
||||
}
|
||||
|
||||
export function isFreemailDomain(domain?: string | null): boolean {
|
||||
return Boolean(domain && FREEMAIL_DOMAINS.has(domain.toLowerCase()));
|
||||
}
|
||||
|
||||
export function getSourceLabel(value?: string | null): string {
|
||||
const option = SIGNUP_SOURCE_OPTIONS.find((item) => item.value === value);
|
||||
return option?.label || 'Unknown';
|
||||
}
|
||||
|
||||
export function getUseCaseLabel(value?: string | null): string {
|
||||
const option = PRIMARY_USE_CASE_OPTIONS.find((item) => item.value === value);
|
||||
return option?.label || 'Unknown';
|
||||
}
|
||||
|
||||
export function getGoalLabel(value?: string | null): string {
|
||||
const option = PRIMARY_GOAL_OPTIONS.find((item) => item.value === value);
|
||||
return option?.label || 'Unknown';
|
||||
}
|
||||
|
||||
export function getRoleLabel(value?: string | null): string {
|
||||
const option = JOB_ROLE_OPTIONS.find((item) => item.value === value);
|
||||
return option?.label || 'Unknown';
|
||||
}
|
||||
|
||||
export function getTeamSizeLabel(value?: string | null): string {
|
||||
const option = TEAM_SIZE_OPTIONS.find((item) => item.value === value);
|
||||
return option?.label || 'Unknown';
|
||||
}
|
||||
|
||||
export function normalizeSource(input: {
|
||||
utmSource?: string | null;
|
||||
referrer?: string | null;
|
||||
landingPath?: string | null;
|
||||
}): string {
|
||||
const utmSource = input.utmSource?.toLowerCase().trim();
|
||||
const referrer = input.referrer?.toLowerCase().trim();
|
||||
const landingPath = input.landingPath?.toLowerCase().trim();
|
||||
|
||||
if (utmSource) {
|
||||
if (utmSource.includes('google')) return 'google_search';
|
||||
if (utmSource.includes('instagram')) return 'instagram';
|
||||
if (utmSource.includes('facebook') || utmSource.includes('meta')) return 'facebook';
|
||||
if (utmSource.includes('tiktok')) return 'tiktok';
|
||||
if (utmSource.includes('linkedin')) return 'linkedin';
|
||||
if (utmSource.includes('youtube')) return 'youtube';
|
||||
if (utmSource.includes('blog') || utmSource.includes('article') || utmSource.includes('seo')) return 'blog_article';
|
||||
}
|
||||
|
||||
if (referrer) {
|
||||
if (referrer.includes('google.')) return 'google_search';
|
||||
if (referrer.includes('instagram.')) return 'instagram';
|
||||
if (referrer.includes('facebook.') || referrer.includes('fb.com')) return 'facebook';
|
||||
if (referrer.includes('tiktok.')) return 'tiktok';
|
||||
if (referrer.includes('linkedin.')) return 'linkedin';
|
||||
if (referrer.includes('youtube.')) return 'youtube';
|
||||
if (referrer.includes('medium.com') || referrer.includes('substack.com') || referrer.includes('/blog')) return 'blog_article';
|
||||
}
|
||||
|
||||
if (landingPath && landingPath.startsWith('/blog')) {
|
||||
return 'blog_article';
|
||||
}
|
||||
|
||||
return 'direct';
|
||||
}
|
||||
|
||||
export function buildAttributionSnapshot(params: {
|
||||
utmSource?: string | null;
|
||||
utmMedium?: string | null;
|
||||
utmCampaign?: string | null;
|
||||
utmContent?: string | null;
|
||||
utmTerm?: string | null;
|
||||
referrer?: string | null;
|
||||
landingPath?: string | null;
|
||||
firstSeenAt?: Date | string | null;
|
||||
}): AttributionSnapshot {
|
||||
const signupFirstSeenAt =
|
||||
params.firstSeenAt instanceof Date
|
||||
? params.firstSeenAt.toISOString()
|
||||
: params.firstSeenAt || new Date().toISOString();
|
||||
|
||||
return {
|
||||
signupSource: normalizeSource({
|
||||
utmSource: params.utmSource,
|
||||
referrer: params.referrer,
|
||||
landingPath: params.landingPath,
|
||||
}),
|
||||
signupMedium: params.utmMedium || null,
|
||||
signupCampaign: params.utmCampaign || null,
|
||||
signupContent: params.utmContent || null,
|
||||
signupTerm: params.utmTerm || null,
|
||||
signupReferrer: params.referrer || null,
|
||||
signupLandingPath: params.landingPath || null,
|
||||
signupFirstSeenAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldResumeOnboarding(user: {
|
||||
onboardingStartedAt?: Date | null;
|
||||
onboardingCompletedAt?: Date | null;
|
||||
} | null): boolean {
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(user.onboardingStartedAt && !user.onboardingCompletedAt);
|
||||
}
|
||||
|
||||
export function getLifecycleStageLabel(stage?: string | null): string {
|
||||
switch (stage) {
|
||||
case 'activated':
|
||||
return 'Activated';
|
||||
case 'warm':
|
||||
return 'Warm';
|
||||
case 'hot':
|
||||
return 'Hot';
|
||||
case 'upgrade_candidate':
|
||||
return 'Upgrade Candidate';
|
||||
case 'paid':
|
||||
return 'Paid';
|
||||
default:
|
||||
return 'Cold';
|
||||
}
|
||||
}
|
||||
|
||||
export function getOnboardingHeadlineForUseCase(useCase?: string | null): string {
|
||||
switch (useCase) {
|
||||
case 'marketing_campaign':
|
||||
return 'Create your first campaign QR code';
|
||||
case 'menu_pdf':
|
||||
return 'Create your first menu QR code';
|
||||
case 'contact_card':
|
||||
return 'Create your first contact QR code';
|
||||
case 'barcode':
|
||||
return 'Create your first barcode';
|
||||
case 'bulk_qr':
|
||||
return 'Set up your first business-ready QR';
|
||||
default:
|
||||
return 'Create your first QR code';
|
||||
}
|
||||
}
|
||||
|
||||
export function getCreatePresetForUseCase(useCase?: string | null): {
|
||||
contentType: string;
|
||||
dynamic: boolean;
|
||||
title: string;
|
||||
content: Record<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),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import Stripe from 'stripe';
|
||||
import {
|
||||
FREE_DYNAMIC_QR_LIMIT,
|
||||
PRO_DYNAMIC_QR_LIMIT,
|
||||
BUSINESS_DYNAMIC_QR_LIMIT,
|
||||
} from '@/lib/plans';
|
||||
|
||||
// Use a placeholder during build time, real key at runtime
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
|
||||
@@ -22,13 +27,13 @@ export const STRIPE_PLANS = {
|
||||
currency: 'EUR',
|
||||
interval: 'month',
|
||||
features: [
|
||||
'3 dynamische QR-Codes',
|
||||
`${FREE_DYNAMIC_QR_LIMIT} dynamische QR-Codes`,
|
||||
'Basis-Tracking (Scans + Standort)',
|
||||
'Einfache Designs',
|
||||
'Unbegrenzte statische QR-Codes',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 3,
|
||||
dynamicQRCodes: FREE_DYNAMIC_QR_LIMIT,
|
||||
staticQRCodes: -1, // unlimited
|
||||
teamMembers: 1,
|
||||
},
|
||||
@@ -48,7 +53,7 @@ export const STRIPE_PLANS = {
|
||||
'SVG/PNG Download',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 50,
|
||||
dynamicQRCodes: PRO_DYNAMIC_QR_LIMIT,
|
||||
staticQRCodes: -1,
|
||||
teamMembers: 1,
|
||||
},
|
||||
@@ -68,7 +73,7 @@ export const STRIPE_PLANS = {
|
||||
'Priority Support',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 500,
|
||||
dynamicQRCodes: BUSINESS_DYNAMIC_QR_LIMIT,
|
||||
staticQRCodes: -1,
|
||||
teamMembers: 1,
|
||||
},
|
||||
|
||||
@@ -122,6 +122,17 @@ export const updateProfileSchema = z.object({
|
||||
.trim(),
|
||||
});
|
||||
|
||||
export const onboardingUpdateSchema = z.object({
|
||||
signupSourceSelfReported: z.string().max(100).optional(),
|
||||
primaryUseCase: z.string().max(100).optional(),
|
||||
primaryGoal: z.string().max(100).optional(),
|
||||
jobRole: z.string().max(100).optional(),
|
||||
companyName: z.string().max(200).optional(),
|
||||
companyWebsite: z.string().max(200).optional(),
|
||||
teamSizeBucket: z.string().max(100).optional(),
|
||||
markProfileComplete: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string()
|
||||
.min(1, 'Current password is required'),
|
||||
|
||||
@@ -1,21 +1,61 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import {
|
||||
ATTRIBUTION_COOKIE_NAME,
|
||||
buildAttributionSnapshot,
|
||||
serializeAttributionCookie,
|
||||
} from '@/lib/revops';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
function attachAttributionCookie(req: NextRequest, response: NextResponse) {
|
||||
if (req.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const path = req.nextUrl.pathname;
|
||||
|
||||
if (path.startsWith('/api/') || path.startsWith('/_next') || path.startsWith('/r/') || path.includes('.')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const snapshot = buildAttributionSnapshot({
|
||||
utmSource: req.nextUrl.searchParams.get('utm_source'),
|
||||
utmMedium: req.nextUrl.searchParams.get('utm_medium'),
|
||||
utmCampaign: req.nextUrl.searchParams.get('utm_campaign'),
|
||||
utmContent: req.nextUrl.searchParams.get('utm_content'),
|
||||
utmTerm: req.nextUrl.searchParams.get('utm_term'),
|
||||
referrer: req.headers.get('referer'),
|
||||
landingPath: path,
|
||||
firstSeenAt: new Date(),
|
||||
});
|
||||
|
||||
response.cookies.set(ATTRIBUTION_COOKIE_NAME, serializeAttributionCookie(snapshot), {
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 90,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname;
|
||||
|
||||
// 301 Redirects for /guide -> /learn to avoid duplicate content and consolidate authority
|
||||
if (path === '/guide/tracking-analytics') {
|
||||
return NextResponse.redirect(new URL('/learn/tracking', req.url), 301);
|
||||
return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/tracking', req.url), 301));
|
||||
}
|
||||
if (path === '/guide/bulk-qr-code-generation') {
|
||||
return NextResponse.redirect(new URL('/learn/developer', req.url), 301);
|
||||
return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/developer', req.url), 301));
|
||||
}
|
||||
if (path === '/guide/qr-code-best-practices') {
|
||||
return NextResponse.redirect(new URL('/learn/basics', req.url), 301);
|
||||
return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/basics', req.url), 301));
|
||||
}
|
||||
if (path === '/create-qr') {
|
||||
return NextResponse.redirect(new URL('/dynamic-qr-code-generator', req.url), 301);
|
||||
return attachAttributionCookie(req, NextResponse.redirect(new URL('/dynamic-qr-code-generator', req.url), 301));
|
||||
}
|
||||
|
||||
// Public routes that don't require authentication
|
||||
@@ -70,22 +110,22 @@ export function middleware(req: NextRequest) {
|
||||
|
||||
// Allow API routes
|
||||
if (path.startsWith('/api/')) {
|
||||
return NextResponse.next();
|
||||
return attachAttributionCookie(req, NextResponse.next());
|
||||
}
|
||||
|
||||
// Allow redirect routes (QR code redirects)
|
||||
if (path.startsWith('/r/')) {
|
||||
return NextResponse.next();
|
||||
return attachAttributionCookie(req, NextResponse.next());
|
||||
}
|
||||
|
||||
// Allow static files
|
||||
if (path.includes('.') || path.startsWith('/_next')) {
|
||||
return NextResponse.next();
|
||||
return attachAttributionCookie(req, NextResponse.next());
|
||||
}
|
||||
|
||||
// Allow public paths
|
||||
if (isPublicPath) {
|
||||
return NextResponse.next();
|
||||
return attachAttributionCookie(req, NextResponse.next());
|
||||
}
|
||||
|
||||
// For protected routes, check for userId cookie
|
||||
@@ -94,11 +134,13 @@ export function middleware(req: NextRequest) {
|
||||
if (!userId) {
|
||||
// Not authenticated - redirect to signup
|
||||
const signupUrl = new URL('/signup', req.url);
|
||||
return NextResponse.redirect(signupUrl);
|
||||
const redirectTarget = `${path}${req.nextUrl.search}`;
|
||||
signupUrl.searchParams.set('redirect', redirectTarget);
|
||||
return attachAttributionCookie(req, NextResponse.redirect(signupUrl));
|
||||
}
|
||||
|
||||
// Authenticated - allow access
|
||||
return NextResponse.next();
|
||||
return attachAttributionCookie(req, NextResponse.next());
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
Reference in New Issue
Block a user