Fix
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,325 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { BillingToggle } from '@/components/ui/BillingToggle';
|
||||
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
|
||||
import { trackEvent } from '@/components/PostHogProvider';
|
||||
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||
|
||||
export default function PricingPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
||||
const [currentInterval, setCurrentInterval] = useState<
|
||||
'month' | 'year' | null
|
||||
>(null);
|
||||
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch current user plan
|
||||
const fetchUserPlan = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/plan');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCurrentPlan(data.plan || 'FREE');
|
||||
setCurrentInterval(data.interval || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user plan:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserPlan();
|
||||
}, []);
|
||||
|
||||
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
|
||||
setLoading(plan);
|
||||
|
||||
try {
|
||||
trackEvent('upgrade_clicked', {
|
||||
plan,
|
||||
billing_interval: billingPeriod,
|
||||
source: 'pricing_page',
|
||||
current_plan: currentPlan,
|
||||
});
|
||||
|
||||
const response = await fetch('/api/stripe/create-checkout-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
billingInterval: billingPeriod === 'month' ? 'month' : 'year',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.error || 'Failed to create checkout session');
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
window.location.href = url;
|
||||
} catch (error: any) {
|
||||
console.error('Error creating checkout session:', error);
|
||||
showToast(error?.message || 'Failed to start checkout. Please try again.', 'error');
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDowngrade = async () => {
|
||||
// Show confirmation dialog
|
||||
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.'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading('FREE');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stripe/cancel-subscription', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to cancel subscription');
|
||||
}
|
||||
|
||||
showToast('Subscription will end at the end of your current billing period.', 'success');
|
||||
|
||||
// Refresh to update the plan
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error canceling subscription:', error);
|
||||
showToast(
|
||||
error.message || 'Failed to downgrade. Please try again.',
|
||||
'error'
|
||||
);
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if this is the user's exact current plan (plan + interval)
|
||||
const isCurrentPlanWithInterval = (
|
||||
planType: string,
|
||||
interval: 'month' | 'year'
|
||||
) => {
|
||||
return currentPlan === planType && currentInterval === interval;
|
||||
};
|
||||
|
||||
// Helper function to check if user has this plan but different interval
|
||||
const hasPlanDifferentInterval = (planType: string) => {
|
||||
return (
|
||||
currentPlan === planType &&
|
||||
currentInterval &&
|
||||
currentInterval !== billingPeriod
|
||||
);
|
||||
};
|
||||
|
||||
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
|
||||
|
||||
const plans = [
|
||||
{
|
||||
key: 'free',
|
||||
name: 'Free',
|
||||
price: '€0',
|
||||
period: 'forever',
|
||||
showDiscount: false,
|
||||
features: [
|
||||
`${FREE_DYNAMIC_QR_LIMIT} active dynamic QR codes (8 types available)`,
|
||||
'Unlimited static QR codes',
|
||||
'Basic scan tracking',
|
||||
'Standard QR design templates',
|
||||
'Download as SVG/PNG',
|
||||
],
|
||||
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Cancel paid plan',
|
||||
buttonVariant: 'outline' as const,
|
||||
disabled: currentPlan === 'FREE',
|
||||
popular: false,
|
||||
onDowngrade: handleDowngrade,
|
||||
},
|
||||
{
|
||||
key: 'pro',
|
||||
name: 'Pro',
|
||||
price: billingPeriod === 'month' ? '€9' : '€90',
|
||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||
showDiscount: billingPeriod === 'year',
|
||||
features: [
|
||||
'50 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Advanced analytics (scans, devices, locations)',
|
||||
'Custom branding (colors & logos)',
|
||||
],
|
||||
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
|
||||
? 'Current Plan'
|
||||
: hasPlanDifferentInterval('PRO')
|
||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||
: 'Upgrade to Pro',
|
||||
buttonVariant: 'primary' as const,
|
||||
disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
|
||||
popular: true,
|
||||
onUpgrade: () => handleUpgrade('PRO'),
|
||||
},
|
||||
{
|
||||
key: 'business',
|
||||
name: 'Business',
|
||||
price: billingPeriod === 'month' ? '€29' : '€290',
|
||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||
showDiscount: billingPeriod === 'year',
|
||||
features: [
|
||||
'500 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Everything from Pro',
|
||||
'Bulk QR Creation (up to 1,000)',
|
||||
'Priority email support',
|
||||
'Advanced tracking & insights',
|
||||
],
|
||||
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
|
||||
? 'Current Plan'
|
||||
: hasPlanDifferentInterval('BUSINESS')
|
||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||
: 'Upgrade to Business',
|
||||
buttonVariant: 'primary' as const,
|
||||
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
|
||||
popular: false,
|
||||
onUpgrade: () => handleUpgrade('BUSINESS'),
|
||||
},
|
||||
{
|
||||
key: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
price: 'Custom',
|
||||
period: '',
|
||||
showDiscount: false,
|
||||
features: [
|
||||
'∞ dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Everything from Business',
|
||||
'Dedicated Account Manager',
|
||||
],
|
||||
buttonText: 'Contact Us',
|
||||
buttonVariant: 'outline' as const,
|
||||
disabled: false,
|
||||
popular: false,
|
||||
onUpgrade: () => (window.location.href = 'mailto:timo@qrmaster.net'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Choose Your Plan
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Select the perfect plan for your QR code needs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-8">
|
||||
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-7xl mx-auto">
|
||||
{plans.map((plan) => (
|
||||
<Card
|
||||
key={plan.key}
|
||||
className={
|
||||
plan.popular ? 'border-primary-500 shadow-xl relative' : ''
|
||||
}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<Badge variant="info" className="px-3 py-1">
|
||||
Most Popular
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="text-center pb-8">
|
||||
<CardTitle className="text-2xl mb-4">{plan.name}</CardTitle>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-baseline justify-center">
|
||||
<span className="text-4xl font-bold">{plan.price}</span>
|
||||
<span className="text-gray-600 ml-2">{plan.period}</span>
|
||||
</div>
|
||||
{plan.showDiscount && (
|
||||
<Badge variant="success" className="mt-2">
|
||||
Save 16%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<ul className="space-y-3">
|
||||
{plan.features.map((feature: string, index: number) => (
|
||||
<li key={index} className="flex items-start space-x-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
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"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-gray-700">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant={plan.buttonVariant}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
||||
onClick={
|
||||
plan.key === 'free'
|
||||
? (plan as any).onDowngrade
|
||||
: (plan as any).onUpgrade
|
||||
}
|
||||
>
|
||||
{loading === plan.key.toUpperCase()
|
||||
? 'Processing...'
|
||||
: plan.buttonText}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-12">
|
||||
<p className="text-gray-600">
|
||||
All plans include unlimited static QR codes and basic customization.
|
||||
</p>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Need help choosing?{' '}
|
||||
<ObfuscatedMailto
|
||||
email="support@qrmaster.net"
|
||||
className="text-primary-600 hover:text-primary-700 underline"
|
||||
>
|
||||
Contact our team
|
||||
</ObfuscatedMailto>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { BillingToggle } from '@/components/ui/BillingToggle';
|
||||
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
|
||||
import { trackEvent } from '@/components/PostHogProvider';
|
||||
import { FREE_DYNAMIC_QR_LIMIT } from '@/lib/plans';
|
||||
|
||||
export default function PricingPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
||||
const [currentInterval, setCurrentInterval] = useState<
|
||||
'month' | 'year' | null
|
||||
>(null);
|
||||
const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>('month');
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch current user plan
|
||||
const fetchUserPlan = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/plan');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCurrentPlan(data.plan || 'FREE');
|
||||
setCurrentInterval(data.interval || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user plan:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserPlan();
|
||||
}, []);
|
||||
|
||||
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
|
||||
setLoading(plan);
|
||||
|
||||
try {
|
||||
trackEvent('upgrade_clicked', {
|
||||
plan,
|
||||
billing_interval: billingPeriod,
|
||||
source: 'pricing_page',
|
||||
current_plan: currentPlan,
|
||||
});
|
||||
|
||||
const response = await fetch('/api/stripe/create-checkout-session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
billingInterval: billingPeriod === 'month' ? 'month' : 'year',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.error || 'Failed to create checkout session');
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
window.location.href = url;
|
||||
} catch (error: any) {
|
||||
console.error('Error creating checkout session:', error);
|
||||
showToast(error?.message || 'Failed to start checkout. Please try again.', 'error');
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDowngrade = async () => {
|
||||
// Show confirmation dialog
|
||||
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.'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading('FREE');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stripe/cancel-subscription', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to cancel subscription');
|
||||
}
|
||||
|
||||
showToast('Subscription will end at the end of your current billing period.', 'success');
|
||||
|
||||
// Refresh to update the plan
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error canceling subscription:', error);
|
||||
showToast(
|
||||
error.message || 'Failed to downgrade. Please try again.',
|
||||
'error'
|
||||
);
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if this is the user's exact current plan (plan + interval)
|
||||
const isCurrentPlanWithInterval = (
|
||||
planType: string,
|
||||
interval: 'month' | 'year'
|
||||
) => {
|
||||
return currentPlan === planType && currentInterval === interval;
|
||||
};
|
||||
|
||||
// Helper function to check if user has this plan but different interval
|
||||
const hasPlanDifferentInterval = (planType: string) => {
|
||||
return (
|
||||
currentPlan === planType &&
|
||||
currentInterval &&
|
||||
currentInterval !== billingPeriod
|
||||
);
|
||||
};
|
||||
|
||||
const selectedInterval = billingPeriod === 'month' ? 'month' : 'year';
|
||||
|
||||
const plans = [
|
||||
{
|
||||
key: 'free',
|
||||
name: 'Free',
|
||||
price: '€0',
|
||||
period: 'forever',
|
||||
showDiscount: false,
|
||||
features: [
|
||||
`${FREE_DYNAMIC_QR_LIMIT} active dynamic QR codes (8 types available)`,
|
||||
'Unlimited static QR codes',
|
||||
'Basic scan tracking',
|
||||
'Standard QR design templates',
|
||||
'Download as SVG/PNG',
|
||||
],
|
||||
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Cancel paid plan',
|
||||
buttonVariant: 'outline' as const,
|
||||
disabled: currentPlan === 'FREE',
|
||||
popular: false,
|
||||
onDowngrade: handleDowngrade,
|
||||
},
|
||||
{
|
||||
key: 'pro',
|
||||
name: 'Pro',
|
||||
price: billingPeriod === 'month' ? '€9' : '€90',
|
||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||
showDiscount: billingPeriod === 'year',
|
||||
features: [
|
||||
'50 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Advanced analytics (scans, devices, locations)',
|
||||
'Custom branding (colors & logos)',
|
||||
],
|
||||
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
|
||||
? 'Current Plan'
|
||||
: hasPlanDifferentInterval('PRO')
|
||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||
: 'Upgrade to Pro',
|
||||
buttonVariant: 'primary' as const,
|
||||
disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
|
||||
popular: true,
|
||||
onUpgrade: () => handleUpgrade('PRO'),
|
||||
},
|
||||
{
|
||||
key: 'business',
|
||||
name: 'Business',
|
||||
price: billingPeriod === 'month' ? '€29' : '€290',
|
||||
period: billingPeriod === 'month' ? 'per month' : 'per year',
|
||||
showDiscount: billingPeriod === 'year',
|
||||
features: [
|
||||
'500 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Everything from Pro',
|
||||
'Bulk QR Creation (up to 1,000)',
|
||||
'Priority email support',
|
||||
'Advanced tracking & insights',
|
||||
],
|
||||
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
|
||||
? 'Current Plan'
|
||||
: hasPlanDifferentInterval('BUSINESS')
|
||||
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||
: 'Upgrade to Business',
|
||||
buttonVariant: 'primary' as const,
|
||||
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
|
||||
popular: false,
|
||||
onUpgrade: () => handleUpgrade('BUSINESS'),
|
||||
},
|
||||
{
|
||||
key: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
price: 'Custom',
|
||||
period: '',
|
||||
showDiscount: false,
|
||||
features: [
|
||||
'∞ dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Everything from Business',
|
||||
'Dedicated Account Manager',
|
||||
],
|
||||
buttonText: 'Contact Us',
|
||||
buttonVariant: 'outline' as const,
|
||||
disabled: false,
|
||||
popular: false,
|
||||
onUpgrade: () => (window.location.href = 'mailto:timo@qrmaster.net'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Choose Your Plan
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Select the perfect plan for your QR code needs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-8">
|
||||
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-7xl mx-auto">
|
||||
{plans.map((plan) => (
|
||||
<Card
|
||||
key={plan.key}
|
||||
className={
|
||||
plan.popular ? 'border-primary-500 shadow-xl relative' : ''
|
||||
}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<Badge variant="info" className="px-3 py-1">
|
||||
Most Popular
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="text-center pb-8">
|
||||
<CardTitle className="text-2xl mb-4">{plan.name}</CardTitle>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-baseline justify-center">
|
||||
<span className="text-4xl font-bold">{plan.price}</span>
|
||||
<span className="text-gray-600 ml-2">{plan.period}</span>
|
||||
</div>
|
||||
{plan.showDiscount && (
|
||||
<Badge variant="success" className="mt-2">
|
||||
Save 16%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<ul className="space-y-3">
|
||||
{plan.features.map((feature: string, index: number) => (
|
||||
<li key={index} className="flex items-start space-x-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
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"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-gray-700">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant={plan.buttonVariant}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
||||
onClick={
|
||||
plan.key === 'free'
|
||||
? (plan as any).onDowngrade
|
||||
: (plan as any).onUpgrade
|
||||
}
|
||||
>
|
||||
{loading === plan.key.toUpperCase()
|
||||
? 'Processing...'
|
||||
: plan.buttonText}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-12">
|
||||
<p className="text-gray-600">
|
||||
All plans include unlimited static QR codes and basic customization.
|
||||
</p>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Need help choosing?{' '}
|
||||
<ObfuscatedMailto
|
||||
email="support@qrmaster.net"
|
||||
className="text-primary-600 hover:text-primary-700 underline"
|
||||
>
|
||||
Contact our team
|
||||
</ObfuscatedMailto>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,469 +1,469 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import {
|
||||
getGoalLabel,
|
||||
getLifecycleStageLabel,
|
||||
getRoleLabel,
|
||||
getSourceLabel,
|
||||
getTeamSizeLabel,
|
||||
getUseCaseLabel,
|
||||
} from '@/lib/revops';
|
||||
import { db } from '@/lib/db';
|
||||
import { getMetricSnapshot, getUpgradeCandidateBadges } from '@/lib/revops-server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type HydratedUser = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
emailDomain: string | null;
|
||||
plan: string;
|
||||
lifecycleStage: string;
|
||||
fitScore: number;
|
||||
intentScore: number;
|
||||
leadScore: number;
|
||||
signupSource: string | null;
|
||||
signupSourceSelfReported: string | null;
|
||||
signupCampaign: string | null;
|
||||
signupLandingPath: string | null;
|
||||
primaryUseCase: string | null;
|
||||
primaryGoal: string | null;
|
||||
jobRole: string | null;
|
||||
companyName: string | null;
|
||||
companyWebsite: string | null;
|
||||
teamSizeBucket: string | null;
|
||||
createdAt: string;
|
||||
firstQrCreatedAt: string | null;
|
||||
activationAt: string | null;
|
||||
firstDynamicQrAt: string | null;
|
||||
qrCount: number;
|
||||
dynamicQrCount: number;
|
||||
scanCount: number;
|
||||
contentTypeCount: number;
|
||||
upgradeBadges: string[];
|
||||
};
|
||||
|
||||
function hasAdminSession() {
|
||||
const adminCookie = cookies().get('newsletter-admin');
|
||||
return adminCookie?.value === 'authenticated';
|
||||
}
|
||||
|
||||
function toIso(value: Date | null) {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
function safeDate(value: string | null) {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function applyUserFilters(users: HydratedUser[], request: NextRequest) {
|
||||
const stage = request.nextUrl.searchParams.get('stage');
|
||||
const source = request.nextUrl.searchParams.get('source');
|
||||
const campaign = request.nextUrl.searchParams.get('campaign');
|
||||
const landingPath = request.nextUrl.searchParams.get('landingPath');
|
||||
const useCase = request.nextUrl.searchParams.get('useCase');
|
||||
const goal = request.nextUrl.searchParams.get('goal');
|
||||
const role = request.nextUrl.searchParams.get('role');
|
||||
const teamSize = request.nextUrl.searchParams.get('teamSize');
|
||||
const plan = request.nextUrl.searchParams.get('plan');
|
||||
const search = request.nextUrl.searchParams.get('search')?.toLowerCase().trim();
|
||||
const from = safeDate(request.nextUrl.searchParams.get('from'));
|
||||
const to = safeDate(request.nextUrl.searchParams.get('to'));
|
||||
|
||||
return users.filter((user) => {
|
||||
const createdAt = new Date(user.createdAt);
|
||||
const matchesSearch = !search || [
|
||||
user.name,
|
||||
user.email,
|
||||
user.companyName,
|
||||
user.emailDomain,
|
||||
].filter(Boolean).some((value) => value!.toLowerCase().includes(search));
|
||||
|
||||
return (
|
||||
(!stage || user.lifecycleStage === stage) &&
|
||||
(!source || user.signupSource === source) &&
|
||||
(!campaign || user.signupCampaign === campaign) &&
|
||||
(!landingPath || user.signupLandingPath === landingPath) &&
|
||||
(!useCase || user.primaryUseCase === useCase) &&
|
||||
(!goal || user.primaryGoal === goal) &&
|
||||
(!role || user.jobRole === role) &&
|
||||
(!teamSize || user.teamSizeBucket === teamSize) &&
|
||||
(!plan || user.plan === plan) &&
|
||||
(!from || createdAt >= from) &&
|
||||
(!to || createdAt <= to) &&
|
||||
matchesSearch
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function sortUsers(users: HydratedUser[], sort: string) {
|
||||
const sorted = [...users];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
switch (sort) {
|
||||
case 'createdAt_asc':
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
case 'activationAt_desc':
|
||||
return new Date(b.activationAt || 0).getTime() - new Date(a.activationAt || 0).getTime();
|
||||
case 'leadScore_asc':
|
||||
return a.leadScore - b.leadScore;
|
||||
case 'fitScore_desc':
|
||||
return b.fitScore - a.fitScore;
|
||||
case 'intentScore_desc':
|
||||
return b.intentScore - a.intentScore;
|
||||
case 'leadScore_desc':
|
||||
default:
|
||||
return b.leadScore - a.leadScore || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function buildGroupedRows(users: HydratedUser[], key: keyof HydratedUser) {
|
||||
const rows = new Map<string, {
|
||||
key: string;
|
||||
signups: number;
|
||||
firstQr: number;
|
||||
activated: number;
|
||||
hot: number;
|
||||
upgradeCandidates: number;
|
||||
paid: number;
|
||||
}>();
|
||||
|
||||
users.forEach((user) => {
|
||||
const rawValue = (user[key] as string | null) || 'unknown';
|
||||
const row = rows.get(rawValue) || {
|
||||
key: rawValue,
|
||||
signups: 0,
|
||||
firstQr: 0,
|
||||
activated: 0,
|
||||
hot: 0,
|
||||
upgradeCandidates: 0,
|
||||
paid: 0,
|
||||
};
|
||||
|
||||
row.signups += 1;
|
||||
if (user.firstQrCreatedAt) row.firstQr += 1;
|
||||
if (user.activationAt) row.activated += 1;
|
||||
if (user.lifecycleStage === 'hot') row.hot += 1;
|
||||
if (user.lifecycleStage === 'upgrade_candidate') row.upgradeCandidates += 1;
|
||||
if (user.lifecycleStage === 'paid') row.paid += 1;
|
||||
|
||||
rows.set(rawValue, row);
|
||||
});
|
||||
|
||||
return Array.from(rows.values()).sort((a, b) => b.signups - a.signups);
|
||||
}
|
||||
|
||||
function buildFunnel(users: HydratedUser[]) {
|
||||
return {
|
||||
signup: users.length,
|
||||
sourceConfirmed: users.filter((user) => Boolean(user.signupSourceSelfReported)).length,
|
||||
useCaseSelected: users.filter((user) => Boolean(user.primaryUseCase)).length,
|
||||
goalSelected: users.filter((user) => Boolean(user.primaryGoal)).length,
|
||||
profileCaptured: users.filter((user) => Boolean(user.jobRole && user.teamSizeBucket)).length,
|
||||
firstQrCreated: users.filter((user) => Boolean(user.firstQrCreatedAt)).length,
|
||||
firstDynamicQrCreated: users.filter((user) => Boolean(user.firstDynamicQrAt)).length,
|
||||
activated: users.filter((user) => Boolean(user.activationAt)).length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLifecycleSummary(users: HydratedUser[]) {
|
||||
return {
|
||||
cold: users.filter((user) => user.lifecycleStage === 'cold').length,
|
||||
activated: users.filter((user) => user.lifecycleStage === 'activated').length,
|
||||
warm: users.filter((user) => user.lifecycleStage === 'warm').length,
|
||||
hot: users.filter((user) => user.lifecycleStage === 'hot').length,
|
||||
upgrade_candidate: users.filter((user) => user.lifecycleStage === 'upgrade_candidate').length,
|
||||
paid: users.filter((user) => user.lifecycleStage === 'paid').length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCsv(rows: HydratedUser[]) {
|
||||
const headers = [
|
||||
'name',
|
||||
'email',
|
||||
'email_domain',
|
||||
'plan',
|
||||
'lifecycle_stage',
|
||||
'fit_score',
|
||||
'intent_score',
|
||||
'lead_score',
|
||||
'source',
|
||||
'self_reported_source',
|
||||
'campaign',
|
||||
'landing_page',
|
||||
'use_case',
|
||||
'goal',
|
||||
'role',
|
||||
'company',
|
||||
'team_size',
|
||||
'created_at',
|
||||
'first_qr_created_at',
|
||||
'activation_at',
|
||||
'qr_count',
|
||||
'dynamic_qr_count',
|
||||
'scan_count',
|
||||
];
|
||||
|
||||
const escape = (value: string | number | null) => {
|
||||
const normalized = value == null ? '' : String(value);
|
||||
return `"${normalized.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
const lines = rows.map((row) => [
|
||||
row.name,
|
||||
row.email,
|
||||
row.emailDomain,
|
||||
row.plan,
|
||||
row.lifecycleStage,
|
||||
row.fitScore,
|
||||
row.intentScore,
|
||||
row.leadScore,
|
||||
row.signupSource,
|
||||
row.signupSourceSelfReported,
|
||||
row.signupCampaign,
|
||||
row.signupLandingPath,
|
||||
row.primaryUseCase,
|
||||
row.primaryGoal,
|
||||
row.jobRole,
|
||||
row.companyName,
|
||||
row.teamSizeBucket,
|
||||
row.createdAt,
|
||||
row.firstQrCreatedAt,
|
||||
row.activationAt,
|
||||
row.qrCount,
|
||||
row.dynamicQrCount,
|
||||
row.scanCount,
|
||||
].map(escape).join(','));
|
||||
|
||||
return [headers.join(','), ...lines].join('\n');
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
if (!hasAdminSession()) {
|
||||
return NextResponse.json({ error: 'Unauthorized - Admin login required' }, { status: 401 });
|
||||
}
|
||||
|
||||
const rawUsers = await db.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailDomain: true,
|
||||
plan: true,
|
||||
lifecycleStage: true,
|
||||
fitScore: true,
|
||||
intentScore: true,
|
||||
leadScore: true,
|
||||
signupSource: true,
|
||||
signupSourceSelfReported: true,
|
||||
signupCampaign: true,
|
||||
signupLandingPath: true,
|
||||
primaryUseCase: true,
|
||||
primaryGoal: true,
|
||||
jobRole: true,
|
||||
companyName: true,
|
||||
companyWebsite: true,
|
||||
teamSizeBucket: true,
|
||||
createdAt: true,
|
||||
firstQrCreatedAt: true,
|
||||
firstDynamicQrAt: true,
|
||||
activationAt: true,
|
||||
qrCodes: {
|
||||
select: {
|
||||
type: true,
|
||||
contentType: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
scans: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
const recentBillingLogs = await db.userLifecycleLog.findMany({
|
||||
where: {
|
||||
reason: {
|
||||
startsWith: 'subscription_',
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 10,
|
||||
select: {
|
||||
fromStage: true,
|
||||
toStage: true,
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
plan: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const users: HydratedUser[] = rawUsers.map((user) => {
|
||||
const metrics = getMetricSnapshot(user.qrCodes);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailDomain: user.emailDomain,
|
||||
plan: user.plan,
|
||||
lifecycleStage: user.lifecycleStage,
|
||||
fitScore: user.fitScore,
|
||||
intentScore: user.intentScore,
|
||||
leadScore: user.leadScore,
|
||||
signupSource: user.signupSource,
|
||||
signupSourceSelfReported: user.signupSourceSelfReported,
|
||||
signupCampaign: user.signupCampaign,
|
||||
signupLandingPath: user.signupLandingPath,
|
||||
primaryUseCase: user.primaryUseCase,
|
||||
primaryGoal: user.primaryGoal,
|
||||
jobRole: user.jobRole,
|
||||
companyName: user.companyName,
|
||||
companyWebsite: user.companyWebsite,
|
||||
teamSizeBucket: user.teamSizeBucket,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
firstQrCreatedAt: toIso(user.firstQrCreatedAt),
|
||||
activationAt: toIso(user.activationAt),
|
||||
firstDynamicQrAt: toIso(user.firstDynamicQrAt),
|
||||
qrCount: metrics.qrCount,
|
||||
dynamicQrCount: metrics.dynamicQrCount,
|
||||
scanCount: metrics.scanCount,
|
||||
contentTypeCount: metrics.contentTypeCount,
|
||||
upgradeBadges: getUpgradeCandidateBadges(user, metrics),
|
||||
};
|
||||
});
|
||||
|
||||
const filteredUsers = sortUsers(
|
||||
applyUserFilters(users, request),
|
||||
request.nextUrl.searchParams.get('sort') || 'leadScore_desc'
|
||||
);
|
||||
|
||||
if (request.nextUrl.searchParams.get('format') === 'csv') {
|
||||
const csv = buildCsv(filteredUsers);
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="qrmaster-revops-export.csv"',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const page = Number(request.nextUrl.searchParams.get('page') || '1');
|
||||
const pageSize = Number(request.nextUrl.searchParams.get('pageSize') || '25');
|
||||
const total = filteredUsers.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const paginatedUsers = filteredUsers.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
const acquisitionBySource = buildGroupedRows(users, 'signupSource').map((row) => ({
|
||||
...row,
|
||||
label: getSourceLabel(row.key),
|
||||
activationRate: row.signups ? Math.round((row.activated / row.signups) * 100) : 0,
|
||||
}));
|
||||
const acquisitionByCampaign = buildGroupedRows(users, 'signupCampaign');
|
||||
const acquisitionByLandingPath = buildGroupedRows(users, 'signupLandingPath');
|
||||
const funnel = buildFunnel(users);
|
||||
const lifecycleSummary = buildLifecycleSummary(users);
|
||||
|
||||
const mismatchCount = users.filter(
|
||||
(user) =>
|
||||
user.signupSource &&
|
||||
user.signupSourceSelfReported &&
|
||||
user.signupSource !== user.signupSourceSelfReported
|
||||
).length;
|
||||
|
||||
const upgradeCandidates = users
|
||||
.filter((user) => user.plan === 'FREE' && user.lifecycleStage === 'upgrade_candidate')
|
||||
.sort((a, b) => b.leadScore - a.leadScore)
|
||||
.slice(0, 25);
|
||||
|
||||
const recentBillingActivity = recentBillingLogs.map((log) => ({
|
||||
userId: log.user.id,
|
||||
name: log.user.name,
|
||||
email: log.user.email,
|
||||
plan: log.user.plan,
|
||||
reason: log.reason,
|
||||
fromStage: log.fromStage,
|
||||
toStage: log.toStage,
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
const filterOptions = {
|
||||
stages: ['cold', 'activated', 'warm', 'hot', 'upgrade_candidate', 'paid'],
|
||||
sources: Array.from(new Set(users.map((user) => user.signupSource).filter((value): value is string => Boolean(value)))),
|
||||
campaigns: Array.from(new Set(users.map((user) => user.signupCampaign).filter((value): value is string => Boolean(value)))),
|
||||
landingPaths: Array.from(new Set(users.map((user) => user.signupLandingPath).filter((value): value is string => Boolean(value)))),
|
||||
useCases: Array.from(new Set(users.map((user) => user.primaryUseCase).filter((value): value is string => Boolean(value)))),
|
||||
goals: Array.from(new Set(users.map((user) => user.primaryGoal).filter((value): value is string => Boolean(value)))),
|
||||
roles: Array.from(new Set(users.map((user) => user.jobRole).filter((value): value is string => Boolean(value)))),
|
||||
teamSizes: Array.from(new Set(users.map((user) => user.teamSizeBucket).filter((value): value is string => Boolean(value)))),
|
||||
plans: Array.from(new Set(users.map((user) => user.plan).filter((value): value is string => Boolean(value)))),
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
overview: {
|
||||
totalUsers: users.length,
|
||||
mismatchCount,
|
||||
activatedUsers: funnel.activated,
|
||||
paidUsers: lifecycleSummary.paid,
|
||||
recentBillingEvents: recentBillingActivity.length,
|
||||
},
|
||||
acquisition: {
|
||||
bySource: acquisitionBySource,
|
||||
byCampaign: acquisitionByCampaign.slice(0, 15),
|
||||
byLandingPath: acquisitionByLandingPath.slice(0, 15),
|
||||
},
|
||||
funnel,
|
||||
funnelBreakdowns: {
|
||||
bySource: acquisitionBySource.slice(0, 10),
|
||||
byUseCase: buildGroupedRows(users, 'primaryUseCase').map((row) => ({ ...row, label: getUseCaseLabel(row.key) })),
|
||||
byRole: buildGroupedRows(users, 'jobRole').map((row) => ({ ...row, label: getRoleLabel(row.key) })),
|
||||
byTeamSize: buildGroupedRows(users, 'teamSizeBucket').map((row) => ({ ...row, label: getTeamSizeLabel(row.key) })),
|
||||
},
|
||||
lifecycleSummary,
|
||||
recentBillingActivity,
|
||||
campaignSourceQuality: acquisitionBySource,
|
||||
upgradeCandidates,
|
||||
filterOptions,
|
||||
segments: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
rows: paginatedUsers.map((user) => ({
|
||||
...user,
|
||||
lifecycleStageLabel: getLifecycleStageLabel(user.lifecycleStage),
|
||||
signupSourceLabel: getSourceLabel(user.signupSource),
|
||||
signupSourceSelfReportedLabel: getSourceLabel(user.signupSourceSelfReported),
|
||||
primaryUseCaseLabel: getUseCaseLabel(user.primaryUseCase),
|
||||
primaryGoalLabel: getGoalLabel(user.primaryGoal),
|
||||
jobRoleLabel: getRoleLabel(user.jobRole),
|
||||
teamSizeLabel: getTeamSizeLabel(user.teamSizeBucket),
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching RevOps dashboard data:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch RevOps dashboard data' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import {
|
||||
getGoalLabel,
|
||||
getLifecycleStageLabel,
|
||||
getRoleLabel,
|
||||
getSourceLabel,
|
||||
getTeamSizeLabel,
|
||||
getUseCaseLabel,
|
||||
} from '@/lib/revops';
|
||||
import { db } from '@/lib/db';
|
||||
import { getMetricSnapshot, getUpgradeCandidateBadges } from '@/lib/revops-server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type HydratedUser = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
emailDomain: string | null;
|
||||
plan: string;
|
||||
lifecycleStage: string;
|
||||
fitScore: number;
|
||||
intentScore: number;
|
||||
leadScore: number;
|
||||
signupSource: string | null;
|
||||
signupSourceSelfReported: string | null;
|
||||
signupCampaign: string | null;
|
||||
signupLandingPath: string | null;
|
||||
primaryUseCase: string | null;
|
||||
primaryGoal: string | null;
|
||||
jobRole: string | null;
|
||||
companyName: string | null;
|
||||
companyWebsite: string | null;
|
||||
teamSizeBucket: string | null;
|
||||
createdAt: string;
|
||||
firstQrCreatedAt: string | null;
|
||||
activationAt: string | null;
|
||||
firstDynamicQrAt: string | null;
|
||||
qrCount: number;
|
||||
dynamicQrCount: number;
|
||||
scanCount: number;
|
||||
contentTypeCount: number;
|
||||
upgradeBadges: string[];
|
||||
};
|
||||
|
||||
function hasAdminSession() {
|
||||
const adminCookie = cookies().get('newsletter-admin');
|
||||
return adminCookie?.value === 'authenticated';
|
||||
}
|
||||
|
||||
function toIso(value: Date | null) {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
function safeDate(value: string | null) {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function applyUserFilters(users: HydratedUser[], request: NextRequest) {
|
||||
const stage = request.nextUrl.searchParams.get('stage');
|
||||
const source = request.nextUrl.searchParams.get('source');
|
||||
const campaign = request.nextUrl.searchParams.get('campaign');
|
||||
const landingPath = request.nextUrl.searchParams.get('landingPath');
|
||||
const useCase = request.nextUrl.searchParams.get('useCase');
|
||||
const goal = request.nextUrl.searchParams.get('goal');
|
||||
const role = request.nextUrl.searchParams.get('role');
|
||||
const teamSize = request.nextUrl.searchParams.get('teamSize');
|
||||
const plan = request.nextUrl.searchParams.get('plan');
|
||||
const search = request.nextUrl.searchParams.get('search')?.toLowerCase().trim();
|
||||
const from = safeDate(request.nextUrl.searchParams.get('from'));
|
||||
const to = safeDate(request.nextUrl.searchParams.get('to'));
|
||||
|
||||
return users.filter((user) => {
|
||||
const createdAt = new Date(user.createdAt);
|
||||
const matchesSearch = !search || [
|
||||
user.name,
|
||||
user.email,
|
||||
user.companyName,
|
||||
user.emailDomain,
|
||||
].filter(Boolean).some((value) => value!.toLowerCase().includes(search));
|
||||
|
||||
return (
|
||||
(!stage || user.lifecycleStage === stage) &&
|
||||
(!source || user.signupSource === source) &&
|
||||
(!campaign || user.signupCampaign === campaign) &&
|
||||
(!landingPath || user.signupLandingPath === landingPath) &&
|
||||
(!useCase || user.primaryUseCase === useCase) &&
|
||||
(!goal || user.primaryGoal === goal) &&
|
||||
(!role || user.jobRole === role) &&
|
||||
(!teamSize || user.teamSizeBucket === teamSize) &&
|
||||
(!plan || user.plan === plan) &&
|
||||
(!from || createdAt >= from) &&
|
||||
(!to || createdAt <= to) &&
|
||||
matchesSearch
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function sortUsers(users: HydratedUser[], sort: string) {
|
||||
const sorted = [...users];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
switch (sort) {
|
||||
case 'createdAt_asc':
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
case 'activationAt_desc':
|
||||
return new Date(b.activationAt || 0).getTime() - new Date(a.activationAt || 0).getTime();
|
||||
case 'leadScore_asc':
|
||||
return a.leadScore - b.leadScore;
|
||||
case 'fitScore_desc':
|
||||
return b.fitScore - a.fitScore;
|
||||
case 'intentScore_desc':
|
||||
return b.intentScore - a.intentScore;
|
||||
case 'leadScore_desc':
|
||||
default:
|
||||
return b.leadScore - a.leadScore || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function buildGroupedRows(users: HydratedUser[], key: keyof HydratedUser) {
|
||||
const rows = new Map<string, {
|
||||
key: string;
|
||||
signups: number;
|
||||
firstQr: number;
|
||||
activated: number;
|
||||
hot: number;
|
||||
upgradeCandidates: number;
|
||||
paid: number;
|
||||
}>();
|
||||
|
||||
users.forEach((user) => {
|
||||
const rawValue = (user[key] as string | null) || 'unknown';
|
||||
const row = rows.get(rawValue) || {
|
||||
key: rawValue,
|
||||
signups: 0,
|
||||
firstQr: 0,
|
||||
activated: 0,
|
||||
hot: 0,
|
||||
upgradeCandidates: 0,
|
||||
paid: 0,
|
||||
};
|
||||
|
||||
row.signups += 1;
|
||||
if (user.firstQrCreatedAt) row.firstQr += 1;
|
||||
if (user.activationAt) row.activated += 1;
|
||||
if (user.lifecycleStage === 'hot') row.hot += 1;
|
||||
if (user.lifecycleStage === 'upgrade_candidate') row.upgradeCandidates += 1;
|
||||
if (user.lifecycleStage === 'paid') row.paid += 1;
|
||||
|
||||
rows.set(rawValue, row);
|
||||
});
|
||||
|
||||
return Array.from(rows.values()).sort((a, b) => b.signups - a.signups);
|
||||
}
|
||||
|
||||
function buildFunnel(users: HydratedUser[]) {
|
||||
return {
|
||||
signup: users.length,
|
||||
sourceConfirmed: users.filter((user) => Boolean(user.signupSourceSelfReported)).length,
|
||||
useCaseSelected: users.filter((user) => Boolean(user.primaryUseCase)).length,
|
||||
goalSelected: users.filter((user) => Boolean(user.primaryGoal)).length,
|
||||
profileCaptured: users.filter((user) => Boolean(user.jobRole && user.teamSizeBucket)).length,
|
||||
firstQrCreated: users.filter((user) => Boolean(user.firstQrCreatedAt)).length,
|
||||
firstDynamicQrCreated: users.filter((user) => Boolean(user.firstDynamicQrAt)).length,
|
||||
activated: users.filter((user) => Boolean(user.activationAt)).length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLifecycleSummary(users: HydratedUser[]) {
|
||||
return {
|
||||
cold: users.filter((user) => user.lifecycleStage === 'cold').length,
|
||||
activated: users.filter((user) => user.lifecycleStage === 'activated').length,
|
||||
warm: users.filter((user) => user.lifecycleStage === 'warm').length,
|
||||
hot: users.filter((user) => user.lifecycleStage === 'hot').length,
|
||||
upgrade_candidate: users.filter((user) => user.lifecycleStage === 'upgrade_candidate').length,
|
||||
paid: users.filter((user) => user.lifecycleStage === 'paid').length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCsv(rows: HydratedUser[]) {
|
||||
const headers = [
|
||||
'name',
|
||||
'email',
|
||||
'email_domain',
|
||||
'plan',
|
||||
'lifecycle_stage',
|
||||
'fit_score',
|
||||
'intent_score',
|
||||
'lead_score',
|
||||
'source',
|
||||
'self_reported_source',
|
||||
'campaign',
|
||||
'landing_page',
|
||||
'use_case',
|
||||
'goal',
|
||||
'role',
|
||||
'company',
|
||||
'team_size',
|
||||
'created_at',
|
||||
'first_qr_created_at',
|
||||
'activation_at',
|
||||
'qr_count',
|
||||
'dynamic_qr_count',
|
||||
'scan_count',
|
||||
];
|
||||
|
||||
const escape = (value: string | number | null) => {
|
||||
const normalized = value == null ? '' : String(value);
|
||||
return `"${normalized.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
const lines = rows.map((row) => [
|
||||
row.name,
|
||||
row.email,
|
||||
row.emailDomain,
|
||||
row.plan,
|
||||
row.lifecycleStage,
|
||||
row.fitScore,
|
||||
row.intentScore,
|
||||
row.leadScore,
|
||||
row.signupSource,
|
||||
row.signupSourceSelfReported,
|
||||
row.signupCampaign,
|
||||
row.signupLandingPath,
|
||||
row.primaryUseCase,
|
||||
row.primaryGoal,
|
||||
row.jobRole,
|
||||
row.companyName,
|
||||
row.teamSizeBucket,
|
||||
row.createdAt,
|
||||
row.firstQrCreatedAt,
|
||||
row.activationAt,
|
||||
row.qrCount,
|
||||
row.dynamicQrCount,
|
||||
row.scanCount,
|
||||
].map(escape).join(','));
|
||||
|
||||
return [headers.join(','), ...lines].join('\n');
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
if (!hasAdminSession()) {
|
||||
return NextResponse.json({ error: 'Unauthorized - Admin login required' }, { status: 401 });
|
||||
}
|
||||
|
||||
const rawUsers = await db.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailDomain: true,
|
||||
plan: true,
|
||||
lifecycleStage: true,
|
||||
fitScore: true,
|
||||
intentScore: true,
|
||||
leadScore: true,
|
||||
signupSource: true,
|
||||
signupSourceSelfReported: true,
|
||||
signupCampaign: true,
|
||||
signupLandingPath: true,
|
||||
primaryUseCase: true,
|
||||
primaryGoal: true,
|
||||
jobRole: true,
|
||||
companyName: true,
|
||||
companyWebsite: true,
|
||||
teamSizeBucket: true,
|
||||
createdAt: true,
|
||||
firstQrCreatedAt: true,
|
||||
firstDynamicQrAt: true,
|
||||
activationAt: true,
|
||||
qrCodes: {
|
||||
select: {
|
||||
type: true,
|
||||
contentType: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
scans: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
const recentBillingLogs = await db.userLifecycleLog.findMany({
|
||||
where: {
|
||||
reason: {
|
||||
startsWith: 'subscription_',
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 10,
|
||||
select: {
|
||||
fromStage: true,
|
||||
toStage: true,
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
plan: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const users: HydratedUser[] = rawUsers.map((user) => {
|
||||
const metrics = getMetricSnapshot(user.qrCodes);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailDomain: user.emailDomain,
|
||||
plan: user.plan,
|
||||
lifecycleStage: user.lifecycleStage,
|
||||
fitScore: user.fitScore,
|
||||
intentScore: user.intentScore,
|
||||
leadScore: user.leadScore,
|
||||
signupSource: user.signupSource,
|
||||
signupSourceSelfReported: user.signupSourceSelfReported,
|
||||
signupCampaign: user.signupCampaign,
|
||||
signupLandingPath: user.signupLandingPath,
|
||||
primaryUseCase: user.primaryUseCase,
|
||||
primaryGoal: user.primaryGoal,
|
||||
jobRole: user.jobRole,
|
||||
companyName: user.companyName,
|
||||
companyWebsite: user.companyWebsite,
|
||||
teamSizeBucket: user.teamSizeBucket,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
firstQrCreatedAt: toIso(user.firstQrCreatedAt),
|
||||
activationAt: toIso(user.activationAt),
|
||||
firstDynamicQrAt: toIso(user.firstDynamicQrAt),
|
||||
qrCount: metrics.qrCount,
|
||||
dynamicQrCount: metrics.dynamicQrCount,
|
||||
scanCount: metrics.scanCount,
|
||||
contentTypeCount: metrics.contentTypeCount,
|
||||
upgradeBadges: getUpgradeCandidateBadges(user, metrics),
|
||||
};
|
||||
});
|
||||
|
||||
const filteredUsers = sortUsers(
|
||||
applyUserFilters(users, request),
|
||||
request.nextUrl.searchParams.get('sort') || 'leadScore_desc'
|
||||
);
|
||||
|
||||
if (request.nextUrl.searchParams.get('format') === 'csv') {
|
||||
const csv = buildCsv(filteredUsers);
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="qrmaster-revops-export.csv"',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const page = Number(request.nextUrl.searchParams.get('page') || '1');
|
||||
const pageSize = Number(request.nextUrl.searchParams.get('pageSize') || '25');
|
||||
const total = filteredUsers.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const paginatedUsers = filteredUsers.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
const acquisitionBySource = buildGroupedRows(users, 'signupSource').map((row) => ({
|
||||
...row,
|
||||
label: getSourceLabel(row.key),
|
||||
activationRate: row.signups ? Math.round((row.activated / row.signups) * 100) : 0,
|
||||
}));
|
||||
const acquisitionByCampaign = buildGroupedRows(users, 'signupCampaign');
|
||||
const acquisitionByLandingPath = buildGroupedRows(users, 'signupLandingPath');
|
||||
const funnel = buildFunnel(users);
|
||||
const lifecycleSummary = buildLifecycleSummary(users);
|
||||
|
||||
const mismatchCount = users.filter(
|
||||
(user) =>
|
||||
user.signupSource &&
|
||||
user.signupSourceSelfReported &&
|
||||
user.signupSource !== user.signupSourceSelfReported
|
||||
).length;
|
||||
|
||||
const upgradeCandidates = users
|
||||
.filter((user) => user.plan === 'FREE' && user.lifecycleStage === 'upgrade_candidate')
|
||||
.sort((a, b) => b.leadScore - a.leadScore)
|
||||
.slice(0, 25);
|
||||
|
||||
const recentBillingActivity = recentBillingLogs.map((log) => ({
|
||||
userId: log.user.id,
|
||||
name: log.user.name,
|
||||
email: log.user.email,
|
||||
plan: log.user.plan,
|
||||
reason: log.reason,
|
||||
fromStage: log.fromStage,
|
||||
toStage: log.toStage,
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
const filterOptions = {
|
||||
stages: ['cold', 'activated', 'warm', 'hot', 'upgrade_candidate', 'paid'],
|
||||
sources: Array.from(new Set(users.map((user) => user.signupSource).filter((value): value is string => Boolean(value)))),
|
||||
campaigns: Array.from(new Set(users.map((user) => user.signupCampaign).filter((value): value is string => Boolean(value)))),
|
||||
landingPaths: Array.from(new Set(users.map((user) => user.signupLandingPath).filter((value): value is string => Boolean(value)))),
|
||||
useCases: Array.from(new Set(users.map((user) => user.primaryUseCase).filter((value): value is string => Boolean(value)))),
|
||||
goals: Array.from(new Set(users.map((user) => user.primaryGoal).filter((value): value is string => Boolean(value)))),
|
||||
roles: Array.from(new Set(users.map((user) => user.jobRole).filter((value): value is string => Boolean(value)))),
|
||||
teamSizes: Array.from(new Set(users.map((user) => user.teamSizeBucket).filter((value): value is string => Boolean(value)))),
|
||||
plans: Array.from(new Set(users.map((user) => user.plan).filter((value): value is string => Boolean(value)))),
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
overview: {
|
||||
totalUsers: users.length,
|
||||
mismatchCount,
|
||||
activatedUsers: funnel.activated,
|
||||
paidUsers: lifecycleSummary.paid,
|
||||
recentBillingEvents: recentBillingActivity.length,
|
||||
},
|
||||
acquisition: {
|
||||
bySource: acquisitionBySource,
|
||||
byCampaign: acquisitionByCampaign.slice(0, 15),
|
||||
byLandingPath: acquisitionByLandingPath.slice(0, 15),
|
||||
},
|
||||
funnel,
|
||||
funnelBreakdowns: {
|
||||
bySource: acquisitionBySource.slice(0, 10),
|
||||
byUseCase: buildGroupedRows(users, 'primaryUseCase').map((row) => ({ ...row, label: getUseCaseLabel(row.key) })),
|
||||
byRole: buildGroupedRows(users, 'jobRole').map((row) => ({ ...row, label: getRoleLabel(row.key) })),
|
||||
byTeamSize: buildGroupedRows(users, 'teamSizeBucket').map((row) => ({ ...row, label: getTeamSizeLabel(row.key) })),
|
||||
},
|
||||
lifecycleSummary,
|
||||
recentBillingActivity,
|
||||
campaignSourceQuality: acquisitionBySource,
|
||||
upgradeCandidates,
|
||||
filterOptions,
|
||||
segments: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
rows: paginatedUsers.map((user) => ({
|
||||
...user,
|
||||
lifecycleStageLabel: getLifecycleStageLabel(user.lifecycleStage),
|
||||
signupSourceLabel: getSourceLabel(user.signupSource),
|
||||
signupSourceSelfReportedLabel: getSourceLabel(user.signupSourceSelfReported),
|
||||
primaryUseCaseLabel: getUseCaseLabel(user.primaryUseCase),
|
||||
primaryGoalLabel: getGoalLabel(user.primaryGoal),
|
||||
jobRoleLabel: getRoleLabel(user.jobRole),
|
||||
teamSizeLabel: getTeamSizeLabel(user.teamSizeBucket),
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching RevOps dashboard data:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch RevOps dashboard data' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user