src/app/api/flow-trace/route.tsimport { NextResponse } from 'next/server'import { DateTime, Duration } from 'luxon'import { studioUrl } from '@/utils/studio-helpers'export const runtime = 'nodejs'export const dynamic = 'force-dynamic'const zone = 'America/Chicago'/* --------------------------------------------------------* Utility: Form Data Encoding* ------------------------------------------------------ */function encodeFormData(data: Record<string, any>) {const fd = new FormData()for (const [k, v] of Object.entries(data)) {if (Array.isArray(v)) {for (const item of v) fd.append(k, item as any)} else if (v !== undefined && v !== null) {fd.append(k, v as any)}}return fd}/* --------------------------------------------------------* Utility: Duration (ms → human-readable)* ------------------------------------------------------ */function humanFromMs(ms: number): string {const dur = Duration.fromMillis(ms).shiftTo('days','hours','minutes','seconds')const parts: string[] = []if (dur.days) parts.push(`${dur.days} day${dur.days === 1 ? '' : 's'}`)if (dur.hours) parts.push(`${dur.hours} hr${dur.hours === 1 ? '' : 's'}`)if (dur.minutes)parts.push(`${dur.minutes} min${dur.minutes === 1 ? '' : 's'}`)const secs = Math.round(dur.seconds)if (secs || parts.length === 0) parts.push(`${secs}s`)return parts.join(parts.length > 2 ? ', ' : ' ')}/* --------------------------------------------------------* Utility: Device Detection* ------------------------------------------------------ */function detectDeviceType(ua: string): 'Mobile' | 'Tablet' | 'Desktop' | 'Unknown' {const agent = ua.toLowerCase()if (!agent) return 'Unknown'if (/ipad|tablet|(android(?!.*mobile))/.test(agent)) return 'Tablet'if (/mobi|iphone|ipod|android.*mobile|blackberry|phone/.test(agent))return 'Mobile'if (/windows|macintosh|linux|x11/.test(agent)) return 'Desktop'return 'Unknown'}/* --------------------------------------------------------* Utility: HTML Escaping* ------------------------------------------------------ */function escapeHtml(s: any) {return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')}/* --------------------------------------------------------* Utility: Inline Rendering* ------------------------------------------------------ */function renderInlineValue(v: unknown): string {if (v == null) return ''if (typeof v === 'string')return escapeHtml(v.trim()).replace(/\r?\n/g, '<br>')return escapeHtml(String(v))}/* --------------------------------------------------------* Utility: Key/Value Section Rendering* ------------------------------------------------------ */function renderPlainRows(data: Record<string, any>): string {return Object.entries(data).map(([k, v]) =>`<p class="row"><strong class="key">${escapeHtml(k)}:</strong> <span class="val">${renderInlineValue(v)}</span></p>`).join('\n')}function renderSection(title: string,data: Record<string, any> | null | undefined) {if (!data || Object.keys(data).length === 0) return ''return `<hr>${title? `<h3 style="font-size:1.1rem;margin:16px 0 10px;color:#222">${escapeHtml(title)}</h3>`: ''}${renderPlainRows(data)}`}/* --------------------------------------------------------* Utility: URL Helpers* ------------------------------------------------------ */function stripTrailingSlash(u: string) {return u.endsWith('/') ? u.slice(0, -1) : u}function ensureLeadingSlash(p: string) {return p.startsWith('/') ? p : `/${p}`}/* --------------------------------------------------------* Path Rendering Section* ------------------------------------------------------ */function renderPathSection(arr: Array<{ path: string; ts: string; spentMs: number; source?: string }>,baseUrl: string) {if (!Array.isArray(arr) || arr.length === 0) return ''const base = stripTrailingSlash(baseUrl || '')const rows = arr.map((p) => {const ts = p?.ts? DateTime.fromISO(escapeHtml(p.ts)).setZone(zone).toFormat('MM/dd/yy hh:mm:ss a'): ''const rawPath = p?.path || ''if (!rawPath) {return `<p class="row"><strong class="key">Time:</strong> <span class="val">${ts}</span></p>`}const safePath = escapeHtml(rawPath)const fullHref = base ? `${base}${ensureLeadingSlash(rawPath)}` : safePathconst safeHref = escapeHtml(fullHref)const source = p?.sourceconst duration = humanFromMs(p?.spentMs)return `<p class="row">${source ? `<strong class="key">Time:</strong> <span class="val">${ts}</span><br>` : ''}${!source? `<strong class="key">Duration:</strong> <span class="val">${duration}</span><br>`: ''}${source ? `<strong class="key">Referrer:</strong> ${source}<br>` : ''}<strong class="key">Path:</strong><a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safePath}</a></p>`}).join('\n')return `<hr><h3 style="font-size:1.1rem;margin:16px 0 10px;color:#222">Path</h3>${rows}`}/* --------------------------------------------------------* Email Template Renderer* ------------------------------------------------------ */function renderEmailHtml(args: {element?: Record<string, any>context?: Record<string, any>ip?: stringua?: stringdeviceType?: stringgeo?: stringtimeNow: anyfirstSeen: anysessionStart: anytotalDuration: any}) {const {element,context,ip,ua,deviceType,geo,timeNow,sessionStart,totalDuration,} = argsconst baseUrl = process.env.NEXT_PUBLIC_PRODUCTION_URL || ''// studioUrl returns an absolute CDN URL for processed images and a// root-relative path otherwise; only prepend the origin in the latter case.const logoPath = studioUrl('/logo.png', 'medium')const logoSrc = /^https?:\/\//.test(logoPath)? logoPath: `${baseUrl}${logoPath}`const event = {When: timeNow.toFormat('MM/dd/yy hh:mm:ss a'),'IP Address': ip || '','Geo Location': geo,'User-Agent': ua || '','Device Type': deviceType || '',URL: context?.page?.url || '','Session Duration': humanFromMs(timeNow.diff(sessionStart).toMillis()),'Total Duration': humanFromMs(totalDuration),}const pathFormat = Array.isArray(context?.path)? context!.path.slice(-100).reverse(): []return `<!doctype html><html><head><meta charset="utf-8" /><style>body {background-color: #f7f7f7;font-family: Arial, sans-serif;margin: 0;padding: 20px;width: 100%;}.container {background-color: #f7f7f7;max-width: 100%;margin: auto;padding: 75px 15px;text-align: center;}.content {max-width: 400px;display: inline-block;text-align: left;}.logo {display: block;margin: 0 auto 20px;max-width: 200px;padding-bottom: 20px;}.row { margin: 0 0 10px 0; line-height: 1.45; font-size: 1rem; color: #000000; }.key { font-weight: 700; margin-right: .35rem; display: inline-block; color: inherit !important; }.val { display: inline; color: inherit !important; }hr { border: none; border-top: 1px solid #000000; margin: 24px 0; }</style></head><body><div class="container"><div class="content"><img src="${logoSrc}" alt="Logo" class="logo" /><h2 style="font-size:1.25rem;margin:0 0 6px">${escapeHtml(element?.label? element.label: element?.href? 'Click - ' + element?.href: 'Click')} - ${escapeHtml(element?.innerText || element?.id || '')}</h2>${renderSection('', event)}${renderPathSection(pathFormat as any, baseUrl)}</div></div></body></html>`}/* --------------------------------------------------------* Geo IP Resolver* ------------------------------------------------------ */function resolveGeo(req: Request): string {// This site sits behind Cloudflare, which forwards the real visitor location// in its own headers (cf-ipcity / cf-region / cf-ipcountry). Requires the// "Add visitor location headers" managed transform to be enabled in// Cloudflare (Rules → Settings → Managed Transforms).const parts = [req.headers.get('cf-ipcity'),req.headers.get('cf-region'),req.headers.get('cf-ipcountry'),].filter(Boolean)return parts.join(', ')}/* --------------------------------------------------------* POST Handler* ------------------------------------------------------ */export async function POST(req: Request) {try {const body = await req.json().catch(() => ({}))const { element, context } = body || {}// Behind Cloudflare, cf-connecting-ip holds the real visitor IP (unlike// x-forwarded-for[0], which is the Cloudflare edge).const ip = req.headers.get('cf-connecting-ip')?.trim() || 'unknown'const ua = req.headers.get('user-agent') || ''const deviceType = detectDeviceType(ua)const geo = resolveGeo(req)const timeNow = DateTime.now().setZone(zone)const firstSeen = DateTime.fromISO(context.firstSeen).setZone(zone)const sessionStart = DateTime.fromISO(context.sessionStart).setZone(zone)const totalDuration = context.timeSpentTotalMsconst emailHtml = renderEmailHtml({element,context,ip,ua,deviceType,geo,timeNow,firstSeen,sessionStart,totalDuration,})const isDevelopment = process.env.NODE_ENV === 'development'const productionUrlClean = process.env.NEXT_PUBLIC_PRODUCTION_URL?.replace(/^https?:\/\//,'')const subject = `[FlowTrace] ${productionUrlClean || 'Site'}: ${timeNow}`const mailBody: Record<string, any> = {from: `${process.env.MAILGUN_FROM_NAME} <contact@${process.env.MAILGUN_DOMAIN_NAME}>`,to: isDevelopment? `${process.env.MAILGUN_DEV_NAME} <${process.env.MAILGUN_DEV_MAIL}>`: (process.env.FLOWTRACE_TO_RECIPIENTS || '').split(',').map((r) => r.trim()).filter(Boolean),subject,html: emailHtml,}const resp = await fetch(`https://api.mailgun.net/v3/${process.env.MAILGUN_DOMAIN_NAME}/messages`,{method: 'POST',headers: {Authorization: `Basic ${Buffer.from(`api:${process.env.MAILGUN_API_KEY}`).toString('base64')}`,},body: encodeFormData(mailBody),})const data = await resp.text()const ok =data !== 'Forbidden' &&(() => {try {return JSON.parse(data).id} catch {return null}})()return NextResponse.json({ message: ok ? 'FlowTrace sent.' : 'An error occurred.' },{ status: ok ? 200 : 500 })} catch {return NextResponse.json({ message: 'An error occurred.' }, { status: 500 })}}