Fix
15
.codex/hooks.json
Normal 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'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
.codex/hooks/check-gstack.sh
Normal 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
@@ -50,7 +50,7 @@ logs
|
|||||||
|
|
||||||
# project-specific
|
# project-specific
|
||||||
Leads/
|
Leads/
|
||||||
marketing/
|
/marketing/
|
||||||
output/
|
output/
|
||||||
remotion/
|
remotion/
|
||||||
|
|
||||||
|
|||||||
BIN
public/marketing/use-cases/business-card-qr-codes.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/marketing/use-cases/coupon-qr-codes.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/marketing/use-cases/event-qr-codes.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/marketing/use-cases/feedback-qr-codes.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/marketing/use-cases/flyer-qr-codes.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/marketing/use-cases/hotel-welcome-qr-codes.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/marketing/use-cases/packaging-qr-codes.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/marketing/use-cases/payment-qr-codes.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/marketing/use-cases/qr-codes-for-barbershops.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/marketing/use-cases/qr-codes-for-hotel.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/marketing/use-cases/qr-codes-for-review-collection.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/marketing/use-cases/real-estate-sign-qr-codes.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/marketing/use-cases/restaurant-menu-qr-codes.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/marketing/use-cases/salon-barbershop-qr-codes.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/components/marketing/HeroSpotlight.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||