diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..9971705 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Skill", + "hooks": [ + { + "type": "command", + "command": "'C:\\Users\\timo\\Documents\\qrmaster\\QR-master\\.codex\\hooks\\check-gstack.sh'" + } + ] + } + ] + } +} diff --git a/.codex/hooks/check-gstack.sh b/.codex/hooks/check-gstack.sh new file mode 100644 index 0000000..23b9b7f --- /dev/null +++ b/.codex/hooks/check-gstack.sh @@ -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 '{}' diff --git a/.gitignore b/.gitignore index f6fcc0c..b8f7f1c 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,7 @@ logs # project-specific Leads/ -marketing/ +/marketing/ output/ remotion/ diff --git a/public/marketing/use-cases/business-card-qr-codes.png b/public/marketing/use-cases/business-card-qr-codes.png new file mode 100644 index 0000000..e9d81c2 Binary files /dev/null and b/public/marketing/use-cases/business-card-qr-codes.png differ diff --git a/public/marketing/use-cases/coupon-qr-codes.png b/public/marketing/use-cases/coupon-qr-codes.png new file mode 100644 index 0000000..86c90d4 Binary files /dev/null and b/public/marketing/use-cases/coupon-qr-codes.png differ diff --git a/public/marketing/use-cases/event-qr-codes.png b/public/marketing/use-cases/event-qr-codes.png new file mode 100644 index 0000000..101c262 Binary files /dev/null and b/public/marketing/use-cases/event-qr-codes.png differ diff --git a/public/marketing/use-cases/feedback-qr-codes.png b/public/marketing/use-cases/feedback-qr-codes.png new file mode 100644 index 0000000..fcc013b Binary files /dev/null and b/public/marketing/use-cases/feedback-qr-codes.png differ diff --git a/public/marketing/use-cases/flyer-qr-codes.png b/public/marketing/use-cases/flyer-qr-codes.png new file mode 100644 index 0000000..de2cd26 Binary files /dev/null and b/public/marketing/use-cases/flyer-qr-codes.png differ diff --git a/public/marketing/use-cases/hotel-welcome-qr-codes.png b/public/marketing/use-cases/hotel-welcome-qr-codes.png new file mode 100644 index 0000000..d9fda9b Binary files /dev/null and b/public/marketing/use-cases/hotel-welcome-qr-codes.png differ diff --git a/public/marketing/use-cases/packaging-qr-codes.png b/public/marketing/use-cases/packaging-qr-codes.png new file mode 100644 index 0000000..4b2a6df Binary files /dev/null and b/public/marketing/use-cases/packaging-qr-codes.png differ diff --git a/public/marketing/use-cases/payment-qr-codes.png b/public/marketing/use-cases/payment-qr-codes.png new file mode 100644 index 0000000..1f6067c Binary files /dev/null and b/public/marketing/use-cases/payment-qr-codes.png differ diff --git a/public/marketing/use-cases/qr-codes-for-barbershops.png b/public/marketing/use-cases/qr-codes-for-barbershops.png new file mode 100644 index 0000000..0ad180a Binary files /dev/null and b/public/marketing/use-cases/qr-codes-for-barbershops.png differ diff --git a/public/marketing/use-cases/qr-codes-for-hotel.png b/public/marketing/use-cases/qr-codes-for-hotel.png new file mode 100644 index 0000000..d9fda9b Binary files /dev/null and b/public/marketing/use-cases/qr-codes-for-hotel.png differ diff --git a/public/marketing/use-cases/qr-codes-for-review-collection.png b/public/marketing/use-cases/qr-codes-for-review-collection.png new file mode 100644 index 0000000..87a804f Binary files /dev/null and b/public/marketing/use-cases/qr-codes-for-review-collection.png differ diff --git a/public/marketing/use-cases/real-estate-sign-qr-codes.png b/public/marketing/use-cases/real-estate-sign-qr-codes.png new file mode 100644 index 0000000..c82d881 Binary files /dev/null and b/public/marketing/use-cases/real-estate-sign-qr-codes.png differ diff --git a/public/marketing/use-cases/restaurant-menu-qr-codes.png b/public/marketing/use-cases/restaurant-menu-qr-codes.png new file mode 100644 index 0000000..0b6ce17 Binary files /dev/null and b/public/marketing/use-cases/restaurant-menu-qr-codes.png differ diff --git a/public/marketing/use-cases/salon-barbershop-qr-codes.png b/public/marketing/use-cases/salon-barbershop-qr-codes.png new file mode 100644 index 0000000..058767b Binary files /dev/null and b/public/marketing/use-cases/salon-barbershop-qr-codes.png differ diff --git a/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx b/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx index 669cd9d..4e6d50a 100644 --- a/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx +++ b/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx @@ -1,908 +1,908 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { Badge } from '@/components/ui/Badge'; -import { Button } from '@/components/ui/Button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; -import { Input } from '@/components/ui/Input'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/Table'; -import { - BarChart3, - Copy, - CreditCard, - Download, - Filter, - Loader2, - Lock, - LogOut, - Mail, - Search, - TrendingUp, - Users, -} from 'lucide-react'; -import { - getGoalLabel, - getRoleLabel, - getSourceLabel, - getTeamSizeLabel, - getUseCaseLabel, -} from '@/lib/revops'; - -type SegmentRow = { - id: string; - name: string | null; - email: string; - emailDomain: string | null; - plan: string; - lifecycleStage: string; - lifecycleStageLabel: string; - fitScore: number; - intentScore: number; - leadScore: number; - signupSource: string | null; - signupSourceLabel: string; - signupSourceSelfReported: string | null; - signupSourceSelfReportedLabel: string; - signupCampaign: string | null; - signupLandingPath: string | null; - primaryUseCase: string | null; - primaryUseCaseLabel: string; - primaryGoal: string | null; - primaryGoalLabel: string; - jobRole: string | null; - jobRoleLabel: string; - companyName: string | null; - teamSizeBucket: string | null; - teamSizeLabel: string; - upgradeBadges: string[]; - createdAt: string; - firstQrCreatedAt: string | null; - activationAt: string | null; - qrCount: number; - dynamicQrCount: number; - scanCount: number; - lifecycleLogs: Array<{ - fromStage: string | null; - toStage: string; - reason: string | null; - createdAt: string; - }>; -}; - -type DashboardData = { - overview: { - totalUsers: number; - mismatchCount: number; - activatedUsers: number; - paidUsers: number; - recentBillingEvents: number; - }; - acquisition: { - bySource: Array<{ - key: string; - label: string; - signups: number; - firstQr: number; - activated: number; - hot: number; - upgradeCandidates: number; - paid: number; - activationRate: number; - }>; - byCampaign: Array<{ - key: string; - signups: number; - activated: number; - paid: number; - }>; - byLandingPath: Array<{ - key: string; - signups: number; - activated: number; - paid: number; - }>; - }; - funnel: { - signup: number; - sourceConfirmed: number; - useCaseSelected: number; - goalSelected: number; - profileCaptured: number; - firstQrCreated: number; - firstDynamicQrCreated: number; - activated: number; - }; - funnelBreakdowns: { - bySource: Array; - byUseCase: Array; - byRole: Array; - byTeamSize: Array; - }; - lifecycleSummary: Record; - recentBillingActivity: Array<{ - userId: string; - name: string | null; - email: string; - plan: string; - reason: string | null; - fromStage: string | null; - toStage: string; - createdAt: string; - }>; - campaignSourceQuality: Array; - upgradeCandidates: Array; - filterOptions: { - stages: string[]; - sources: string[]; - campaigns: string[]; - landingPaths: string[]; - useCases: string[]; - goals: string[]; - roles: string[]; - teamSizes: string[]; - plans: string[]; - }; - segments: { - total: number; - page: number; - pageSize: number; - totalPages: number; - rows: SegmentRow[]; - }; -}; - -type Filters = { - stage: string; - source: string; - campaign: string; - landingPath: string; - useCase: string; - goal: string; - role: string; - teamSize: string; - plan: string; - search: string; - sort: string; - page: number; -}; - -const defaultFilters: Filters = { - stage: '', - source: '', - campaign: '', - landingPath: '', - useCase: '', - goal: '', - role: '', - teamSize: '', - plan: '', - search: '', - sort: 'leadScore_desc', - page: 1, -}; - -function buildQuery(filters: Filters) { - const params = new URLSearchParams(); - - Object.entries(filters).forEach(([key, value]) => { - if (value) { - params.set(key, String(value)); - } - }); - - params.set('pageSize', '25'); - return params.toString(); -} - -function formatStage(value: string | null) { - return value ? value.replace(/_/g, ' ') : 'none'; -} - -function formatBillingReason(value: string | null) { - switch (value) { - case 'subscription_created': - return 'Started paid plan'; - case 'subscription_updated': - return 'Updated subscription'; - case 'subscription_canceled_at_period_end': - return 'Cancels at period end'; - case 'subscription_deleted': - return 'Ended subscription'; - case 'subscription_synced': - return 'Synced subscription'; - default: - return 'Subscription changed'; - } -} - -function LifecycleCard({ - label, - value, - active, - onClick, -}: { - label: string; - value: number; - active: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -function BreakdownTable({ - title, - rows, -}: { - title: string; - rows: Array<{ label?: string; key: string; signups: number; activated: number; paid: number }>; -}) { - return ( - - - {title} - - - - - - Segment - Signups - Activated - Paid - - - - {rows.slice(0, 8).map((row) => ( - - {row.label || row.key} - {row.signups} - {row.activated} - {row.paid} - - ))} - -
-
-
- ); -} - -export default function NewsletterClient() { - const router = useRouter(); - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [isAuthenticating, setIsAuthenticating] = useState(true); - const [loginError, setLoginError] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [filters, setFilters] = useState(defaultFilters); - - const queryString = useMemo(() => buildQuery(filters), [filters]); - - const fetchDashboard = async (query: string) => { - setLoading(true); - try { - const response = await fetch(`/api/admin/revops?${query}`); - if (!response.ok) { - if (response.status === 401) { - setIsAuthenticated(false); - return; - } - throw new Error('Failed to load dashboard'); - } - - const payload = await response.json(); - setData(payload); - setIsAuthenticated(true); - } catch (error) { - console.error(error); - } finally { - setLoading(false); - setIsAuthenticating(false); - } - }; - - useEffect(() => { - fetchDashboard(queryString); - }, [queryString]); - - const handleLogin = async (event: React.FormEvent) => { - event.preventDefault(); - setLoginError(''); - setIsAuthenticating(true); - - try { - const response = await fetch('/api/newsletter/admin-login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email, password }), - }); - - if (!response.ok) { - const body = await response.json(); - setLoginError(body.error || 'Invalid credentials'); - setIsAuthenticating(false); - return; - } - - await fetchDashboard(queryString); - } catch (error) { - setLoginError('Login failed. Please try again.'); - setIsAuthenticating(false); - } - }; - - const handleLogout = async () => { - await fetch('/api/auth/logout', { method: 'POST' }); - setIsAuthenticated(false); - setData(null); - router.refresh(); - }; - - const updateFilter = (key: keyof Filters, value: string | number) => { - setFilters((current) => ({ - ...current, - [key]: value, - page: key === 'page' ? Number(value) : 1, - })); - }; - - const resetFilters = () => setFilters(defaultFilters); - - const copyEmails = async () => { - if (!data?.segments.rows.length) return; - const text = data.segments.rows.map((row) => row.email).join(', '); - await navigator.clipboard.writeText(text); - }; - - const downloadCsv = () => { - window.location.href = `/api/admin/revops?${queryString}&format=csv`; - }; - - if (!isAuthenticated) { - return ( -
-
- - -
- -
- QR Master Ops Cockpit -

Internal access only.

-
- -
- setEmail(event.target.value)} - autoComplete="off" - required - /> - setPassword(event.target.value)} - autoComplete="new-password" - required - /> - {loginError &&

{loginError}

} - -
-
-
-
-
- ); - } - - if (!data) { - return ( -
- -
- ); - } - - const stageCards = [ - { key: 'cold', label: 'Cold' }, - { key: 'activated', label: 'Activated' }, - { key: 'warm', label: 'Warm' }, - { key: 'hot', label: 'Hot' }, - { key: 'upgrade_candidate', label: 'Upgrade Candidate' }, - { key: 'paid', label: 'Paid' }, - ]; - - const funnelSteps = [ - ['Signup', data.funnel.signup], - ['Source confirmed', data.funnel.sourceConfirmed], - ['Use case selected', data.funnel.useCaseSelected], - ['Goal selected', data.funnel.goalSelected], - ['Role/company/team captured', data.funnel.profileCaptured], - ['First QR created', data.funnel.firstQrCreated], - ['First dynamic QR created', data.funnel.firstDynamicQrCreated], - ['Activated', data.funnel.activated], - ]; - - return ( -
-
-
-
-

Ops Cockpit

-

- Attribution, onboarding funnel, lifecycle quality, and filtered segments for QR Master. -

-
- -
- -
- - -
-
-

Total users

-

{data.overview.totalUsers}

-
- -
-
-
- - -
-
-

Activated users

-

{data.overview.activatedUsers}

-
- -
-
-
- - -
-
-

Paid users

-

{data.overview.paidUsers}

-
- -
-
-
- - -
-
-

Tracked vs self-reported mismatch

-

{data.overview.mismatchCount}

-
- -
-
-
-
- - - -
- - Recent Billing Activity -
-
- - {data.recentBillingActivity.length > 0 ? ( - - - - User - Plan - Action - Lifecycle change - Date - - - - {data.recentBillingActivity.map((event) => ( - - -
-
{event.name || event.email}
-
{event.email}
-
-
- {event.plan} - {formatBillingReason(event.reason)} - - {formatStage(event.fromStage)} -> {formatStage(event.toStage)} - - {new Date(event.createdAt).toLocaleString()} -
- ))} -
-
- ) : ( -

No billing activity logged yet.

- )} -
-
- -
- - - Acquisition Overview - - - - - - Source - Signups - First QR - Activated - Hot - Upgrade - Paid - Activation rate - - - - {data.acquisition.bySource.map((row) => ( - - {row.label} - {row.signups} - {row.firstQr} - {row.activated} - {row.hot} - {row.upgradeCandidates} - {row.paid} - {row.activationRate}% - - ))} - -
-
-
- -
- ({ ...row, label: row.key || 'unknown' }))} /> - ({ ...row, label: row.key || 'unknown' }))} /> -
-
- -
- - - Onboarding Funnel - - - {funnelSteps.map(([label, value]) => { - const percentage = data.funnel.signup ? Math.round((Number(value) / data.funnel.signup) * 100) : 0; - return ( -
-
- {label} - {value} ({percentage}%) -
-
-
-
-
- ); - })} - - -
- - - - -
-
- -
-
-

