This commit is contained in:
2026-07-07 00:10:29 +02:00
parent 68b2ac0089
commit 01284f5283
22 changed files with 2173 additions and 2089 deletions

15
.codex/hooks.json Normal file
View File

@@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "'C:\\Users\\timo\\Documents\\qrmaster\\QR-master\\.codex\\hooks\\check-gstack.sh'"
}
]
}
]
}
}

View File

@@ -0,0 +1,19 @@
#!/bin/bash
# Block skill usage when gstack is not installed globally.
if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then
cat >&2 <<'MSG'
BLOCKED: gstack is not installed globally.
gstack is required for AI-assisted work in this repo.
Install it:
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
cd ~/.claude/skills/gstack && ./setup --team
Then restart your AI coding tool.
MSG
echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}'
exit 0
fi
echo '{}'

2
.gitignore vendored
View File

@@ -50,7 +50,7 @@ logs
# project-specific # project-specific
Leads/ Leads/
marketing/ /marketing/
output/ output/
remotion/ remotion/

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,325 +1,325 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
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 { showToast } from '@/components/ui/Toast'; 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 { trackEvent } from '@/components/PostHogProvider';
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans'; import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
export default function PricingPage() { export default function PricingPage() {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState<string | null>(null); const [loading, setLoading] = useState<string | null>(null);
const [currentPlan, setCurrentPlan] = useState<string>('FREE'); const [currentPlan, setCurrentPlan] = useState<string>('FREE');
const [currentInterval, setCurrentInterval] = useState< const [currentInterval, setCurrentInterval] = useState<
'month' | 'year' | null 'month' | 'year' | null
>(null); >(null);
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month'); const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
useEffect(() => { useEffect(() => {
// Fetch current user plan // Fetch current user plan
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) {
const data = await response.json(); const data = await response.json();
setCurrentPlan(data.plan || 'FREE'); setCurrentPlan(data.plan || 'FREE');
setCurrentInterval(data.interval || null); setCurrentInterval(data.interval || null);
} }
} catch (error) { } catch (error) {
console.error('Error fetching user plan:', error); console.error('Error fetching user plan:', error);
} }
}; };
fetchUserPlan(); fetchUserPlan();
}, []); }, []);
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => { const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
setLoading(plan); setLoading(plan);
try { try {
trackEvent('upgrade_clicked', { trackEvent('upgrade_clicked', {
plan, plan,
billing_interval: billingPeriod, billing_interval: billingPeriod,
source: 'pricing_page', source: 'pricing_page',
current_plan: currentPlan, 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: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
plan, plan,
billingInterval: billingPeriod === 'month' ? 'month' : 'year', billingInterval: billingPeriod === 'month' ? 'month' : 'year',
}), }),
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => null); const errorData = await response.json().catch(() => null);
throw new Error(errorData?.error || 'Failed to create checkout session'); 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: any) { } catch (error: any) {
console.error('Error creating checkout session:', error); console.error('Error creating checkout session:', error);
showToast(error?.message || 'Failed to start checkout. Please try again.', 'error'); showToast(error?.message || 'Failed to start checkout. Please try again.', 'error');
setLoading(null); setLoading(null);
} }
}; };
const handleDowngrade = async () => { const handleDowngrade = async () => {
// Show confirmation dialog // Show confirmation dialog
const confirmed = window.confirm( const confirmed = window.confirm(
'Are you sure you want to cancel your paid plan? You will keep premium features until the end of your current billing period.' 'Are you sure you want to cancel your paid plan? You will keep premium features until the end of your current billing period.'
); );
if (!confirmed) { if (!confirmed) {
return; return;
} }
setLoading('FREE'); setLoading('FREE');
try { try {
const response = await fetch('/api/stripe/cancel-subscription', { const response = await fetch('/api/stripe/cancel-subscription', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to cancel subscription'); throw new Error(error.error || 'Failed to cancel subscription');
} }
showToast('Subscription will end at the end of your current billing period.', 'success'); showToast('Subscription will end at the end of your current billing period.', 'success');
// Refresh to update the plan // Refresh to update the plan
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 1500); }, 1500);
} catch (error: any) { } catch (error: any) {
console.error('Error canceling subscription:', error); console.error('Error canceling subscription:', error);
showToast( showToast(
error.message || 'Failed to downgrade. Please try again.', error.message || 'Failed to downgrade. Please try again.',
'error' 'error'
); );
setLoading(null); setLoading(null);
} }
}; };
// Helper function to check if this is the user's exact current plan (plan + interval) // Helper function to check if this is the user's exact current plan (plan + interval)
const isCurrentPlanWithInterval = ( const isCurrentPlanWithInterval = (
planType: string, planType: string,
interval: 'month' | 'year' interval: 'month' | 'year'
) => { ) => {
return currentPlan === planType && currentInterval === interval; return currentPlan === planType && currentInterval === interval;
}; };
// Helper function to check if user has this plan but different interval // Helper function to check if user has this plan but different interval
const hasPlanDifferentInterval = (planType: string) => { const hasPlanDifferentInterval = (planType: string) => {
return ( return (
currentPlan === planType && currentPlan === planType &&
currentInterval && currentInterval &&
currentInterval !== billingPeriod currentInterval !== billingPeriod
); );
}; };
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year'; const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
const plans = [ const plans = [
{ {
key: 'free', key: 'free',
name: 'Free', name: 'Free',
price: '€0', price: '€0',
period: 'forever', period: 'forever',
showDiscount: false, showDiscount: false,
features: [ features: [
`${FREE_DYNAMIC_QR_LIMIT} 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',
'Download as SVG/PNG', 'Download as SVG/PNG',
], ],
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Cancel paid plan', buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Cancel paid plan',
buttonVariant: 'outline' as const, buttonVariant: 'outline' as const,
disabled: currentPlan === 'FREE', disabled: currentPlan === 'FREE',
popular: false, popular: false,
onDowngrade: handleDowngrade, onDowngrade: handleDowngrade,
}, },
{ {
key: 'pro', key: 'pro',
name: 'Pro', name: 'Pro',
price: billingPeriod === 'month' ? '€9' : '€90', price: billingPeriod === 'month' ? '€9' : '€90',
period: billingPeriod === 'month' ? 'per month' : 'per year', period: billingPeriod === 'month' ? 'per month' : 'per year',
showDiscount: billingPeriod === 'year', showDiscount: billingPeriod === 'year',
features: [ features: [
'50 dynamic QR codes', '50 dynamic QR codes',
'Unlimited static QR codes', 'Unlimited static QR codes',
'Advanced analytics (scans, devices, locations)', 'Advanced analytics (scans, devices, locations)',
'Custom branding (colors & logos)', 'Custom branding (colors & logos)',
], ],
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval) buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
? 'Current Plan' ? 'Current Plan'
: hasPlanDifferentInterval('PRO') : hasPlanDifferentInterval('PRO')
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}` ? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
: 'Upgrade to Pro', : 'Upgrade to Pro',
buttonVariant: 'primary' as const, buttonVariant: 'primary' as const,
disabled: isCurrentPlanWithInterval('PRO', selectedInterval), disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
popular: true, popular: true,
onUpgrade: () => handleUpgrade('PRO'), onUpgrade: () => handleUpgrade('PRO'),
}, },
{ {
key: 'business', key: 'business',
name: 'Business', name: 'Business',
price: billingPeriod === 'month' ? '€29' : '€290', price: billingPeriod === 'month' ? '€29' : '€290',
period: billingPeriod === 'month' ? 'per month' : 'per year', period: billingPeriod === 'month' ? 'per month' : 'per year',
showDiscount: billingPeriod === 'year', showDiscount: billingPeriod === 'year',
features: [ features: [
'500 dynamic QR codes', '500 dynamic QR codes',
'Unlimited static QR codes', 'Unlimited static QR codes',
'Everything from Pro', 'Everything from Pro',
'Bulk QR Creation (up to 1,000)', 'Bulk QR Creation (up to 1,000)',
'Priority email support', 'Priority email support',
'Advanced tracking & insights', 'Advanced tracking & insights',
], ],
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval) buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
? 'Current Plan' ? 'Current Plan'
: hasPlanDifferentInterval('BUSINESS') : hasPlanDifferentInterval('BUSINESS')
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}` ? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
: 'Upgrade to Business', : 'Upgrade to Business',
buttonVariant: 'primary' as const, buttonVariant: 'primary' as const,
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval), disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
popular: false, popular: false,
onUpgrade: () => handleUpgrade('BUSINESS'), onUpgrade: () => handleUpgrade('BUSINESS'),
}, },
{ {
key: 'enterprise', key: 'enterprise',
name: 'Enterprise', name: 'Enterprise',
price: 'Custom', price: 'Custom',
period: '', period: '',
showDiscount: false, showDiscount: false,
features: [ features: [
'∞ dynamic QR codes', '∞ dynamic QR codes',
'Unlimited static QR codes', 'Unlimited static QR codes',
'Everything from Business', 'Everything from Business',
'Dedicated Account Manager', 'Dedicated Account Manager',
], ],
buttonText: 'Contact Us', buttonText: 'Contact Us',
buttonVariant: 'outline' as const, buttonVariant: 'outline' as const,
disabled: false, disabled: false,
popular: false, popular: false,
onUpgrade: () => (window.location.href = 'mailto:timo@qrmaster.net'), onUpgrade: () => (window.location.href = 'mailto:timo@qrmaster.net'),
}, },
]; ];
return ( return (
<div className="container mx-auto px-4 py-12"> <div className="container mx-auto px-4 py-12">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4"> <h1 className="text-4xl font-bold text-gray-900 mb-4">
Choose Your Plan Choose Your Plan
</h1> </h1>
<p className="text-xl text-gray-600"> <p className="text-xl text-gray-600">
Select the perfect plan for your QR code needs Select the perfect plan for your QR code needs
</p> </p>
</div> </div>
<div className="flex justify-center mb-8"> <div className="flex justify-center mb-8">
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} /> <BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
</div> </div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-7xl mx-auto"> <div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-7xl mx-auto">
{plans.map((plan) => ( {plans.map((plan) => (
<Card <Card
key={plan.key} key={plan.key}
className={ className={
plan.popular ? 'border-primary-500 shadow-xl relative' : '' plan.popular ? 'border-primary-500 shadow-xl relative' : ''
} }
> >
{plan.popular && ( {plan.popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2"> <div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<Badge variant="info" className="px-3 py-1"> <Badge variant="info" className="px-3 py-1">
Most Popular Most Popular
</Badge> </Badge>
</div> </div>
)} )}
<CardHeader className="text-center pb-8"> <CardHeader className="text-center pb-8">
<CardTitle className="text-2xl mb-4">{plan.name}</CardTitle> <CardTitle className="text-2xl mb-4">{plan.name}</CardTitle>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="flex items-baseline justify-center"> <div className="flex items-baseline justify-center">
<span className="text-4xl font-bold">{plan.price}</span> <span className="text-4xl font-bold">{plan.price}</span>
<span className="text-gray-600 ml-2">{plan.period}</span> <span className="text-gray-600 ml-2">{plan.period}</span>
</div> </div>
{plan.showDiscount && ( {plan.showDiscount && (
<Badge variant="success" className="mt-2"> <Badge variant="success" className="mt-2">
Save 16% Save 16%
</Badge> </Badge>
)} )}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<ul className="space-y-3"> <ul className="space-y-3">
{plan.features.map((feature: string, index: number) => ( {plan.features.map((feature: string, index: number) => (
<li key={index} className="flex items-start space-x-3"> <li key={index} className="flex items-start space-x-3">
<svg <svg
className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
> >
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd" clipRule="evenodd"
/> />
</svg> </svg>
<span className="text-gray-700">{feature}</span> <span className="text-gray-700">{feature}</span>
</li> </li>
))} ))}
</ul> </ul>
<Button <Button
variant={plan.buttonVariant} variant={plan.buttonVariant}
className="w-full" className="w-full"
size="lg" size="lg"
disabled={plan.disabled || loading === plan.key.toUpperCase()} disabled={plan.disabled || loading === plan.key.toUpperCase()}
onClick={ onClick={
plan.key === 'free' plan.key === 'free'
? (plan as any).onDowngrade ? (plan as any).onDowngrade
: (plan as any).onUpgrade : (plan as any).onUpgrade
} }
> >
{loading === plan.key.toUpperCase() {loading === plan.key.toUpperCase()
? 'Processing...' ? 'Processing...'
: plan.buttonText} : plan.buttonText}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
<div className="text-center mt-12"> <div className="text-center mt-12">
<p className="text-gray-600"> <p className="text-gray-600">
All plans include unlimited static QR codes and basic customization. All plans include unlimited static QR codes and basic customization.
</p> </p>
<p className="text-gray-600 mt-2"> <p className="text-gray-600 mt-2">
Need help choosing?{' '} Need help choosing?{' '}
<ObfuscatedMailto <ObfuscatedMailto
email="support@qrmaster.net" email="support@qrmaster.net"
className="text-primary-600 hover:text-primary-700 underline" className="text-primary-600 hover:text-primary-700 underline"
> >
Contact our team Contact our team
</ObfuscatedMailto> </ObfuscatedMailto>
</p> </p>
</div> </div>
</div> </div>
); );
} }

View File

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

View File

@@ -0,0 +1,50 @@
"use client";
import React, { useState } from "react";
export function HeroSpotlight({ children }: { children: React.ReactNode }) {
const [coords, setCoords] = useState({ x: 0, y: 0 });
const [isHovered, setIsHovered] = useState(false);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
const { left, top, width, height } = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - left) / width) * 100;
const y = ((e.clientY - top) / height) * 100;
setCoords({ x, y });
};
return (
<section
className="relative overflow-hidden bg-white border-b border-slate-150 pb-20 pt-16"
onMouseMove={handleMouseMove}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Grid Container - Handles the fade-out mask at the bottom */}
<div
className="absolute inset-0 pointer-events-none"
style={{
maskImage: 'radial-gradient(ellipse 60% 50% at 50% 0%, black 70%, transparent 100%)',
WebkitMaskImage: 'radial-gradient(ellipse 60% 50% at 50% 0%, black 70%, transparent 100%)',
}}
>
{/* Base Grid - light gray lines */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#cbd5e1_1px,transparent_1px),linear-gradient(to_bottom,#cbd5e1_1px,transparent_1px)] bg-[size:3.5rem_3.5rem] opacity-[0.35]" />
{/* Active Grid - blue lines, only visible within 120px circle around cursor */}
<div
className="absolute inset-0 bg-[linear-gradient(to_right,#3b82f6_1.5px,transparent_1.5px),linear-gradient(to_bottom,#3b82f6_1.5px,transparent_1.5px)] bg-[size:3.5rem_3.5rem] transition-opacity duration-300"
style={{
opacity: isHovered ? 1 : 0,
maskImage: `radial-gradient(120px circle at ${coords.x}% ${coords.y}%, black, transparent)`,
WebkitMaskImage: `radial-gradient(120px circle at ${coords.x}% ${coords.y}%, black, transparent)`,
}}
/>
</div>
<div className="relative z-10">
{children}
</div>
</section>
);
}

View File

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