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 |
@@ -11,11 +11,11 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
password String?
|
password String?
|
||||||
image String?
|
image String?
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -32,16 +32,56 @@ model User {
|
|||||||
resetPasswordToken String? @unique
|
resetPasswordToken String? @unique
|
||||||
resetPasswordExpires DateTime?
|
resetPasswordExpires DateTime?
|
||||||
|
|
||||||
// Retention email tracking
|
// Retention email tracking
|
||||||
activationNudgeSentAt DateTime?
|
activationNudgeSentAt DateTime?
|
||||||
upgradeNudgeSentAt DateTime?
|
upgradeNudgeSentAt DateTime?
|
||||||
thirtyDayNudgeSentAt DateTime?
|
thirtyDayNudgeSentAt DateTime?
|
||||||
|
|
||||||
qrCodes QRCode[]
|
// RevOps attribution
|
||||||
integrations Integration[]
|
signupSource String?
|
||||||
accounts Account[]
|
signupSourceSelfReported String?
|
||||||
sessions Session[]
|
signupMedium String?
|
||||||
}
|
signupCampaign String?
|
||||||
|
signupContent String?
|
||||||
|
signupTerm String?
|
||||||
|
signupReferrer String?
|
||||||
|
signupLandingPath String?
|
||||||
|
signupFirstSeenAt DateTime?
|
||||||
|
emailDomain String?
|
||||||
|
|
||||||
|
// Onboarding and qualification
|
||||||
|
primaryUseCase String?
|
||||||
|
primaryGoal String?
|
||||||
|
jobRole String?
|
||||||
|
companyName String?
|
||||||
|
companyWebsite String?
|
||||||
|
teamSizeBucket String?
|
||||||
|
onboardingStartedAt DateTime?
|
||||||
|
sourceConfirmedAt DateTime?
|
||||||
|
useCaseSelectedAt DateTime?
|
||||||
|
goalSelectedAt DateTime?
|
||||||
|
profileCompletedAt DateTime?
|
||||||
|
firstQrCreatedAt DateTime?
|
||||||
|
firstDynamicQrAt DateTime?
|
||||||
|
firstStaticQrAt DateTime?
|
||||||
|
firstScanAt DateTime?
|
||||||
|
activationAt DateTime?
|
||||||
|
onboardingCompletedAt DateTime?
|
||||||
|
|
||||||
|
// RevOps scoring
|
||||||
|
fitScore Int @default(0)
|
||||||
|
intentScore Int @default(0)
|
||||||
|
leadScore Int @default(0)
|
||||||
|
lifecycleStage String @default("cold")
|
||||||
|
lastQualifiedAt DateTime?
|
||||||
|
lastScoredAt DateTime?
|
||||||
|
|
||||||
|
qrCodes QRCode[]
|
||||||
|
integrations Integration[]
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
lifecycleLogs UserLifecycleLog[]
|
||||||
|
}
|
||||||
|
|
||||||
enum Plan {
|
enum Plan {
|
||||||
FREE
|
FREE
|
||||||
@@ -149,7 +189,7 @@ model QRScan {
|
|||||||
@@index([qrId, ts])
|
@@index([qrId, ts])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Integration {
|
model Integration {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
provider String
|
provider String
|
||||||
@@ -158,8 +198,22 @@ model Integration {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserLifecycleLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
fromStage String?
|
||||||
|
toStage String
|
||||||
|
fitScore Int @default(0)
|
||||||
|
intentScore Int @default(0)
|
||||||
|
leadScore Int @default(0)
|
||||||
|
reason String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
model NewsletterSubscription {
|
model NewsletterSubscription {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
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 |
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { toPng } from 'html-to-image';
|
import { toPng } from 'html-to-image';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
@@ -11,12 +11,14 @@ import { Select } from '@/components/ui/Select';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { calculateContrast, cn } from '@/lib/utils';
|
import { calculateContrast, cn } from '@/lib/utils';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
import {
|
import { trackEvent } from '@/components/PostHogProvider';
|
||||||
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon
|
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
|
||||||
} from 'lucide-react';
|
import {
|
||||||
|
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload, Barcode as BarcodeIcon
|
||||||
|
} from 'lucide-react';
|
||||||
import Barcode from 'react-barcode';
|
import Barcode from 'react-barcode';
|
||||||
|
|
||||||
// Tooltip component for form field help
|
// Tooltip component for form field help
|
||||||
@@ -99,9 +101,10 @@ function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string
|
|||||||
return new XMLSerializer().serializeToString(cloned);
|
return new XMLSerializer().serializeToString(cloned);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CreatePage() {
|
export default function CreatePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const searchParams = useSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
const { fetchWithCsrf } = useCsrf();
|
const { fetchWithCsrf } = useCsrf();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@@ -145,14 +148,14 @@ export default function CreatePage() {
|
|||||||
const [excavate, setExcavate] = useState(true);
|
const [excavate, setExcavate] = useState(true);
|
||||||
|
|
||||||
// QR preview
|
// QR preview
|
||||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
|
|
||||||
// Check if user can customize colors (PRO+ only)
|
// Check if user can customize colors (PRO+ only)
|
||||||
const canCustomizeColors = userPlan === 'PRO' || userPlan === 'BUSINESS';
|
const canCustomizeColors = userPlan === 'PRO' || userPlan === 'BUSINESS';
|
||||||
|
|
||||||
// Load user plan
|
// Load user plan
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserPlan = async () => {
|
const fetchUserPlan = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/plan');
|
const response = await fetch('/api/user/plan');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -163,8 +166,44 @@ export default function CreatePage() {
|
|||||||
console.error('Error fetching user plan:', error);
|
console.error('Error fetching user plan:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchUserPlan();
|
fetchUserPlan();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const queryContentType = searchParams.get('contentType');
|
||||||
|
const useCase = searchParams.get('useCase');
|
||||||
|
const titleParam = searchParams.get('title');
|
||||||
|
const isDynamicParam = searchParams.get('dynamic');
|
||||||
|
|
||||||
|
if (queryContentType) {
|
||||||
|
setContentType(queryContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (titleParam) {
|
||||||
|
setTitle(titleParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDynamicParam) {
|
||||||
|
setIsDynamic(isDynamicParam === '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useCase === 'menu_pdf') {
|
||||||
|
setContent((prev: any) => ({ ...prev, fileUrl: prev.fileUrl || '' }));
|
||||||
|
} else if (useCase === 'contact_card') {
|
||||||
|
setContent((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
firstName: prev.firstName || '',
|
||||||
|
lastName: prev.lastName || '',
|
||||||
|
}));
|
||||||
|
} else if (useCase === 'barcode') {
|
||||||
|
setContent((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
format: prev.format || 'CODE128',
|
||||||
|
}));
|
||||||
|
} else if (queryContentType === 'URL') {
|
||||||
|
setContent((prev: any) => ({ ...prev, url: prev.url || '' }));
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
const contrast = calculateContrast(foregroundColor, backgroundColor);
|
const contrast = calculateContrast(foregroundColor, backgroundColor);
|
||||||
const hasGoodContrast = contrast >= 4.5;
|
const hasGoodContrast = contrast >= 4.5;
|
||||||
@@ -226,13 +265,19 @@ export default function CreatePage() {
|
|||||||
const downloadQR = async (format: 'svg' | 'png') => {
|
const downloadQR = async (format: 'svg' | 'png') => {
|
||||||
if (!qrRef.current) return;
|
if (!qrRef.current) return;
|
||||||
try {
|
try {
|
||||||
if (format === 'png') {
|
if (format === 'png') {
|
||||||
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.download = `qrcode-${title || 'download'}.png`;
|
link.download = `qrcode-${title || 'download'}.png`;
|
||||||
link.href = dataUrl;
|
link.href = dataUrl;
|
||||||
link.click();
|
link.click();
|
||||||
} else {
|
trackEvent('qr_code_downloaded', {
|
||||||
|
format: 'png',
|
||||||
|
content_type: contentType,
|
||||||
|
qr_type: isDynamic ? 'dynamic' : 'static',
|
||||||
|
plan: userPlan,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
// For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed
|
// For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed
|
||||||
// Simplest is to check if we can export the SVG element directly but that misses the frame HTML.
|
// Simplest is to check if we can export the SVG element directly but that misses the frame HTML.
|
||||||
// html-to-image can generate SVG too.
|
// html-to-image can generate SVG too.
|
||||||
@@ -254,21 +299,34 @@ export default function CreatePage() {
|
|||||||
: new XMLSerializer().serializeToString(svgElement);
|
: new XMLSerializer().serializeToString(svgElement);
|
||||||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `qrcode-${title || 'download'}.svg`;
|
a.download = `qrcode-${title || 'download'}.svg`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
trackEvent('qr_code_downloaded', {
|
||||||
} else {
|
format: 'svg',
|
||||||
showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info');
|
content_type: contentType,
|
||||||
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
qr_type: isDynamic ? 'dynamic' : 'static',
|
||||||
const link = document.createElement('a');
|
plan: userPlan,
|
||||||
link.download = `qrcode-${title || 'download'}.png`;
|
});
|
||||||
link.href = dataUrl;
|
}
|
||||||
link.click();
|
} else {
|
||||||
}
|
showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info');
|
||||||
}
|
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `qrcode-${title || 'download'}.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
trackEvent('qr_code_downloaded', {
|
||||||
|
format: 'png',
|
||||||
|
content_type: contentType,
|
||||||
|
qr_type: isDynamic ? 'dynamic' : 'static',
|
||||||
|
plan: userPlan,
|
||||||
|
fallback_from: 'svg_with_frame',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error downloading QR code:', err);
|
console.error('Error downloading QR code:', err);
|
||||||
showToast('Error downloading QR code', 'error');
|
showToast('Error downloading QR code', 'error');
|
||||||
@@ -354,18 +412,38 @@ export default function CreatePage() {
|
|||||||
const responseData = await response.json();
|
const responseData = await response.json();
|
||||||
console.log('RESPONSE DATA:', responseData);
|
console.log('RESPONSE DATA:', responseData);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast(`QR Code "${title}" created successfully!`, 'success');
|
trackEvent('qr_code_created', {
|
||||||
|
content_type: contentType,
|
||||||
// Wait a moment so user sees the toast, then redirect
|
qr_type: isDynamic ? 'dynamic' : 'static',
|
||||||
setTimeout(() => {
|
plan: userPlan,
|
||||||
router.push('/dashboard');
|
has_logo: Boolean(logoUrl),
|
||||||
router.refresh();
|
frame_type: frameType,
|
||||||
}, 1000);
|
});
|
||||||
} else {
|
|
||||||
console.error('Error creating QR code:', responseData);
|
showToast(`QR Code "${title}" created successfully!`, 'success');
|
||||||
showToast(responseData.error || 'Error creating QR code', 'error');
|
|
||||||
}
|
// Wait a moment so user sees the toast, then redirect
|
||||||
|
setTimeout(() => {
|
||||||
|
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
|
||||||
|
if (searchParams.get('onboarding') === '1') {
|
||||||
|
router.push(appendRedirectParam('/onboarding', redirectTarget, { step: '8' }));
|
||||||
|
} else {
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
console.error('Error creating QR code:', responseData);
|
||||||
|
|
||||||
|
if (response.status === 403 && responseData.error === 'Limit reached') {
|
||||||
|
showToast(responseData.message || 'You have reached your plan limit.', 'error');
|
||||||
|
router.push('/pricing?reason=limit_reached');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(responseData.error || 'Error creating QR code', 'error');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating QR code:', error);
|
console.error('Error creating QR code:', error);
|
||||||
showToast('Error creating QR code. Please try again.', 'error');
|
showToast('Error creating QR code. Please try again.', 'error');
|
||||||
@@ -1180,4 +1258,4 @@ export default function CreatePage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import { StatsGrid } from '@/components/dashboard/StatsGrid';
|
|||||||
import { QRCodeCard } from '@/components/dashboard/QRCodeCard';
|
import { QRCodeCard } from '@/components/dashboard/QRCodeCard';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
||||||
import { QrCode } from 'lucide-react';
|
import { QrCode } from 'lucide-react';
|
||||||
|
import { trackEvent, identifyUser } from '@/components/PostHogProvider';
|
||||||
|
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||||
|
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist';
|
||||||
|
|
||||||
interface QRCodeData {
|
interface QRCodeData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,7 +47,8 @@ export default function DashboardPage() {
|
|||||||
conversionRate: 0,
|
conversionRate: 0,
|
||||||
uniqueScans: 0,
|
uniqueScans: 0,
|
||||||
});
|
});
|
||||||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||||||
|
const [onboardingState, setOnboardingState] = useState<any>(null);
|
||||||
|
|
||||||
|
|
||||||
const blogPosts = [
|
const blogPosts = [
|
||||||
@@ -117,12 +121,11 @@ export default function DashboardPage() {
|
|||||||
// Store in localStorage for consistency
|
// Store in localStorage for consistency
|
||||||
localStorage.setItem('user', JSON.stringify(user));
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
|
|
||||||
const { identifyUser, trackEvent } = await import('@/components/PostHogProvider');
|
identifyUser(user.id, {
|
||||||
identifyUser(user.id, {
|
email: user.email,
|
||||||
email: user.email,
|
name: user.name,
|
||||||
name: user.name,
|
plan: user.plan || 'FREE',
|
||||||
plan: user.plan || 'FREE',
|
provider: 'google',
|
||||||
provider: 'google',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
trackEvent(isNewUser ? 'user_signup' : 'user_login', {
|
trackEvent(isNewUser ? 'user_signup' : 'user_login', {
|
||||||
@@ -143,25 +146,35 @@ export default function DashboardPage() {
|
|||||||
}, [searchParams, router]);
|
}, [searchParams, router]);
|
||||||
|
|
||||||
// Check for successful payment and verify session
|
// Check for successful payment and verify session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const success = searchParams.get('success');
|
const success = searchParams.get('success');
|
||||||
if (success === 'true') {
|
const sessionId = searchParams.get('session_id');
|
||||||
const verifySession = async () => {
|
|
||||||
try {
|
if (success === 'true' && sessionId) {
|
||||||
const response = await fetch('/api/stripe/verify-session', {
|
const verifySession = async () => {
|
||||||
method: 'POST',
|
try {
|
||||||
});
|
const response = await fetch('/api/stripe/verify-session', {
|
||||||
|
method: 'POST',
|
||||||
if (response.ok) {
|
headers: {
|
||||||
const data = await response.json();
|
'Content-Type': 'application/json',
|
||||||
setUserPlan(data.plan);
|
},
|
||||||
setUpgradedPlan(data.plan);
|
body: JSON.stringify({ sessionId }),
|
||||||
setShowUpgradeDialog(true);
|
});
|
||||||
// Remove success parameter from URL
|
|
||||||
router.replace('/dashboard');
|
if (response.ok) {
|
||||||
} else {
|
const data = await response.json();
|
||||||
console.error('Failed to verify session:', await response.text());
|
setUserPlan(data.plan);
|
||||||
}
|
setUpgradedPlan(data.plan);
|
||||||
|
setShowUpgradeDialog(true);
|
||||||
|
trackEvent('upgrade_completed', {
|
||||||
|
plan: data.plan,
|
||||||
|
source: 'stripe_checkout',
|
||||||
|
});
|
||||||
|
// Remove success parameter from URL
|
||||||
|
router.replace('/dashboard');
|
||||||
|
} else {
|
||||||
|
console.error('Failed to verify session:', await response.text());
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error verifying session:', error);
|
console.error('Error verifying session:', error);
|
||||||
}
|
}
|
||||||
@@ -212,13 +225,19 @@ export default function DashboardPage() {
|
|||||||
setUserPlan(userData.plan || 'FREE');
|
setUserPlan(userData.plan || 'FREE');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch analytics data for trends (last 30 days = month comparison)
|
// Fetch analytics data for trends (last 30 days = month comparison)
|
||||||
const analyticsResponse = await fetch('/api/analytics/summary?range=30');
|
const analyticsResponse = await fetch('/api/analytics/summary?range=30');
|
||||||
if (analyticsResponse.ok) {
|
if (analyticsResponse.ok) {
|
||||||
const analytics = await analyticsResponse.json();
|
const analytics = await analyticsResponse.json();
|
||||||
setAnalyticsData(analytics);
|
setAnalyticsData(analytics);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
|
const onboardingResponse = await fetch('/api/onboarding');
|
||||||
|
if (onboardingResponse.ok) {
|
||||||
|
const onboardingData = await onboardingResponse.json();
|
||||||
|
setOnboardingState(onboardingData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
console.error('Error fetching data:', error);
|
console.error('Error fetching data:', error);
|
||||||
setQrCodes([]);
|
setQrCodes([]);
|
||||||
setStats({
|
setStats({
|
||||||
@@ -341,9 +360,11 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<StatsGrid
|
<OnboardingChecklist state={onboardingState} />
|
||||||
stats={stats}
|
|
||||||
|
<StatsGrid
|
||||||
|
stats={stats}
|
||||||
trends={{
|
trends={{
|
||||||
totalScans: analyticsData?.summary.scansTrend,
|
totalScans: analyticsData?.summary.scansTrend,
|
||||||
comparisonPeriod: analyticsData?.summary.comparisonPeriod || 'month'
|
comparisonPeriod: analyticsData?.summary.comparisonPeriod || 'month'
|
||||||
@@ -393,8 +414,8 @@ export default function DashboardPage() {
|
|||||||
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">Create your first QR code</h3>
|
<h3 className="text-lg font-semibold text-gray-700 mb-2">Create your first QR code</h3>
|
||||||
<p className="text-gray-500 mb-6 max-w-sm mx-auto">
|
<p className="text-gray-500 mb-6 max-w-sm mx-auto">
|
||||||
You have 3 free dynamic QR codes. They redirect wherever you want and track every scan.
|
You have {FREE_DYNAMIC_QR_LIMIT} free dynamic QR codes. They redirect wherever you want and track every scan.
|
||||||
</p>
|
</p>
|
||||||
<Link href="/create">
|
<Link href="/create">
|
||||||
<Button>Create QR Code — it takes 90 seconds</Button>
|
<Button>Create QR Code — it takes 90 seconds</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -521,4 +542,4 @@ export default function DashboardPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
|
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
|
||||||
|
|
||||||
type LoginClientProps = {
|
type LoginClientProps = {
|
||||||
showPageHeading?: boolean;
|
showPageHeading?: boolean;
|
||||||
@@ -20,9 +21,10 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
|
|||||||
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -57,10 +59,12 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
|
|||||||
console.error('PostHog tracking error:', error);
|
console.error('PostHog tracking error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for redirect parameter
|
// Check for redirect parameter
|
||||||
const redirectUrl = searchParams.get('redirect') || '/dashboard';
|
const redirectUrl = data.needsOnboarding
|
||||||
router.push(redirectUrl);
|
? appendRedirectParam('/onboarding', redirectTarget)
|
||||||
router.refresh();
|
: (redirectTarget || '/dashboard');
|
||||||
|
router.push(redirectUrl);
|
||||||
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Invalid email or password');
|
setError(data.error || 'Invalid email or password');
|
||||||
}
|
}
|
||||||
@@ -71,10 +75,10 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleSignIn = () => {
|
const handleGoogleSignIn = () => {
|
||||||
// Redirect to Google OAuth API route
|
// Redirect to Google OAuth API route
|
||||||
window.location.href = '/api/auth/google';
|
window.location.href = appendRedirectParam('/api/auth/google', redirectTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
@@ -199,9 +203,9 @@ export default function LoginClient({ showPageHeading = true }: LoginClientProps
|
|||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link href="/signup" className="text-primary-600 hover:text-primary-700 font-medium">
|
<Link href={appendRedirectParam('/signup', redirectTarget)} className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
|
import { appendRedirectParam, sanitizeRedirectPath } from '@/lib/auth-flow';
|
||||||
export default function SignupClient() {
|
|
||||||
const router = useRouter();
|
export default function SignupClient() {
|
||||||
const { t } = useTranslation();
|
const router = useRouter();
|
||||||
const { fetchWithCsrf } = useCsrf();
|
const searchParams = useSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { fetchWithCsrf } = useCsrf();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -68,9 +71,9 @@ export default function SignupClient() {
|
|||||||
console.error('PostHog tracking error:', error);
|
console.error('PostHog tracking error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to dashboard
|
// Redirect to onboarding
|
||||||
router.push('/dashboard');
|
router.push(appendRedirectParam('/onboarding', redirectTarget));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Failed to create account');
|
setError(data.error || 'Failed to create account');
|
||||||
}
|
}
|
||||||
@@ -81,10 +84,10 @@ export default function SignupClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleSignIn = () => {
|
const handleGoogleSignIn = () => {
|
||||||
// Redirect to Google OAuth API route
|
// Redirect to Google OAuth API route
|
||||||
window.location.href = '/api/auth/google';
|
window.location.href = appendRedirectParam('/api/auth/google', redirectTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
|
||||||
@@ -234,11 +237,11 @@ export default function SignupClient() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
|
<Link href={appendRedirectParam('/login', redirectTarget)} className="text-primary-600 hover:text-primary-700 font-medium">
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ import { showToast } from '@/components/ui/Toast';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { BillingToggle } from '@/components/ui/BillingToggle';
|
import { BillingToggle } from '@/components/ui/BillingToggle';
|
||||||
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
|
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
|
||||||
|
import { trackEvent } from '@/components/PostHogProvider';
|
||||||
|
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -40,6 +42,13 @@ export default function PricingPage() {
|
|||||||
setLoading(plan);
|
setLoading(plan);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
trackEvent('upgrade_clicked', {
|
||||||
|
plan,
|
||||||
|
billing_interval: billingPeriod,
|
||||||
|
source: 'pricing_page',
|
||||||
|
current_plan: currentPlan,
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch('/api/stripe/create-checkout-session', {
|
const response = await fetch('/api/stripe/create-checkout-session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -52,14 +61,15 @@ export default function PricingPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to create checkout session');
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.error || 'Failed to create checkout session');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { url } = await response.json();
|
const { url } = await response.json();
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Error creating checkout session:', error);
|
console.error('Error creating checkout session:', error);
|
||||||
showToast('Failed to start checkout. Please try again.', 'error');
|
showToast(error?.message || 'Failed to start checkout. Please try again.', 'error');
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -132,7 +142,7 @@ export default function PricingPage() {
|
|||||||
period: 'forever',
|
period: 'forever',
|
||||||
showDiscount: false,
|
showDiscount: false,
|
||||||
features: [
|
features: [
|
||||||
'3 active dynamic QR codes (8 types available)',
|
`${FREE_DYNAMIC_QR_LIMIT} active dynamic QR codes (8 types available)`,
|
||||||
'Unlimited static QR codes',
|
'Unlimited static QR codes',
|
||||||
'Basic scan tracking',
|
'Basic scan tracking',
|
||||||
'Standard QR design templates',
|
'Standard QR design templates',
|
||||||
|
|||||||
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,14 +1,32 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { cookies } from 'next/headers';
|
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
import {
|
||||||
|
appendRedirectParam,
|
||||||
export async function GET(request: NextRequest) {
|
GOOGLE_OAUTH_STATE_COOKIE_NAME,
|
||||||
const { searchParams } = new URL(request.url);
|
POST_AUTH_REDIRECT_COOKIE_NAME,
|
||||||
const code = searchParams.get('code');
|
sanitizeRedirectPath,
|
||||||
|
} from '@/lib/auth-flow';
|
||||||
// If no code, redirect to Google OAuth
|
import {
|
||||||
if (!code) {
|
ATTRIBUTION_COOKIE_NAME,
|
||||||
|
getEmailDomain,
|
||||||
|
parseAttributionCookie,
|
||||||
|
shouldResumeOnboarding,
|
||||||
|
} from '@/lib/revops';
|
||||||
|
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const code = searchParams.get('code');
|
||||||
|
const state = searchParams.get('state');
|
||||||
|
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
|
||||||
|
const savedOauthState = request.cookies.get(GOOGLE_OAUTH_STATE_COOKIE_NAME)?.value;
|
||||||
|
const savedRedirect = sanitizeRedirectPath(request.cookies.get(POST_AUTH_REDIRECT_COOKIE_NAME)?.value);
|
||||||
|
|
||||||
|
// If no code, redirect to Google OAuth
|
||||||
|
if (!code) {
|
||||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||||
|
|
||||||
if (!googleClientId) {
|
if (!googleClientId) {
|
||||||
@@ -17,19 +35,56 @@ export async function GET(request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
||||||
const scope = 'openid email profile';
|
const scope = 'openid email profile';
|
||||||
|
const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect'));
|
||||||
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}`;
|
const oauthState = crypto.randomUUID();
|
||||||
|
|
||||||
return NextResponse.redirect(googleAuthUrl);
|
const googleAuthUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||||
}
|
googleAuthUrl.searchParams.set('client_id', googleClientId);
|
||||||
|
googleAuthUrl.searchParams.set('redirect_uri', redirectUri);
|
||||||
// Handle callback with code
|
googleAuthUrl.searchParams.set('response_type', 'code');
|
||||||
try {
|
googleAuthUrl.searchParams.set('scope', scope);
|
||||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
googleAuthUrl.searchParams.set('state', oauthState);
|
||||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
|
||||||
|
const response = NextResponse.redirect(googleAuthUrl);
|
||||||
|
response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (redirectTarget) {
|
||||||
|
response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 10,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle callback with code
|
||||||
|
try {
|
||||||
|
if (!state || !savedOauthState || state !== savedOauthState) {
|
||||||
|
const invalidStateResponse = NextResponse.redirect(
|
||||||
|
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-state-invalid`
|
||||||
|
);
|
||||||
|
invalidStateResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
||||||
|
invalidStateResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||||
|
return invalidStateResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||||
|
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||||
|
|
||||||
if (!googleClientId || !googleClientSecret) {
|
if (!googleClientId || !googleClientSecret) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -50,9 +105,9 @@ export async function GET(request: NextRequest) {
|
|||||||
code,
|
code,
|
||||||
client_id: googleClientId,
|
client_id: googleClientId,
|
||||||
client_secret: googleClientSecret,
|
client_secret: googleClientSecret,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
@@ -82,16 +137,27 @@ export async function GET(request: NextRequest) {
|
|||||||
const isNewUser = !user;
|
const isNewUser = !user;
|
||||||
|
|
||||||
// Create user if they don't exist
|
// Create user if they don't exist
|
||||||
if (!user) {
|
if (!user) {
|
||||||
user = await db.user.create({
|
const onboardingStartedAt = new Date();
|
||||||
data: {
|
user = await db.user.create({
|
||||||
email: userInfo.email,
|
data: {
|
||||||
name: userInfo.name || userInfo.email.split('@')[0],
|
email: userInfo.email,
|
||||||
image: userInfo.picture,
|
name: userInfo.name || userInfo.email.split('@')[0],
|
||||||
emailVerified: new Date(), // Google already verified the email
|
image: userInfo.picture,
|
||||||
password: null, // OAuth users don't need a password
|
emailVerified: new Date(), // Google already verified the email
|
||||||
},
|
password: null, // OAuth users don't need a password
|
||||||
});
|
onboardingStartedAt,
|
||||||
|
emailDomain: getEmailDomain(userInfo.email),
|
||||||
|
signupSource: firstTouch?.signupSource || null,
|
||||||
|
signupMedium: firstTouch?.signupMedium || null,
|
||||||
|
signupCampaign: firstTouch?.signupCampaign || null,
|
||||||
|
signupContent: firstTouch?.signupContent || null,
|
||||||
|
signupTerm: firstTouch?.signupTerm || null,
|
||||||
|
signupReferrer: firstTouch?.signupReferrer || null,
|
||||||
|
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
|
||||||
|
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Create Account entry for the OAuth provider
|
// Create Account entry for the OAuth provider
|
||||||
await db.account.create({
|
await db.account.create({
|
||||||
@@ -144,22 +210,35 @@ export async function GET(request: NextRequest) {
|
|||||||
id_token: tokens.id_token,
|
id_token: tokens.id_token,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set authentication cookie
|
triggerLifecycleScoring(user.id, isNewUser ? 'signup' : 'subscription_changed');
|
||||||
cookies().set('userId', user.id, getAuthCookieOptions());
|
|
||||||
|
const onboardingTarget = isNewUser || shouldResumeOnboarding(user)
|
||||||
// Redirect to dashboard with tracking params
|
? appendRedirectParam('/onboarding', savedRedirect, {
|
||||||
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`);
|
authMethod: 'google',
|
||||||
redirectUrl.searchParams.set('authMethod', 'google');
|
isNewUser: isNewUser.toString(),
|
||||||
redirectUrl.searchParams.set('isNewUser', isNewUser.toString());
|
})
|
||||||
|
: (savedRedirect || appendRedirectParam('/dashboard', null, {
|
||||||
return NextResponse.redirect(redirectUrl.toString());
|
authMethod: 'google',
|
||||||
} catch (error) {
|
isNewUser: isNewUser.toString(),
|
||||||
console.error('Google OAuth error:', error);
|
}));
|
||||||
return NextResponse.redirect(
|
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`);
|
||||||
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
|
|
||||||
);
|
const response = NextResponse.redirect(redirectUrl.toString());
|
||||||
}
|
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
||||||
}
|
response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
||||||
|
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||||
|
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google OAuth error:', error);
|
||||||
|
const errorResponse = NextResponse.redirect(
|
||||||
|
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
|
||||||
|
);
|
||||||
|
errorResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
||||||
|
errorResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||||
|
return errorResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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,14 +1,19 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { cookies } from 'next/headers';
|
import { db } from '@/lib/db';
|
||||||
import { db } from '@/lib/db';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||||
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
|
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
|
||||||
import { sendWelcomeEmail } from '@/lib/email';
|
import { sendWelcomeEmail } from '@/lib/email';
|
||||||
import { sendConversionEvent } from '@/lib/meta';
|
import { sendConversionEvent } from '@/lib/meta';
|
||||||
|
import {
|
||||||
|
ATTRIBUTION_COOKIE_NAME,
|
||||||
|
getEmailDomain,
|
||||||
|
parseAttributionCookie,
|
||||||
|
} from '@/lib/revops';
|
||||||
|
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -67,14 +72,29 @@ export async function POST(request: NextRequest) {
|
|||||||
// Hash password
|
// Hash password
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
// Create user
|
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
|
||||||
const user = await db.user.create({
|
const onboardingStartedAt = new Date();
|
||||||
data: {
|
|
||||||
name,
|
// Create user
|
||||||
email,
|
const user = await db.user.create({
|
||||||
password: hashedPassword,
|
data: {
|
||||||
},
|
name,
|
||||||
});
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
onboardingStartedAt,
|
||||||
|
emailDomain: getEmailDomain(email),
|
||||||
|
signupSource: firstTouch?.signupSource || null,
|
||||||
|
signupMedium: firstTouch?.signupMedium || null,
|
||||||
|
signupCampaign: firstTouch?.signupCampaign || null,
|
||||||
|
signupContent: firstTouch?.signupContent || null,
|
||||||
|
signupTerm: firstTouch?.signupTerm || null,
|
||||||
|
signupReferrer: firstTouch?.signupReferrer || null,
|
||||||
|
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
|
||||||
|
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
triggerLifecycleScoring(user.id, 'signup');
|
||||||
|
|
||||||
// Send welcome email (fire-and-forget — never block signup)
|
// Send welcome email (fire-and-forget — never block signup)
|
||||||
try {
|
try {
|
||||||
@@ -97,20 +117,22 @@ export async function POST(request: NextRequest) {
|
|||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
|
|
||||||
// Create response
|
// Create response
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
user: {
|
needsOnboarding: true,
|
||||||
id: user.id,
|
user: {
|
||||||
name: user.name,
|
id: user.id,
|
||||||
email: user.email,
|
name: user.name,
|
||||||
plan: 'FREE',
|
email: user.email,
|
||||||
|
plan: 'FREE',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set cookie for auto-login after signup
|
// Set cookie for auto-login after signup
|
||||||
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
||||||
|
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
||||||
return response;
|
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -125,4 +147,4 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||||
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
|
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
|
||||||
|
import { shouldResumeOnboarding } from '@/lib/revops';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -50,9 +51,18 @@ export async function POST(request: NextRequest) {
|
|||||||
const { email, password } = validation.data;
|
const { email, password } = validation.data;
|
||||||
|
|
||||||
// Find user
|
// Find user
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
plan: true,
|
||||||
|
password: true,
|
||||||
|
onboardingStartedAt: true,
|
||||||
|
onboardingCompletedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -74,12 +84,13 @@ export async function POST(request: NextRequest) {
|
|||||||
// Set cookie
|
// Set cookie
|
||||||
cookies().set('userId', user.id, getAuthCookieOptions());
|
cookies().set('userId', user.id, getAuthCookieOptions());
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
|
needsOnboarding: shouldResumeOnboarding(user),
|
||||||
});
|
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { generateSlug } from '@/lib/hash';
|
import { generateSlug } from '@/lib/hash';
|
||||||
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
|
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
|
||||||
|
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||||
|
|
||||||
// GET /api/qrs - List user's QR codes
|
// GET /api/qrs - List user's QR codes
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -47,12 +49,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Plan limits
|
// Plan limits
|
||||||
const PLAN_LIMITS = {
|
const PLAN_LIMITS = DYNAMIC_QR_LIMITS;
|
||||||
FREE: 3,
|
|
||||||
PRO: 50,
|
|
||||||
BUSINESS: 500,
|
|
||||||
ENTERPRISE: 99999,
|
|
||||||
};
|
|
||||||
|
|
||||||
// POST /api/qrs - Create a new QR code
|
// POST /api/qrs - Create a new QR code
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -208,9 +205,9 @@ END:VCARD`;
|
|||||||
const slug = generateSlug(body.title);
|
const slug = generateSlug(body.title);
|
||||||
|
|
||||||
// Create QR code
|
// Create QR code
|
||||||
const qrCode = await db.qRCode.create({
|
const qrCode = await db.qRCode.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
title: body.title,
|
title: body.title,
|
||||||
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
||||||
contentType: body.contentType,
|
contentType: body.contentType,
|
||||||
@@ -224,10 +221,12 @@ END:VCARD`;
|
|||||||
},
|
},
|
||||||
slug,
|
slug,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(qrCode);
|
triggerLifecycleScoring(userId, 'qr_created');
|
||||||
|
|
||||||
|
return NextResponse.json(qrCode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating QR code:', error);
|
console.error('Error creating QR code:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -235,4 +234,4 @@ END:VCARD`;
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
|
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -53,32 +54,35 @@ export async function POST(request: NextRequest) {
|
|||||||
// No active subscription
|
// No active subscription
|
||||||
if (!user.stripeSubscriptionId) {
|
if (!user.stripeSubscriptionId) {
|
||||||
// Just update plan to FREE if somehow plan is not FREE but no subscription
|
// Just update plan to FREE if somehow plan is not FREE but no subscription
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
plan: 'FREE',
|
plan: 'FREE',
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
stripeCurrentPeriodEnd: null,
|
stripeCurrentPeriodEnd: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json({ success: true });
|
await scoreUserLifecycle(userId, 'subscription_changed');
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel the Stripe subscription
|
// Cancel the Stripe subscription
|
||||||
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
|
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
|
||||||
|
|
||||||
// Update user plan to FREE
|
// Update user plan to FREE
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
plan: 'FREE',
|
plan: 'FREE',
|
||||||
stripeSubscriptionId: null,
|
stripeSubscriptionId: null,
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
stripeCurrentPeriodEnd: null,
|
stripeCurrentPeriodEnd: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
await scoreUserLifecycle(userId, 'subscription_changed');
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error canceling subscription:', error);
|
console.error('Error canceling subscription:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -65,13 +65,29 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or get Stripe customer
|
// Create or get Stripe customer
|
||||||
let customerId = user.stripeCustomerId;
|
let customerId = user.stripeCustomerId;
|
||||||
|
|
||||||
if (!customerId) {
|
if (customerId) {
|
||||||
const customer = await stripe.customers.create({
|
try {
|
||||||
email: user.email,
|
const existingCustomer = await stripe.customers.retrieve(customerId);
|
||||||
metadata: {
|
|
||||||
|
if ('deleted' in existingCustomer && existingCustomer.deleted) {
|
||||||
|
customerId = null;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'resource_missing' || error?.type === 'StripeInvalidRequestError') {
|
||||||
|
customerId = null;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
const customer = await stripe.customers.create({
|
||||||
|
email: user.email,
|
||||||
|
metadata: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -79,30 +95,33 @@ export async function POST(request: NextRequest) {
|
|||||||
customerId = customer.id;
|
customerId = customer.id;
|
||||||
|
|
||||||
// Update user with Stripe customer ID
|
// Update user with Stripe customer ID
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { stripeCustomerId: customerId },
|
data: { stripeCustomerId: customerId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Stripe Checkout Session
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin;
|
||||||
const checkoutSession = await stripe.checkout.sessions.create({
|
|
||||||
customer: customerId,
|
// Create Stripe Checkout Session
|
||||||
mode: 'subscription',
|
const checkoutSession = await stripe.checkout.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
mode: 'subscription',
|
||||||
payment_method_types: ['card'],
|
payment_method_types: ['card'],
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: priceId,
|
price: priceId,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
|
success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
|
cancel_url: `${appUrl}/pricing?canceled=true`,
|
||||||
metadata: {
|
metadata: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
plan,
|
plan,
|
||||||
},
|
billingInterval,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ url: checkoutSession.url });
|
return NextResponse.json({ url: checkoutSession.url });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual sync endpoint to update user subscription from Stripe
|
* Manual sync endpoint to update user subscription from Stripe
|
||||||
@@ -37,17 +38,19 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (subscriptions.data.length === 0) {
|
if (subscriptions.data.length === 0) {
|
||||||
// No active subscription - set to FREE
|
// No active subscription - set to FREE
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
stripeSubscriptionId: null,
|
stripeSubscriptionId: null,
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
stripeCurrentPeriodEnd: null,
|
stripeCurrentPeriodEnd: null,
|
||||||
plan: 'FREE',
|
plan: 'FREE',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
await scoreUserLifecycle(user.id, 'subscription_changed');
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
plan: 'FREE',
|
plan: 'FREE',
|
||||||
message: 'No active subscription found, set to FREE plan',
|
message: 'No active subscription found, set to FREE plan',
|
||||||
@@ -87,18 +90,20 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update user in database
|
// Update user in database
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
stripePriceId: priceId,
|
stripePriceId: priceId,
|
||||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||||
plan: plan as any,
|
plan: plan as any,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
await scoreUserLifecycle(user.id, 'subscription_changed');
|
||||||
success: true,
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
plan,
|
plan,
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
currentPeriodEnd,
|
currentPeriodEnd,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -20,26 +21,30 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.stripeCustomerId) {
|
if (!user.stripeCustomerId) {
|
||||||
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the most recent checkout session for this customer
|
const { sessionId } = await request.json().catch(() => ({ sessionId: null }));
|
||||||
const checkoutSessions = await stripe.checkout.sessions.list({
|
|
||||||
customer: user.stripeCustomerId,
|
if (!sessionId || typeof sessionId !== 'string') {
|
||||||
limit: 1,
|
return NextResponse.json({ error: 'Missing checkout session ID' }, { status: 400 });
|
||||||
});
|
}
|
||||||
|
|
||||||
if (checkoutSessions.data.length === 0) {
|
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
|
||||||
return NextResponse.json({ error: 'No checkout session found' }, { status: 404 });
|
|
||||||
}
|
const sessionBelongsToUser =
|
||||||
|
checkoutSession.metadata?.userId === user.id ||
|
||||||
const checkoutSession = checkoutSessions.data[0];
|
checkoutSession.customer === user.stripeCustomerId;
|
||||||
|
|
||||||
// Only process if payment was successful
|
if (!sessionBelongsToUser) {
|
||||||
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
|
return NextResponse.json({ error: 'Checkout session does not belong to user' }, { status: 403 });
|
||||||
const subscriptionId = typeof checkoutSession.subscription === 'string'
|
}
|
||||||
? checkoutSession.subscription
|
|
||||||
|
// Only process if payment was successful
|
||||||
|
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
|
||||||
|
const subscriptionId = typeof checkoutSession.subscription === 'string'
|
||||||
|
? checkoutSession.subscription
|
||||||
: checkoutSession.subscription.id;
|
: checkoutSession.subscription.id;
|
||||||
|
|
||||||
// Retrieve the full subscription object
|
// Retrieve the full subscription object
|
||||||
@@ -48,41 +53,32 @@ export async function POST(request: NextRequest) {
|
|||||||
// Determine plan from metadata or price ID
|
// Determine plan from metadata or price ID
|
||||||
const plan = checkoutSession.metadata?.plan || 'PRO';
|
const plan = checkoutSession.metadata?.plan || 'PRO';
|
||||||
|
|
||||||
// Debug log to see the subscription structure
|
// Get current_period_end - Stripe returns it as a Unix timestamp
|
||||||
console.log('Full subscription object:', JSON.stringify(subscription, null, 2));
|
const periodEndTimestamp = subscription.current_period_end
|
||||||
|
|| subscription.currentPeriodEnd
|
||||||
// Get current_period_end - Stripe returns it as a Unix timestamp
|
|| subscription.billing_cycle_anchor;
|
||||||
// Try different possible field names
|
|
||||||
const periodEndTimestamp = subscription.current_period_end
|
|
||||||
|| subscription.currentPeriodEnd
|
|
||||||
|| subscription.billing_cycle_anchor;
|
|
||||||
|
|
||||||
const currentPeriodEnd = periodEndTimestamp
|
const currentPeriodEnd = periodEndTimestamp
|
||||||
? new Date(periodEndTimestamp * 1000)
|
? new Date(periodEndTimestamp * 1000)
|
||||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
|
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
|
||||||
|
|
||||||
console.log('Subscription data:', {
|
// Update user in database
|
||||||
id: subscription.id,
|
await db.user.update({
|
||||||
periodEndTimestamp,
|
where: { id: user.id },
|
||||||
currentPeriodEnd,
|
data: {
|
||||||
priceId: subscription.items?.data?.[0]?.price?.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
});
|
stripePriceId: subscription.items.data[0].price.id,
|
||||||
|
|
||||||
// Update user in database
|
|
||||||
await db.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
stripeSubscriptionId: subscription.id,
|
|
||||||
stripePriceId: subscription.items.data[0].price.id,
|
|
||||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||||
plan: plan as any,
|
plan: plan as any,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
await scoreUserLifecycle(user.id, 'subscription_changed');
|
||||||
success: true,
|
|
||||||
plan,
|
return NextResponse.json({
|
||||||
subscriptionId: subscription.id,
|
success: true,
|
||||||
|
plan,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import { sendConversionEvent } from '@/lib/meta';
|
import { sendConversionEvent } from '@/lib/meta';
|
||||||
|
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.text();
|
const body = await request.text();
|
||||||
@@ -50,17 +51,19 @@ export async function POST(request: NextRequest) {
|
|||||||
? new Date(periodEndTimestamp * 1000)
|
? new Date(periodEndTimestamp * 1000)
|
||||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
const updatedUser = await db.user.update({
|
const updatedUser = await db.user.update({
|
||||||
where: {
|
where: {
|
||||||
stripeCustomerId: session.customer as string,
|
stripeCustomerId: session.customer as string,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
stripePriceId: subscription.items.data[0].price.id,
|
stripePriceId: subscription.items.data[0].price.id,
|
||||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||||
plan: (session.metadata?.plan || 'FREE') as any,
|
plan: (session.metadata?.plan || 'FREE') as any,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
|
||||||
|
|
||||||
// Meta CAPI — Purchase event
|
// Meta CAPI — Purchase event
|
||||||
const amountCents = session.amount_total ?? 0;
|
const amountCents = session.amount_total ?? 0;
|
||||||
@@ -92,34 +95,43 @@ export async function POST(request: NextRequest) {
|
|||||||
? new Date(periodEndTimestamp * 1000)
|
? new Date(periodEndTimestamp * 1000)
|
||||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: {
|
where: {
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
stripePriceId: subscription.items.data[0].price.id,
|
stripePriceId: subscription.items.data[0].price.id,
|
||||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
const updated = await db.user.findUnique({
|
||||||
}
|
where: { stripeSubscriptionId: subscription.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (updated?.id) {
|
||||||
|
await scoreUserLifecycle(updated.id, 'subscription_changed');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'customer.subscription.deleted': {
|
case 'customer.subscription.deleted': {
|
||||||
const subscription = event.data.object as Stripe.Subscription;
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
|
||||||
await db.user.update({
|
const updatedUser = await db.user.update({
|
||||||
where: {
|
where: {
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
stripeSubscriptionId: null,
|
stripeSubscriptionId: null,
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
stripeCurrentPeriodEnd: null,
|
stripeCurrentPeriodEnd: null,
|
||||||
plan: 'FREE',
|
plan: 'FREE',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
|
||||||
}
|
await scoreUserLifecycle(updatedUser.id, 'subscription_changed');
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ received: true });
|
return NextResponse.json({ received: true });
|
||||||
|
|||||||
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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { hashIP } from '@/lib/hash';
|
import { hashIP } from '@/lib/hash';
|
||||||
|
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -10,13 +11,14 @@ export async function GET(
|
|||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
// Fetch QR code by slug
|
// Fetch QR code by slug
|
||||||
const qrCode = await db.qRCode.findUnique({
|
const qrCode = await db.qRCode.findUnique({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
content: true,
|
userId: true,
|
||||||
contentType: true,
|
content: true,
|
||||||
},
|
contentType: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!qrCode) {
|
if (!qrCode) {
|
||||||
@@ -24,7 +26,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Track scan (fire and forget)
|
// Track scan (fire and forget)
|
||||||
trackScan(qrCode.id, request).catch(console.error);
|
trackScan(qrCode.id, qrCode.userId, request).catch(console.error);
|
||||||
|
|
||||||
// Determine destination URL
|
// Determine destination URL
|
||||||
let destination = '';
|
let destination = '';
|
||||||
@@ -121,7 +123,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function trackScan(qrId: string, request: NextRequest) {
|
async function trackScan(qrId: string, userId: string, request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userAgent = request.headers.get('user-agent') || '';
|
const userAgent = request.headers.get('user-agent') || '';
|
||||||
const referer = request.headers.get('referer') || '';
|
const referer = request.headers.get('referer') || '';
|
||||||
@@ -133,15 +135,30 @@ async function trackScan(qrId: string, request: NextRequest) {
|
|||||||
const dnt = request.headers.get('dnt');
|
const dnt = request.headers.get('dnt');
|
||||||
if (dnt === '1') {
|
if (dnt === '1') {
|
||||||
// Respect Do Not Track - only increment counter
|
// Respect Do Not Track - only increment counter
|
||||||
await db.qRScan.create({
|
const scanTimestamp = new Date();
|
||||||
data: {
|
await db.qRScan.create({
|
||||||
qrId,
|
data: {
|
||||||
ipHash: 'dnt',
|
qrId,
|
||||||
isUnique: false,
|
ipHash: 'dnt',
|
||||||
},
|
isUnique: false,
|
||||||
});
|
ts: scanTimestamp,
|
||||||
return;
|
},
|
||||||
}
|
});
|
||||||
|
const activatedUsers = await db.user.updateMany({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
firstScanAt: null,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
firstScanAt: scanTimestamp,
|
||||||
|
activationAt: scanTimestamp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (activatedUsers.count > 0) {
|
||||||
|
triggerLifecycleScoring(userId, 'scan_recorded');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Hash IP for privacy
|
// Hash IP for privacy
|
||||||
const ipHash = hashIP(ip);
|
const ipHash = hashIP(ip);
|
||||||
@@ -222,22 +239,39 @@ async function trackScan(qrId: string, request: NextRequest) {
|
|||||||
const isUnique = !existingScan;
|
const isUnique = !existingScan;
|
||||||
|
|
||||||
// Create scan record
|
// Create scan record
|
||||||
await db.qRScan.create({
|
const scanTimestamp = new Date();
|
||||||
data: {
|
await db.qRScan.create({
|
||||||
qrId,
|
data: {
|
||||||
ipHash,
|
qrId,
|
||||||
userAgent: userAgent.substring(0, 255),
|
ts: scanTimestamp,
|
||||||
device,
|
ipHash,
|
||||||
|
userAgent: userAgent.substring(0, 255),
|
||||||
|
device,
|
||||||
os,
|
os,
|
||||||
country,
|
country,
|
||||||
referrer: referer.substring(0, 255),
|
referrer: referer.substring(0, 255),
|
||||||
utmSource,
|
utmSource,
|
||||||
utmMedium,
|
utmMedium,
|
||||||
utmCampaign,
|
utmCampaign,
|
||||||
isUnique,
|
isUnique,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
|
const activatedUsers = await db.user.updateMany({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
firstScanAt: null,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
firstScanAt: scanTimestamp,
|
||||||
|
activationAt: scanTimestamp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activatedUsers.count > 0) {
|
||||||
|
triggerLifecycleScoring(userId, 'scan_recorded');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
// Don't throw - this is fire and forget
|
// Don't throw - this is fire and forget
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,4 +284,4 @@ function ensureAbsoluteUrl(url: string): string {
|
|||||||
}
|
}
|
||||||
// Default to https for web URLs
|
// Default to https for web URLs
|
||||||
return `https://${url}`;
|
return `https://${url}`;
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react';
|
import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { trackEvent } from '@/components/PostHogProvider';
|
||||||
|
|
||||||
const AIComingSoonBanner = () => {
|
const AIComingSoonBanner = () => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -25,14 +26,21 @@ const AIComingSoonBanner = () => {
|
|||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || 'Failed to subscribe');
|
const errorMessage = typeof data?.error === 'string'
|
||||||
}
|
? data.error
|
||||||
|
: data?.details?.[0]?.message || 'Failed to subscribe';
|
||||||
setSubmitted(true);
|
throw new Error(errorMessage);
|
||||||
setEmail('');
|
}
|
||||||
|
|
||||||
|
trackEvent('newsletter_subscribed', {
|
||||||
|
source: 'ai_coming_soon_banner',
|
||||||
|
already_subscribed: Boolean(data.alreadySubscribed),
|
||||||
|
});
|
||||||
|
setSubmitted(true);
|
||||||
|
setEmail('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
47
src/lib/auth-flow.ts
Normal file
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';
|
||||||
|
}
|
||||||
@@ -905,7 +905,7 @@ export async function sendActivationNudgeEmail(email: string, name: string) {
|
|||||||
<td style="padding:40px 48px 0;">
|
<td style="padding:40px 48px 0;">
|
||||||
<p style="margin:0 0 8px;font-family:'DM Sans',-apple-system,sans-serif;
|
<p style="margin:0 0 8px;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
font-size:16px;line-height:1.75;color:${clr.text};">
|
font-size:16px;line-height:1.75;color:${clr.text};">
|
||||||
Your 3 free dynamic QR codes are still there. Unused.<br>
|
Your 3 free dynamic QR codes are still there. Unused.<br>
|
||||||
Dynamic means: one code, update the link anytime, every scan tracked.
|
Dynamic means: one code, update the link anytime, every scan tracked.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
@@ -1039,7 +1039,7 @@ export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount
|
|||||||
|
|
||||||
<h1 style="margin:28px 0 0;font-family:'DM Serif Display',Georgia,serif;
|
<h1 style="margin:28px 0 0;font-family:'DM Serif Display',Georgia,serif;
|
||||||
font-size:34px;font-weight:400;line-height:1.2;color:#FFFFFF;">
|
font-size:34px;font-weight:400;line-height:1.2;color:#FFFFFF;">
|
||||||
${qrCount} of 3 free codes used,<br>
|
${qrCount} of 3 free codes used,<br>
|
||||||
<em style="color:${clr.gold};">${firstName}.</em>
|
<em style="color:${clr.gold};">${firstName}.</em>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
|||||||
10
src/lib/plans.ts
Normal file
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,7 +1,12 @@
|
|||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
import {
|
||||||
// Use a placeholder during build time, real key at runtime
|
FREE_DYNAMIC_QR_LIMIT,
|
||||||
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
|
PRO_DYNAMIC_QR_LIMIT,
|
||||||
|
BUSINESS_DYNAMIC_QR_LIMIT,
|
||||||
|
} from '@/lib/plans';
|
||||||
|
|
||||||
|
// Use a placeholder during build time, real key at runtime
|
||||||
|
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
|
||||||
|
|
||||||
export const stripe = new Stripe(stripeKey, {
|
export const stripe = new Stripe(stripeKey, {
|
||||||
apiVersion: '2025-10-29.clover',
|
apiVersion: '2025-10-29.clover',
|
||||||
@@ -20,18 +25,18 @@ export const STRIPE_PLANS = {
|
|||||||
name: 'Free / Starter',
|
name: 'Free / Starter',
|
||||||
price: 0,
|
price: 0,
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
interval: 'month',
|
interval: 'month',
|
||||||
features: [
|
features: [
|
||||||
'3 dynamische QR-Codes',
|
`${FREE_DYNAMIC_QR_LIMIT} dynamische QR-Codes`,
|
||||||
'Basis-Tracking (Scans + Standort)',
|
'Basis-Tracking (Scans + Standort)',
|
||||||
'Einfache Designs',
|
'Einfache Designs',
|
||||||
'Unbegrenzte statische QR-Codes',
|
'Unbegrenzte statische QR-Codes',
|
||||||
],
|
],
|
||||||
limits: {
|
limits: {
|
||||||
dynamicQRCodes: 3,
|
dynamicQRCodes: FREE_DYNAMIC_QR_LIMIT,
|
||||||
staticQRCodes: -1, // unlimited
|
staticQRCodes: -1, // unlimited
|
||||||
teamMembers: 1,
|
teamMembers: 1,
|
||||||
},
|
},
|
||||||
priceId: null, // No Stripe price for free plan
|
priceId: null, // No Stripe price for free plan
|
||||||
},
|
},
|
||||||
PRO: {
|
PRO: {
|
||||||
@@ -46,12 +51,12 @@ export const STRIPE_PLANS = {
|
|||||||
'Detailed Analytics (Date, Device, City)',
|
'Detailed Analytics (Date, Device, City)',
|
||||||
'CSV Export',
|
'CSV Export',
|
||||||
'SVG/PNG Download',
|
'SVG/PNG Download',
|
||||||
],
|
],
|
||||||
limits: {
|
limits: {
|
||||||
dynamicQRCodes: 50,
|
dynamicQRCodes: PRO_DYNAMIC_QR_LIMIT,
|
||||||
staticQRCodes: -1,
|
staticQRCodes: -1,
|
||||||
teamMembers: 1,
|
teamMembers: 1,
|
||||||
},
|
},
|
||||||
priceId: process.env.STRIPE_PRICE_ID_PRO_MONTHLY,
|
priceId: process.env.STRIPE_PRICE_ID_PRO_MONTHLY,
|
||||||
priceIdYearly: process.env.STRIPE_PRICE_ID_PRO_YEARLY,
|
priceIdYearly: process.env.STRIPE_PRICE_ID_PRO_YEARLY,
|
||||||
},
|
},
|
||||||
@@ -66,12 +71,12 @@ export const STRIPE_PLANS = {
|
|||||||
'Everything from Pro',
|
'Everything from Pro',
|
||||||
'Bulk QR Generation (up to 1,000)',
|
'Bulk QR Generation (up to 1,000)',
|
||||||
'Priority Support',
|
'Priority Support',
|
||||||
],
|
],
|
||||||
limits: {
|
limits: {
|
||||||
dynamicQRCodes: 500,
|
dynamicQRCodes: BUSINESS_DYNAMIC_QR_LIMIT,
|
||||||
staticQRCodes: -1,
|
staticQRCodes: -1,
|
||||||
teamMembers: 1,
|
teamMembers: 1,
|
||||||
},
|
},
|
||||||
priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY,
|
priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY,
|
||||||
priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY,
|
priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -115,12 +115,23 @@ export const resetPasswordSchema = z.object({
|
|||||||
// Settings Schemas
|
// Settings Schemas
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export const updateProfileSchema = z.object({
|
export const updateProfileSchema = z.object({
|
||||||
name: z.string()
|
name: z.string()
|
||||||
.min(2, 'Name must be at least 2 characters')
|
.min(2, 'Name must be at least 2 characters')
|
||||||
.max(100, 'Name must be less than 100 characters')
|
.max(100, 'Name must be less than 100 characters')
|
||||||
.trim(),
|
.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const onboardingUpdateSchema = z.object({
|
||||||
|
signupSourceSelfReported: z.string().max(100).optional(),
|
||||||
|
primaryUseCase: z.string().max(100).optional(),
|
||||||
|
primaryGoal: z.string().max(100).optional(),
|
||||||
|
jobRole: z.string().max(100).optional(),
|
||||||
|
companyName: z.string().max(200).optional(),
|
||||||
|
companyWebsite: z.string().max(200).optional(),
|
||||||
|
teamSizeBucket: z.string().max(100).optional(),
|
||||||
|
markProfileComplete: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const changePasswordSchema = z.object({
|
export const changePasswordSchema = z.object({
|
||||||
currentPassword: z.string()
|
currentPassword: z.string()
|
||||||
|
|||||||
@@ -1,22 +1,62 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
import {
|
||||||
|
ATTRIBUTION_COOKIE_NAME,
|
||||||
|
buildAttributionSnapshot,
|
||||||
|
serializeAttributionCookie,
|
||||||
|
} from '@/lib/revops';
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
function attachAttributionCookie(req: NextRequest, response: NextResponse) {
|
||||||
|
if (req.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = req.nextUrl.pathname;
|
||||||
|
|
||||||
|
if (path.startsWith('/api/') || path.startsWith('/_next') || path.startsWith('/r/') || path.includes('.')) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = buildAttributionSnapshot({
|
||||||
|
utmSource: req.nextUrl.searchParams.get('utm_source'),
|
||||||
|
utmMedium: req.nextUrl.searchParams.get('utm_medium'),
|
||||||
|
utmCampaign: req.nextUrl.searchParams.get('utm_campaign'),
|
||||||
|
utmContent: req.nextUrl.searchParams.get('utm_content'),
|
||||||
|
utmTerm: req.nextUrl.searchParams.get('utm_term'),
|
||||||
|
referrer: req.headers.get('referer'),
|
||||||
|
landingPath: path,
|
||||||
|
firstSeenAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set(ATTRIBUTION_COOKIE_NAME, serializeAttributionCookie(snapshot), {
|
||||||
|
httpOnly: false,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24 * 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
export function middleware(req: NextRequest) {
|
export function middleware(req: NextRequest) {
|
||||||
const path = req.nextUrl.pathname;
|
const path = req.nextUrl.pathname;
|
||||||
|
|
||||||
// 301 Redirects for /guide -> /learn to avoid duplicate content and consolidate authority
|
// 301 Redirects for /guide -> /learn to avoid duplicate content and consolidate authority
|
||||||
if (path === '/guide/tracking-analytics') {
|
if (path === '/guide/tracking-analytics') {
|
||||||
return NextResponse.redirect(new URL('/learn/tracking', req.url), 301);
|
return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/tracking', req.url), 301));
|
||||||
}
|
}
|
||||||
if (path === '/guide/bulk-qr-code-generation') {
|
if (path === '/guide/bulk-qr-code-generation') {
|
||||||
return NextResponse.redirect(new URL('/learn/developer', req.url), 301);
|
return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/developer', req.url), 301));
|
||||||
}
|
}
|
||||||
if (path === '/guide/qr-code-best-practices') {
|
if (path === '/guide/qr-code-best-practices') {
|
||||||
return NextResponse.redirect(new URL('/learn/basics', req.url), 301);
|
return attachAttributionCookie(req, NextResponse.redirect(new URL('/learn/basics', req.url), 301));
|
||||||
}
|
}
|
||||||
if (path === '/create-qr') {
|
if (path === '/create-qr') {
|
||||||
return NextResponse.redirect(new URL('/dynamic-qr-code-generator', req.url), 301);
|
return attachAttributionCookie(req, NextResponse.redirect(new URL('/dynamic-qr-code-generator', req.url), 301));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public routes that don't require authentication
|
// Public routes that don't require authentication
|
||||||
const publicPaths = [
|
const publicPaths = [
|
||||||
@@ -68,38 +108,40 @@ export function middleware(req: NextRequest) {
|
|||||||
// Check if path is public
|
// Check if path is public
|
||||||
const isPublicPath = publicPaths.some(p => path === p || path.startsWith(p + '/'));
|
const isPublicPath = publicPaths.some(p => path === p || path.startsWith(p + '/'));
|
||||||
|
|
||||||
// Allow API routes
|
// Allow API routes
|
||||||
if (path.startsWith('/api/')) {
|
if (path.startsWith('/api/')) {
|
||||||
return NextResponse.next();
|
return attachAttributionCookie(req, NextResponse.next());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow redirect routes (QR code redirects)
|
// Allow redirect routes (QR code redirects)
|
||||||
if (path.startsWith('/r/')) {
|
if (path.startsWith('/r/')) {
|
||||||
return NextResponse.next();
|
return attachAttributionCookie(req, NextResponse.next());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow static files
|
// Allow static files
|
||||||
if (path.includes('.') || path.startsWith('/_next')) {
|
if (path.includes('.') || path.startsWith('/_next')) {
|
||||||
return NextResponse.next();
|
return attachAttributionCookie(req, NextResponse.next());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow public paths
|
// Allow public paths
|
||||||
if (isPublicPath) {
|
if (isPublicPath) {
|
||||||
return NextResponse.next();
|
return attachAttributionCookie(req, NextResponse.next());
|
||||||
}
|
}
|
||||||
|
|
||||||
// For protected routes, check for userId cookie
|
// For protected routes, check for userId cookie
|
||||||
const userId = req.cookies.get('userId')?.value;
|
const userId = req.cookies.get('userId')?.value;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Not authenticated - redirect to signup
|
// Not authenticated - redirect to signup
|
||||||
const signupUrl = new URL('/signup', req.url);
|
const signupUrl = new URL('/signup', req.url);
|
||||||
return NextResponse.redirect(signupUrl);
|
const redirectTarget = `${path}${req.nextUrl.search}`;
|
||||||
}
|
signupUrl.searchParams.set('redirect', redirectTarget);
|
||||||
|
return attachAttributionCookie(req, NextResponse.redirect(signupUrl));
|
||||||
// Authenticated - allow access
|
}
|
||||||
return NextResponse.next();
|
|
||||||
}
|
// Authenticated - allow access
|
||||||
|
return attachAttributionCookie(req, NextResponse.next());
|
||||||
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
|
|||||||
Reference in New Issue
Block a user