Lifecycle Summary

-

Click a card to open the filtered list below.

-
-
- {stageCards.map((card) => ( - updateFilter('stage', filters.stage === card.key ? '' : card.key)} - /> - ))} -
-
- - - -
-
- Full Segment Lists -

- Filter and export cold, activated, warm, hot, upgrade_candidate, and paid users. -

-
-
- - -
-
-
- -
-
- -
- - updateFilter('search', event.target.value)} - placeholder="Email, name, company" - className="h-11 w-full rounded-xl border border-slate-300 bg-white pl-10 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" - /> -
-
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - - {data.segments.total} matching users - - {loading && } -
- - - - - Name - Email - Domain - Plan - Stage - Fit - Intent - Lead - Source - Self-reported - Campaign - Landing page - Use case - Goal - Role - Company - Team - Created - First QR - Activated - QRs - Dynamic - Scans - - - - {data.segments.rows.map((row) => ( - - {row.name || '—'} - {row.email} - {row.emailDomain || '—'} - {row.plan} - {row.lifecycleStageLabel} - {row.fitScore} - {row.intentScore} - {row.leadScore} - {row.signupSourceLabel} - {row.signupSourceSelfReportedLabel} - {row.signupCampaign || '—'} - {row.signupLandingPath || '—'} - {row.primaryUseCaseLabel} - {row.primaryGoalLabel} - {row.jobRoleLabel} - {row.companyName || '—'} - {row.teamSizeLabel} - {new Date(row.createdAt).toLocaleDateString()} - {row.firstQrCreatedAt ? new Date(row.firstQrCreatedAt).toLocaleDateString() : '—'} - {row.activationAt ? new Date(row.activationAt).toLocaleDateString() : '—'} - {row.qrCount} - {row.dynamicQrCount} - {row.scanCount} - - ))} - -
- -
- - Page {data.segments.page} of {data.segments.totalPages} - -
- - -
-
-
-
- - - - Upgrade Candidates -

- Free users with strong fit and clear commercial intent. -

-
- - - - - User - Lead score - Use case - Role - Company - Reasons - - - - {data.upgradeCandidates.map((row) => ( - - -
-
{row.name || row.email}
-
{row.email}
-
-
- {row.leadScore} - {getUseCaseLabel(row.primaryUseCase)} - {getRoleLabel(row.jobRole)} - {row.companyName || '—'} - -
- {row.upgradeBadges.map((badge) => ( - {badge} - ))} -
-
-
- ))} -
-
-
-
-
-
- ); -} +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; +import { Input } from '@/components/ui/Input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/Table'; +import { + BarChart3, + Copy, + CreditCard, + Download, + Filter, + Loader2, + Lock, + LogOut, + Mail, + Search, + TrendingUp, + Users, +} from 'lucide-react'; +import { + getGoalLabel, + getRoleLabel, + getSourceLabel, + getTeamSizeLabel, + getUseCaseLabel, +} from '@/lib/revops'; + +type SegmentRow = { + id: string; + name: string | null; + email: string; + emailDomain: string | null; + plan: string; + lifecycleStage: string; + lifecycleStageLabel: string; + fitScore: number; + intentScore: number; + leadScore: number; + signupSource: string | null; + signupSourceLabel: string; + signupSourceSelfReported: string | null; + signupSourceSelfReportedLabel: string; + signupCampaign: string | null; + signupLandingPath: string | null; + primaryUseCase: string | null; + primaryUseCaseLabel: string; + primaryGoal: string | null; + primaryGoalLabel: string; + jobRole: string | null; + jobRoleLabel: string; + companyName: string | null; + teamSizeBucket: string | null; + teamSizeLabel: string; + upgradeBadges: string[]; + createdAt: string; + firstQrCreatedAt: string | null; + activationAt: string | null; + qrCount: number; + dynamicQrCount: number; + scanCount: number; + lifecycleLogs: Array<{ + fromStage: string | null; + toStage: string; + reason: string | null; + createdAt: string; + }>; +}; + +type DashboardData = { + overview: { + totalUsers: number; + mismatchCount: number; + activatedUsers: number; + paidUsers: number; + recentBillingEvents: number; + }; + acquisition: { + bySource: Array<{ + key: string; + label: string; + signups: number; + firstQr: number; + activated: number; + hot: number; + upgradeCandidates: number; + paid: number; + activationRate: number; + }>; + byCampaign: Array<{ + key: string; + signups: number; + activated: number; + paid: number; + }>; + byLandingPath: Array<{ + key: string; + signups: number; + activated: number; + paid: number; + }>; + }; + funnel: { + signup: number; + sourceConfirmed: number; + useCaseSelected: number; + goalSelected: number; + profileCaptured: number; + firstQrCreated: number; + firstDynamicQrCreated: number; + activated: number; + }; + funnelBreakdowns: { + bySource: Array; + byUseCase: Array; + byRole: Array; + byTeamSize: Array; + }; + lifecycleSummary: Record; + recentBillingActivity: Array<{ + userId: string; + name: string | null; + email: string; + plan: string; + reason: string | null; + fromStage: string | null; + toStage: string; + createdAt: string; + }>; + campaignSourceQuality: Array; + upgradeCandidates: Array; + filterOptions: { + stages: string[]; + sources: string[]; + campaigns: string[]; + landingPaths: string[]; + useCases: string[]; + goals: string[]; + roles: string[]; + teamSizes: string[]; + plans: string[]; + }; + segments: { + total: number; + page: number; + pageSize: number; + totalPages: number; + rows: SegmentRow[]; + }; +}; + +type Filters = { + stage: string; + source: string; + campaign: string; + landingPath: string; + useCase: string; + goal: string; + role: string; + teamSize: string; + plan: string; + search: string; + sort: string; + page: number; +}; + +const defaultFilters: Filters = { + stage: '', + source: '', + campaign: '', + landingPath: '', + useCase: '', + goal: '', + role: '', + teamSize: '', + plan: '', + search: '', + sort: 'leadScore_desc', + page: 1, +}; + +function buildQuery(filters: Filters) { + const params = new URLSearchParams(); + + Object.entries(filters).forEach(([key, value]) => { + if (value) { + params.set(key, String(value)); + } + }); + + params.set('pageSize', '25'); + return params.toString(); +} + +function formatStage(value: string | null) { + return value ? value.replace(/_/g, ' ') : 'none'; +} + +function formatBillingReason(value: string | null) { + switch (value) { + case 'subscription_created': + return 'Started paid plan'; + case 'subscription_updated': + return 'Updated subscription'; + case 'subscription_canceled_at_period_end': + return 'Cancels at period end'; + case 'subscription_deleted': + return 'Ended subscription'; + case 'subscription_synced': + return 'Synced subscription'; + default: + return 'Subscription changed'; + } +} + +function LifecycleCard({ + label, + value, + active, + onClick, +}: { + label: string; + value: number; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function BreakdownTable({ + title, + rows, +}: { + title: string; + rows: Array<{ label?: string; key: string; signups: number; activated: number; paid: number }>; +}) { + return ( + + + {title} + + + + + + Segment + Signups + Activated + Paid + + + + {rows.slice(0, 8).map((row) => ( + + {row.label || row.key} + {row.signups} + {row.activated} + {row.paid} + + ))} + +
+
+
+ ); +} + +export default function NewsletterClient() { + const router = useRouter(); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isAuthenticating, setIsAuthenticating] = useState(true); + const [loginError, setLoginError] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [filters, setFilters] = useState(defaultFilters); + + const queryString = useMemo(() => buildQuery(filters), [filters]); + + const fetchDashboard = async (query: string) => { + setLoading(true); + try { + const response = await fetch(`/api/admin/revops?${query}`); + if (!response.ok) { + if (response.status === 401) { + setIsAuthenticated(false); + return; + } + throw new Error('Failed to load dashboard'); + } + + const payload = await response.json(); + setData(payload); + setIsAuthenticated(true); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + setIsAuthenticating(false); + } + }; + + useEffect(() => { + fetchDashboard(queryString); + }, [queryString]); + + const handleLogin = async (event: React.FormEvent) => { + event.preventDefault(); + setLoginError(''); + setIsAuthenticating(true); + + try { + const response = await fetch('/api/newsletter/admin-login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + const body = await response.json(); + setLoginError(body.error || 'Invalid credentials'); + setIsAuthenticating(false); + return; + } + + await fetchDashboard(queryString); + } catch (error) { + setLoginError('Login failed. Please try again.'); + setIsAuthenticating(false); + } + }; + + const handleLogout = async () => { + await fetch('/api/auth/logout', { method: 'POST' }); + setIsAuthenticated(false); + setData(null); + router.refresh(); + }; + + const updateFilter = (key: keyof Filters, value: string | number) => { + setFilters((current) => ({ + ...current, + [key]: value, + page: key === 'page' ? Number(value) : 1, + })); + }; + + const resetFilters = () => setFilters(defaultFilters); + + const copyEmails = async () => { + if (!data?.segments.rows.length) return; + const text = data.segments.rows.map((row) => row.email).join(', '); + await navigator.clipboard.writeText(text); + }; + + const downloadCsv = () => { + window.location.href = `/api/admin/revops?${queryString}&format=csv`; + }; + + if (!isAuthenticated) { + return ( +
+
+ + +
+ +
+ QR Master Ops Cockpit +

Internal access only.

+
+ +
+ setEmail(event.target.value)} + autoComplete="off" + required + /> + setPassword(event.target.value)} + autoComplete="new-password" + required + /> + {loginError &&

{loginError}

} + +
+
+
+
+
+ ); + } + + if (!data) { + return ( +
+ +
+ ); + } + + const stageCards = [ + { key: 'cold', label: 'Cold' }, + { key: 'activated', label: 'Activated' }, + { key: 'warm', label: 'Warm' }, + { key: 'hot', label: 'Hot' }, + { key: 'upgrade_candidate', label: 'Upgrade Candidate' }, + { key: 'paid', label: 'Paid' }, + ]; + + const funnelSteps = [ + ['Signup', data.funnel.signup], + ['Source confirmed', data.funnel.sourceConfirmed], + ['Use case selected', data.funnel.useCaseSelected], + ['Goal selected', data.funnel.goalSelected], + ['Role/company/team captured', data.funnel.profileCaptured], + ['First QR created', data.funnel.firstQrCreated], + ['First dynamic QR created', data.funnel.firstDynamicQrCreated], + ['Activated', data.funnel.activated], + ]; + + return ( +
+
+
+
+

Ops Cockpit

+

+ Attribution, onboarding funnel, lifecycle quality, and filtered segments for QR Master. +

+
+ +
+ +
+ + +
+
+

Total users

+

{data.overview.totalUsers}

+
+ +
+
+
+ + +
+
+

Activated users

+

{data.overview.activatedUsers}

+
+ +
+
+
+ + +
+
+

Paid users

+

{data.overview.paidUsers}

+
+ +
+
+
+ + +
+
+

Tracked vs self-reported mismatch

+

{data.overview.mismatchCount}

+
+ +
+
+
+
+ + + +
+ + Recent Billing Activity +
+
+ + {data.recentBillingActivity.length > 0 ? ( + + + + User + Plan + Action + Lifecycle change + Date + + + + {data.recentBillingActivity.map((event) => ( + + +
+
{event.name || event.email}
+
{event.email}
+
+
+ {event.plan} + {formatBillingReason(event.reason)} + + {formatStage(event.fromStage)} -> {formatStage(event.toStage)} + + {new Date(event.createdAt).toLocaleString()} +
+ ))} +
+
+ ) : ( +

No billing activity logged yet.

+ )} +
+
+ +
+ + + Acquisition Overview + + + + + + Source + Signups + First QR + Activated + Hot + Upgrade + Paid + Activation rate + + + + {data.acquisition.bySource.map((row) => ( + + {row.label} + {row.signups} + {row.firstQr} + {row.activated} + {row.hot} + {row.upgradeCandidates} + {row.paid} + {row.activationRate}% + + ))} + +
+
+
+ +
+ ({ ...row, label: row.key || 'unknown' }))} /> + ({ ...row, label: row.key || 'unknown' }))} /> +
+
+ +
+ + + Onboarding Funnel + + + {funnelSteps.map(([label, value]) => { + const percentage = data.funnel.signup ? Math.round((Number(value) / data.funnel.signup) * 100) : 0; + return ( +
+
+ {label} + {value} ({percentage}%) +
+
+
+
+
+ ); + })} + + +
+ + + + +
+
+ +
+
+

Lifecycle Summary

+

Click a card to open the filtered list below.

+
+
+ {stageCards.map((card) => ( + updateFilter('stage', filters.stage === card.key ? '' : card.key)} + /> + ))} +
+
+ + + +
+
+ Full Segment Lists +

+ Filter and export cold, activated, warm, hot, upgrade_candidate, and paid users. +

+
+
+ + +
+
+
+ +
+
+ +
+ + updateFilter('search', event.target.value)} + placeholder="Email, name, company" + className="h-11 w-full rounded-xl border border-slate-300 bg-white pl-10 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" + /> +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + {data.segments.total} matching users + + {loading && } +
+ + + + + Name + Email + Domain + Plan + Stage + Fit + Intent + Lead + Source + Self-reported + Campaign + Landing page + Use case + Goal + Role + Company + Team + Created + First QR + Activated + QRs + Dynamic + Scans + + + + {data.segments.rows.map((row) => ( + + {row.name || '—'} + {row.email} + {row.emailDomain || '—'} + {row.plan} + {row.lifecycleStageLabel} + {row.fitScore} + {row.intentScore} + {row.leadScore} + {row.signupSourceLabel} + {row.signupSourceSelfReportedLabel} + {row.signupCampaign || '—'} + {row.signupLandingPath || '—'} + {row.primaryUseCaseLabel} + {row.primaryGoalLabel} + {row.jobRoleLabel} + {row.companyName || '—'} + {row.teamSizeLabel} + {new Date(row.createdAt).toLocaleDateString()} + {row.firstQrCreatedAt ? new Date(row.firstQrCreatedAt).toLocaleDateString() : '—'} + {row.activationAt ? new Date(row.activationAt).toLocaleDateString() : '—'} + {row.qrCount} + {row.dynamicQrCount} + {row.scanCount} + + ))} + +
+ +
+ + Page {data.segments.page} of {data.segments.totalPages} + +
+ + +
+
+
+
+ + + + Upgrade Candidates +

+ Free users with strong fit and clear commercial intent. +

+
+ + + + + User + Lead score + Use case + Role + Company + Reasons + + + + {data.upgradeCandidates.map((row) => ( + + +
+
{row.name || row.email}
+
{row.email}
+
+
+ {row.leadScore} + {getUseCaseLabel(row.primaryUseCase)} + {getRoleLabel(row.jobRole)} + {row.companyName || '—'} + +
+ {row.upgradeBadges.map((badge) => ( + {badge} + ))} +
+
+
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/src/app/(main)/(marketing)/pricing/PricingClient.tsx b/src/app/(main)/(marketing)/pricing/PricingClient.tsx index dd51072..6a007c8 100644 --- a/src/app/(main)/(marketing)/pricing/PricingClient.tsx +++ b/src/app/(main)/(marketing)/pricing/PricingClient.tsx @@ -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(null); - const [currentPlan, setCurrentPlan] = useState('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 ( -
-
-

- Choose Your Plan -

-

- Select the perfect plan for your QR code needs -

-
- -
- -
- -
- {plans.map((plan) => ( - - {plan.popular && ( -
- - Most Popular - -
- )} - - - {plan.name} -
-
- {plan.price} - {plan.period} -
- {plan.showDiscount && ( - - Save 16% - - )} -
-
- - -
    - {plan.features.map((feature: string, index: number) => ( -
  • - - - - {feature} -
  • - ))} -
- - -
-
- ))} -
- -
-

- All plans include unlimited static QR codes and basic customization. -

-

- Need help choosing?{' '} - - Contact our team - -

-
-
- ); -} +'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(null); + const [currentPlan, setCurrentPlan] = useState('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 ( +
+
+

+ Choose Your Plan +

+

+ Select the perfect plan for your QR code needs +

+
+ +
+ +
+ +
+ {plans.map((plan) => ( + + {plan.popular && ( +
+ + Most Popular + +
+ )} + + + {plan.name} +
+
+ {plan.price} + {plan.period} +
+ {plan.showDiscount && ( + + Save 16% + + )} +
+
+ + +
    + {plan.features.map((feature: string, index: number) => ( +
  • + + + + {feature} +
  • + ))} +
+ + +
+
+ ))} +
+ +
+

+ All plans include unlimited static QR codes and basic customization. +

+

+ Need help choosing?{' '} + + Contact our team + +

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