diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af6121a5d..ef0a46a40 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,13 +61,13 @@ bun run dev:dashboard 8. Seed the database with sample data (optional): ```bash -bun run db:seed [EVENT_COUNT] +bun run db:seed [EVENT_COUNT] [--revenue] ``` -**Examples:** - +Example: ```bash -bun run db:seed g0zlgMtBaXzIP1EGY2ieG 10000 +bun run db:seed g0zlgMtBaXzIP1EGY2ieG 10000 --revenue +``` bun run db:seed d7zlgMtBaSzIL1EGR2ieR 5000 ``` @@ -76,6 +76,7 @@ bun run db:seed d7zlgMtBaSzIL1EGR2ieR 5000 - Default event count is 10,000 if not specified - Seeds events, outgoing links, errors, and web vitals data - You can find your website ID in your website overview settings +- Pass revenu flag to generate revenu ## 💻 Development diff --git a/apps/api/src/query/builders/revenue.ts b/apps/api/src/query/builders/revenue.ts index 935da0660..022f6b437 100644 --- a/apps/api/src/query/builders/revenue.ts +++ b/apps/api/src/query/builders/revenue.ts @@ -221,50 +221,139 @@ const ATTRIBUTION_CTE = ` export const RevenueBuilders: Record = { revenue_overview: { - customSql: (websiteId: string, startDate: string, endDate: string) => ({ - sql: ` - WITH ${ATTRIBUTION_CTE} - SELECT - sumIf(amount, type != 'refund') as total_revenue, - countIf(type != 'refund') as total_transactions, - sumIf(amount, type = 'refund') as refund_amount, - countIf(type = 'refund') as refund_count, - sumIf(amount, type = 'subscription') as subscription_revenue, - countIf(type = 'subscription') as subscription_count, - sumIf(amount, type = 'sale') as sale_revenue, - countIf(type = 'sale') as sale_count, - uniq(r_customer_id) as unique_customers, - countIf(is_attributed = 1 AND type != 'refund') as attributed_transactions, - sumIf(amount, is_attributed = 1 AND type != 'refund') as attributed_revenue - FROM revenue_attributed - `, - params: { websiteId, startDate, endDate }, - }), + meta: { + title: "Revenue Overview", + description: "Total revenue and transaction metrics.", + category: "Revenue", + tags: ["overview", "revenue", "transactions"], + output_fields: [ + { name: "total_revenue", type: "number", label: "Total Revenue" }, + { name: "total_transactions", type: "number", label: "Total Transactions" }, + { name: "refund_amount", type: "number", label: "Refund Amount" }, + { name: "refund_count", type: "number", label: "Refund Count" }, + { name: "unique_customers", type: "number", label: "Unique Customers" }, + { name: "attributed_revenue", type: "number", label: "Attributed Revenue" }, + { name: "attributed_transactions", type: "number", label: "Attributed Transactions" }, + { name: "total_visitors", type: "number", label: "Total Visitors" }, + ], + default_visualization: "metric", + supports_granularity: [], + version: "1.0", + }, + customSql: ( + websiteId: string, + startDate: string, + endDate: string, + _filters?: Filter[], + _granularity?: TimeUnit, + _limit?: number, + _offset?: number, + timezone?: string + ) => { + const tz = timezone || "UTC"; + return { + sql: ` + WITH + ${ATTRIBUTION_CTE}, + visitors AS ( + SELECT uniq(anonymous_id) as total_visitors + FROM analytics.events + WHERE client_id = {websiteId:String} + AND time >= toDateTime({startDate:String}) + AND time <= toDateTime(concat({endDate:String}, ' 23:59:59')) + ) + SELECT + sumIf(amount, type != 'refund') as total_revenue, + countIf(type != 'refund') as total_transactions, + sumIf(amount, type = 'refund') as refund_amount, + countIf(type = 'refund') as refund_count, + sumIf(amount, type = 'subscription') as subscription_revenue, + countIf(type = 'subscription') as subscription_count, + sumIf(amount, type = 'sale') as sale_revenue, + countIf(type = 'sale') as sale_count, + uniq(r_customer_id) as unique_customers, + countIf(is_attributed = 1 AND type != 'refund') as attributed_transactions, + sumIf(amount, is_attributed = 1 AND type != 'refund') as attributed_revenue, + any(total_visitors) as total_visitors + FROM revenue_attributed + CROSS JOIN visitors + `, + params: { websiteId, startDate, endDate, timezone: tz }, + }; + }, timeField: "created", customizable: false, }, revenue_time_series: { - customSql: (websiteId: string, startDate: string, endDate: string) => ({ - sql: ` - WITH ${ATTRIBUTION_CTE} + meta: { + title: "Revenue Time Series", + description: "Daily or hourly breakdown of revenue metrics.", + category: "Revenue", + tags: ["timeseries", "revenue", "trends"], + output_fields: [ + { name: "date", type: "datetime", label: "Date" }, + { name: "revenue", type: "number", label: "Revenue" }, + { name: "transactions", type: "number", label: "Transactions" }, + { name: "customers", type: "number", label: "Customers" }, + ], + default_visualization: "timeseries", + supports_granularity: ["hour", "day"], + version: "1.0", + }, + customSql: ( + websiteId: string, + startDate: string, + endDate: string, + _filters?: Filter[], + _granularity?: TimeUnit, + _limit?: number, + _offset?: number, + timezone?: string + ) => { + const tz = timezone || "UTC"; + const isHourly = _granularity === "hour" || _granularity === "hourly"; + const timeBucketFn = isHourly ? "toStartOfHour" : "toDate"; + const dateFormat = isHourly + ? "formatDateTime(time_bucket, '%Y-%m-%d %H:00:00')" + : "toDate(time_bucket)"; + + return { + sql: ` + WITH + ${ATTRIBUTION_CTE}, + base_revenue AS ( + SELECT + toTimeZone(created, {timezone:String}) as normalized_time, + amount, + type, + r_customer_id, + is_attributed + FROM revenue_attributed + ), + revenue_agg AS ( + SELECT + ${timeBucketFn}(normalized_time) as time_bucket, + sumIf(amount, type != 'refund') as revenue, + countIf(type != 'refund') as transactions, + uniq(r_customer_id) as customers, + sumIf(amount, type = 'refund') as refund_amount, + countIf(type = 'refund') as refund_count, + sumIf(amount, is_attributed = 1 AND type != 'refund') as attributed_revenue, + countIf(is_attributed = 1 AND type != 'refund') as attributed_transactions + FROM base_revenue + GROUP BY time_bucket + ) SELECT - toDate(created) as date, - sumIf(amount, type != 'refund') as revenue, - countIf(type != 'refund') as transactions, - uniq(r_customer_id) as customers, - sumIf(amount, type = 'refund') as refund_amount, - countIf(type = 'refund') as refund_count, - sumIf(amount, is_attributed = 1 AND type != 'refund') as attributed_revenue, - countIf(is_attributed = 1 AND type != 'refund') as attributed_transactions - FROM revenue_attributed - GROUP BY date - ORDER BY date ASC - `, - params: { websiteId, startDate, endDate }, - }), + ${dateFormat} as date, + * + FROM revenue_agg + ORDER BY time_bucket ASC`, + params: { websiteId, startDate, endDate, timezone: tz }, + }; + }, timeField: "created", - customizable: false, + customizable: true, }, revenue_by_provider: { diff --git a/apps/basket/src/routes/webhooks/stripe.ts b/apps/basket/src/routes/webhooks/stripe.ts index 156bf9cab..ed5c70261 100644 --- a/apps/basket/src/routes/webhooks/stripe.ts +++ b/apps/basket/src/routes/webhooks/stripe.ts @@ -79,10 +79,10 @@ interface WebhookSubscription { interface WebhookEvent { data: { object: - | WebhookPaymentIntent - | WebhookCharge - | WebhookInvoice - | WebhookSubscription; + | WebhookPaymentIntent + | WebhookCharge + | WebhookInvoice + | WebhookSubscription; }; id: string; type: string; @@ -182,9 +182,46 @@ function getConfig(hash: string): Promise { >; } +async function fetchSessionFromCheckoutSessionId( + checkoutSessionId: string, + ownerId: string +) { + const results = await clickHouse.query({ + query: ` + SELECT + anonymous_id, + session_id + FROM analytics.custom_events + WHERE + event_name = 'checkout_session' + AND owner_id = {ownerId:String} + AND JSONExtractString(properties, 'checkout_session_id') = {checkoutSessionId:String} + ORDER BY timestamp DESC + LIMIT 1 + `, + query_params: { + ownerId, + checkoutSessionId, + }, + format: "JSONEachRow", + }); + + const data = (await results.json()) as any[]; + if (data && data.length > 0) { + return { + anonymous_id: data[0].anonymous_id, + session_id: data[0].session_id, + }; + } + + return null; +} + async function handlePaymentIntent( pi: WebhookPaymentIntent, - config: WebhookConfig + config: WebhookConfig, + anonymous_id: string, + session_id?: string, ): Promise { const log = useLogger(); const metadata = extractAnalyticsMetadata(pi.metadata); @@ -222,8 +259,8 @@ async function handlePaymentIntent( original_amount: amount, original_currency: currency, currency, - anonymous_id: metadata.anonymous_id || undefined, - session_id: metadata.session_id || undefined, + anonymous_id, + session_id, customer_id: customerId, product_name: pi.description || undefined, metadata: JSON.stringify(metadata), @@ -238,10 +275,13 @@ async function handlePaymentIntent( async function handleFailedPayment( pi: WebhookPaymentIntent, config: WebhookConfig, - status: "failed" | "canceled" + status: "failed" | "canceled", + anonymous_id: string, + session_id?: string, ): Promise { const log = useLogger(); const metadata = extractAnalyticsMetadata(pi.metadata); + const customerId = extractCustomerId(pi.customer); const amount = (pi.amount_received ?? pi.amount) / 100; const currency = pi.currency.toUpperCase(); @@ -276,8 +316,8 @@ async function handleFailedPayment( original_amount: amount, original_currency: currency, currency, - anonymous_id: metadata.anonymous_id || undefined, - session_id: metadata.session_id || undefined, + anonymous_id, + session_id, customer_id: customerId, product_name: pi.description || undefined, metadata: JSON.stringify(metadata), @@ -291,7 +331,9 @@ async function handleFailedPayment( async function handleInvoicePaid( invoice: WebhookInvoice, - config: WebhookConfig + config: WebhookConfig, + anonymous_id: string, + session_id?: string, ): Promise { const log = useLogger(); @@ -341,8 +383,8 @@ async function handleInvoicePaid( original_amount: amount, original_currency: currency, currency, - anonymous_id: metadata.anonymous_id || undefined, - session_id: metadata.session_id || undefined, + anonymous_id, + session_id, customer_id: customerId, product_name: invoice.description || undefined, metadata: JSON.stringify(metadata), @@ -356,7 +398,9 @@ async function handleInvoicePaid( async function handleInvoiceFailed( invoice: WebhookInvoice, - config: WebhookConfig + config: WebhookConfig, + anonymous_id: string, + session_id?: string, ): Promise { const log = useLogger(); const metadata = extractAnalyticsMetadata(invoice.metadata); @@ -395,8 +439,8 @@ async function handleInvoiceFailed( original_amount: amount, original_currency: currency, currency, - anonymous_id: metadata.anonymous_id || undefined, - session_id: metadata.session_id || undefined, + anonymous_id, + session_id, customer_id: customerId, product_name: invoice.description || undefined, metadata: JSON.stringify(metadata), @@ -411,7 +455,9 @@ async function handleInvoiceFailed( async function handleSubscriptionEvent( sub: WebhookSubscription, config: WebhookConfig, - eventType: string + eventType: string, + anonymous_id: string, + session_id?: string, ): Promise { const log = useLogger(); const metadata = extractAnalyticsMetadata(sub.metadata); @@ -468,8 +514,8 @@ async function handleSubscriptionEvent( original_amount: amount, original_currency: currency, currency, - anonymous_id: metadata.anonymous_id || undefined, - session_id: metadata.session_id || undefined, + anonymous_id, + session_id, customer_id: customerId, product_name: firstItem?.plan?.product || undefined, metadata: JSON.stringify(subscriptionMetadata), @@ -483,7 +529,9 @@ async function handleSubscriptionEvent( async function handleRefund( charge: WebhookCharge, - config: WebhookConfig + config: WebhookConfig, + anonymous_id: string, + session_id?: string, ): Promise { const log = useLogger(); const metadata = extractAnalyticsMetadata(charge.metadata); @@ -521,8 +569,8 @@ async function handleRefund( original_amount: -amount, original_currency: currency, currency, - anonymous_id: metadata.anonymous_id || undefined, - session_id: metadata.session_id || undefined, + anonymous_id, + session_id, customer_id: customerId, product_name: "Refund", metadata: JSON.stringify(metadata), @@ -578,13 +626,50 @@ export const stripeWebhook = new Elysia().post( const event = verification.event; log.set({ eventType: event.type, eventId: event.id }); + const stripeObject = event.data.object as any; + console.log({ stripeObject }); + + const checkoutSessionId = + stripeObject.metadata?.checkout_session_id || + stripeObject.payment_details?.order_reference; + + if (!checkoutSessionId) { + log.warn( + `Invalid webhook, No checkout session id found for ${event.id}` + ); + set.status = 400; + return { error: "invalid_webhook" }; + } + + let anonymous_id = stripeObject.metadata?.anonymous_id; + let session_id = stripeObject.metadata?.session_id; + + const existingSession = await fetchSessionFromCheckoutSessionId( + checkoutSessionId, + result.ownerId + ); + + if (existingSession) { + anonymous_id = existingSession.anonymous_id; + session_id = existingSession.session_id; + } + + if (!anonymous_id) { + log.warn( + `Attribution pending for ${event.id} (checkout: ${checkoutSessionId}), returning 503 for retry` + ); + set.status = 503; + return { error: "attribution_pending" }; + } try { switch (event.type) { case "payment_intent.succeeded": { await handlePaymentIntent( event.data.object as WebhookPaymentIntent, - result + result, + anonymous_id, + session_id ); break; } @@ -592,7 +677,9 @@ export const stripeWebhook = new Elysia().post( await handleFailedPayment( event.data.object as WebhookPaymentIntent, result, - "failed" + "failed", + anonymous_id, + session_id ); break; } @@ -600,18 +687,27 @@ export const stripeWebhook = new Elysia().post( await handleFailedPayment( event.data.object as WebhookPaymentIntent, result, - "canceled" + "canceled", + anonymous_id, + session_id ); break; } case "invoice.paid": { - await handleInvoicePaid(event.data.object as WebhookInvoice, result); + await handleInvoicePaid( + event.data.object as WebhookInvoice, + result, + anonymous_id, + session_id + ); break; } case "invoice.payment_failed": { await handleInvoiceFailed( event.data.object as WebhookInvoice, - result + result, + anonymous_id, + session_id ); break; } @@ -624,12 +720,19 @@ export const stripeWebhook = new Elysia().post( await handleSubscriptionEvent( event.data.object as WebhookSubscription, result, - subEventType + subEventType, + anonymous_id, + session_id ); break; } case "charge.refunded": { - await handleRefund(event.data.object as WebhookCharge, result); + await handleRefund( + event.data.object as WebhookCharge, + result, + anonymous_id, + session_id + ); break; } default: { diff --git a/apps/dashboard/app/(main)/billing/hooks/use-billing.ts b/apps/dashboard/app/(main)/billing/hooks/use-billing.ts index d097c06f6..7cc92675c 100644 --- a/apps/dashboard/app/(main)/billing/hooks/use-billing.ts +++ b/apps/dashboard/app/(main)/billing/hooks/use-billing.ts @@ -33,7 +33,7 @@ export function useBilling(refetch?: () => void) { try { await attach({ planId, - successUrl: `${window.location.origin}/billing`, + successUrl: `${window.location.origin}/billing?session_id={CHECKOUT_SESSION_ID}`, metadata: getStripeMetadata(), }); } catch (error) { diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx index b59aa208f..d25407e54 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx @@ -1,5 +1,11 @@ "use client"; +import { + ArrowsCounterClockwiseIcon, + CreditCardIcon, + CurrencyDollarIcon, + TrendUpIcon, +} from "@phosphor-icons/react"; import { ChartLineIcon } from "@phosphor-icons/react"; import { CursorIcon } from "@phosphor-icons/react"; import { GlobeIcon } from "@phosphor-icons/react"; @@ -59,6 +65,8 @@ interface ChartDataPoint { rawDate?: string; sessions?: number; visitors?: number; + revenue?: number; + refunds?: number; [key: string]: unknown; } @@ -213,6 +221,23 @@ export function WebsiteOverviewTab({ granularity: dateRange.granularity, filters, }, + { + id: "overview-revenue", + parameters: [ + "revenue_overview", + "revenue_time_series", + { + name: "revenue_overview", + start_date: previousPeriodRange.start_date, + end_date: previousPeriodRange.end_date, + granularity: previousPeriodRange.granularity, + id: "previous_revenue_overview", + }, + ], + limit: QUERY_CONFIG.limit, + granularity: dateRange.granularity, + filters, + }, ], [dateRange.granularity, filters, previousPeriodRange] ); @@ -254,6 +279,108 @@ export function WebsiteOverviewTab({ countries: getDataForQuery("overview-geo", "country") || [], }; + const revenueOverview = + getDataForQuery("overview-revenue", "revenue_overview")?.[0] || null; + const previousRevenueOverview = + getDataForQuery("overview-revenue", "previous_revenue_overview")?.[0] || + null; + const revenueTimeSeries = + getDataForQuery("overview-revenue", "revenue_time_series") || []; + + const revenueTimeSeriesMap = useMemo(() => { + const map = new Map(); + for (const row of revenueTimeSeries) { + const key = dayjs(row.date).format("YYYY-MM-DD"); + map.set(key, { + revenue: row.revenue ?? 0, + refunds: Math.abs(row.refund_amount ?? 0), + }); + } + return map; + }, [revenueTimeSeries]); + + const revenueData = useMemo(() => { + const totalRevenue = Number(revenueOverview?.total_revenue ?? 0); + const totalTransactions = revenueOverview?.total_transactions ?? 0; + const refundAmount = Number(revenueOverview?.refund_amount ?? 0); + const uniqueCustomers = revenueOverview?.unique_customers ?? 0; + const avgTransaction = + totalTransactions > 0 ? totalRevenue / totalTransactions : 0; + const conversionRate = revenueOverview?.total_visitors + ? ((uniqueCustomers / revenueOverview.total_visitors) * 100).toFixed(2) + : "0"; + + return { + totalRevenue, + totalTransactions, + refundAmount, + uniqueCustomers, + avgTransaction, + conversionRate, + }; + }, [revenueOverview]); + + const formatCurrency = (amount: number): string => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); + }; + + const hasRevenueData = + revenueData.totalTransactions > 0 || revenueData.totalRevenue > 0; + + const calculateRevenueTrends = useMemo(() => { + if (!(revenueOverview && previousRevenueOverview)) { + return {}; + } + + const currentRevenue = Number(revenueOverview.total_revenue ?? 0); + const previousRevenue = Number(previousRevenueOverview.total_revenue ?? 0); + const currentTransactions = revenueOverview.total_transactions ?? 0; + const previousTransactions = + previousRevenueOverview.total_transactions ?? 0; + const currentRefunds = Number(revenueOverview.refund_amount ?? 0); + const previousRefunds = Number(previousRevenueOverview.refund_amount ?? 0); + + const currentUniqueCustomers = revenueOverview.unique_customers ?? 0; + const previousUniqueCustomers = + previousRevenueOverview.unique_customers ?? 0; + const currentVisitors = revenueOverview.total_visitors ?? 0; + const previousVisitors = previousRevenueOverview.total_visitors ?? 0; + + const currentConversionRate = + currentVisitors > 0 + ? (currentUniqueCustomers / currentVisitors) * 100 + : 0; + const previousConversionRate = + previousVisitors > 0 + ? (previousUniqueCustomers / previousVisitors) * 100 + : 0; + + const createTrend = (current: number, previous: number) => { + if (previous === 0 && current === 0) { + return { change: 0 }; + } + if (previous === 0) { + return undefined; + } + const change = calculatePercentChange(current, previous); + return { + change: Math.max(-100, Math.min(1000, Math.round(change))), + }; + }; + + return { + revenue: createTrend(currentRevenue, previousRevenue), + transactions: createTrend(currentTransactions, previousTransactions), + refunds: createTrend(currentRefunds, previousRefunds), + conversion: createTrend(currentConversionRate, previousConversionRate), + }; + }, [revenueOverview, previousRevenueOverview]); + const createPercentageCell = () => (info: CellInfo) => { const percentage = info.getValue() as number; return ; @@ -427,8 +554,10 @@ export function WebsiteOverviewTab({ const chartData = useMemo( () => - processedEventsData.map( - (event: MetricPoint): ChartDataPoint => ({ + processedEventsData.map((event: MetricPoint): ChartDataPoint => { + const dateKey = dayjs(event.date).format("YYYY-MM-DD"); + const revenueEntry = revenueTimeSeriesMap.get(dateKey); + return { date: formatDateByGranularity(event.date, dateRange.granularity), rawDate: event.date, ...(visibleMetrics.pageviews && { @@ -449,9 +578,16 @@ export function WebsiteOverviewTab({ ...(visibleMetrics.median_session_duration && { median_session_duration: event.median_session_duration as number, }), - }) - ), - [processedEventsData, dateRange.granularity, visibleMetrics] + revenue: revenueEntry?.revenue ?? 0, + refunds: revenueEntry?.refunds ?? 0, + }; + }), + [ + processedEventsData, + dateRange.granularity, + visibleMetrics, + revenueTimeSeriesMap, + ] ); const miniChartData = useMemo(() => { @@ -926,10 +1062,53 @@ export function WebsiteOverviewTab({ ))} + {hasRevenueData && ( +
+ + + + +
+ )} + { + const { + x = 0, + y = 0, + width = 0, + height = 0, + fill, + index, + hoveredIndex, + barSize = 6, + payload, + } = props; + + const revenue = payload?.revenue ?? 0; + const refunds = payload?.refunds ?? 0; + + const isHighlighted = + index !== undefined && hoveredIndex !== null && index === hoveredIndex; + const isHovering = hoveredIndex !== null; + const barFill = fill; + const barOpacity = isHovering && !isHighlighted ? 0.3 : 1; + const radius = Math.max(1, Math.min(4, Math.floor(barSize / 3))); + + // Calculate additional height for refunds (proportional to revenue height) + // If revenue = 100 gives height H, then refunds = 10 should add H * (10/100) = H * 0.1 + const refundHeight = revenue > 0 ? (refunds / revenue) * height : 0; + const hasRefunds = refundHeight >= 1; + + // If no refunds, render simple solid bar with rounded top + if (!hasRefunds) { + const path = ` + M${x + radius},${y} + L${x + width - radius},${y} + Q${x + width},${y} ${x + width},${y + radius} + L${x + width},${y + height} + L${x},${y + height} + L${x},${y + radius} + Q${x},${y} ${x + radius},${y} + Z + `; + return ( + + + + ); + } + + // Bar with refunds portion added on top + const refundY = y - refundHeight; // Refund portion extends upward + const strokeW = 1.5; + const halfStroke = strokeW / 2; + + // Solid revenue bar (no rounded top since refund connects above) + const solidPath = ` + M${x},${y} + L${x + width},${y} + L${x + width},${y + height} + L${x},${y + height} + Z + `; + + // Hollow refund portion on top (with rounded top corners) + const hollowFillPath = ` + M${x + radius},${refundY} + L${x + width - radius},${refundY} + Q${x + width},${refundY} ${x + width},${refundY + radius} + L${x + width},${y} + L${x},${y} + L${x},${refundY + radius} + Q${x},${refundY} ${x + radius},${refundY} + Z + `; + + // Dashed border path for refund portion - top and sides only + const dashedBorderPath = ` + M${x + halfStroke},${y} + L${x + halfStroke},${refundY + radius} + Q${x + halfStroke},${refundY + halfStroke} ${x + radius},${refundY + halfStroke} + L${x + width - radius},${refundY + halfStroke} + Q${x + width - halfStroke},${refundY + halfStroke} ${x + width - halfStroke},${refundY + radius} + L${x + width - halfStroke},${y} + `; + + return ( + + {/* Solid revenue portion */} + + {/* Hollow refund portion on top - accent color background */} + + {/* Dashed border for refund portion */} + + + ); +}; diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx index d335564e3..bcb7016b3 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx @@ -16,6 +16,7 @@ import { AnnotationsPanel } from "@/components/charts/annotations-panel"; import { type ChartDataRow, METRICS, + REVENUE_METRICS, } from "@/components/charts/metrics-constants"; import { RangeSelectionPopup } from "@/components/charts/range-selection-popup"; import { useDynamicDasharray } from "@/components/charts/use-dynamic-dasharray"; @@ -57,6 +58,8 @@ import type { CreateAnnotationData, } from "@/types/annotations"; import type { DateRange } from "../../../utils/types"; +import { RevenueBar } from "./revenue-bar"; +import type { Payload } from "recharts/types/component/DefaultLegendContent"; const { Area, @@ -70,6 +73,7 @@ const { Tooltip, XAxis, YAxis, + Bar, } = Chart.Recharts; interface TooltipPayloadEntry { @@ -94,6 +98,30 @@ const CustomTooltip = ({ isDragging, justFinishedDragging, }: TooltipProps) => { + const { uniquePayload, refundsValue, hasRefunds, revenueEntry } = + useMemo(() => { + const map = new Map(); + + let refunds: number | undefined; + + for (const entry of payload ?? []) { + if (!map.has(entry.dataKey)) { + map.set(entry.dataKey, entry); + + if (entry.dataKey === "revenue") { + refunds = entry.payload?.refunds as number | undefined; + } + } + } + + return { + uniquePayload: Array.from(map.values()), + refundsValue: refunds, + hasRefunds: !!refunds && refunds > 0, + revenueEntry: map.get("revenue"), + }; + }, [payload]); + // Hide tooltip during or immediately after dragging if (isDragging || justFinishedDragging) { return null; @@ -110,12 +138,15 @@ const CustomTooltip = ({

{label}

- {payload.map((entry) => { + {uniquePayload.map((entry) => { const metric = METRICS.find((m) => m.key === entry.dataKey); if (!metric || entry.value === undefined || entry.value === null) { return null; } + const indicatorColor = + entry.dataKey === "revenue" ? "var(--color-chart-6)" : metric.color; + const value = metric.formatValue ? metric.formatValue(entry.value, entry.payload as ChartDataRow) : formatLocaleNumber(entry.value); @@ -128,7 +159,7 @@ const CustomTooltip = ({
{metric.label} @@ -140,6 +171,27 @@ const CustomTooltip = ({
); })} + {/* Show refunds if present in the data */} + {hasRefunds && refundsValue && ( +
+
+
+ Refunds +
+ + {REVENUE_METRICS.find((m) => m.key === "refunds")?.formatValue?.( + refundsValue, + revenueEntry?.payload as ChartDataRow + ) ?? formatLocaleNumber(refundsValue)} + +
+ )}
); @@ -370,6 +422,7 @@ export function TrafficTrendsRechartsPlot({ const [isDragging, setIsDragging] = useState(false); const [suppressTooltip, setSuppressTooltip] = useState(false); const [hasAnimated, setHasAnimated] = useState(false); + const [hoveredIndex, setHoveredIndex] = useState(null); const { chartStepType } = useChartPreferences("overview-main"); @@ -402,6 +455,26 @@ export function TrafficTrendsRechartsPlot({ [rawData] ); + const barSize = useMemo(() => { + const dataLength = chartData.length; + if (dataLength <= 8) { + return 24; + } + if (dataLength <= 16) { + return 10; + } + if (dataLength <= 30) { + return 8; + } + if (dataLength <= 60) { + return 6; + } + if (dataLength <= 90) { + return 5; + } + return 10; + }, [chartData.length, granularity]); + const [DasharrayCalculator, lineDasharrays] = useDynamicDasharray({ splitIndex: chartData.length - 2, chartType: chartStepType, @@ -555,7 +628,11 @@ export function TrafficTrendsRechartsPlot({
)} - + setHoveredIndex(null)} + onMouseMove={(state) => { + if (state.activeTooltipIndex === undefined) { + setHoveredIndex(null); + } else { + setHoveredIndex(state.activeTooltipIndex); + } + if (mergedFeatures.rangeSelection) { + handleMouseMove(state); + } + }} onMouseUp={ mergedFeatures.rangeSelection ? handleMouseUp : undefined } @@ -596,6 +681,20 @@ export function TrafficTrendsRechartsPlot({ /> ))} + + + + + + + + m.label === label); const isHidden = metric ? hiddenMetrics[metric.key] - : false; + : label === "Revenue" + ? hiddenMetrics.revenue + : false; return ( { - const metric = metrics.find( - (m) => m.label === payload.value - ); - if (metric) { - toggleMetric(metric.key as keyof typeof visibleMetrics); + onClick={(payload: Payload) => { + if (payload.dataKey) { + toggleMetric(payload.dataKey as keyof typeof visibleMetrics); } }} + payload={[ + ...metrics.map((metric) => ({ + value: metric.label, + type: "circle" as const, + color: hiddenMetrics[metric.key] + ? "var(--muted-foreground)" + : metric.color, + dataKey: metric.key, + })), + { + value: "Revenue", + type: "circle" as const, + color: hiddenMetrics.revenue + ? "var(--muted-foreground)" + : "var(--color-chart-6)", + dataKey: "revenue", + }, + ]} verticalAlign="bottom" wrapperStyle={chartRechartsLegendInteractiveWrapperStyle} /> )} - {metrics.map((metric) => ( - { - setHasAnimated(true); - }} - stroke={metric.color} - strokeDasharray={ - lineDasharrays.find((line) => line.name === metric.key) - ?.strokeDasharray || "0 0" + {metrics.map((metric) => { + const isHovered = hoveredIndex !== null && !isDragging; + return ( + { + setHasAnimated(true); + }} + stroke={metric.color} + strokeDasharray={ + lineDasharrays.find((line) => line.name === metric.key) + ?.strokeDasharray || "0 0" + } + strokeOpacity={isHovered ? 0.3 : 1} + strokeWidth={2.5} + style={{ + transition: + "stroke-opacity 150ms ease-out, fill-opacity 150ms ease-out", + }} + type={chartStepType} + /> + ); + })} + {metrics.map((metric) => { + return ( + line.name === metric.key) + ?.strokeDasharray || "0 0" + } + strokeOpacity={1} + strokeWidth={2.5} + tooltipType="none" + type={chartStepType} + /> + ); + })} + ( + + )} + stackId="revenue" + /> + { + const xAxis = xAxisMap[0]; + if (!xAxis || hoveredIndex === null || isDragging) { + return null; } - strokeWidth={2.5} - type={chartStepType} - /> - ))} + const xPos = xAxis.scale(chartData[hoveredIndex].xKey); + const prevX = + hoveredIndex > 0 + ? xAxis.scale(chartData[hoveredIndex - 1].xKey) + : xPos - + (xAxis.scale(chartData[1]?.xKey || xPos) - xPos); + const nextX = + hoveredIndex < chartData.length - 1 + ? xAxis.scale(chartData[hoveredIndex + 1].xKey) + : xPos + + (xPos - xAxis.scale(chartData.at(-2)?.xKey || xPos)); + + const halfWidth = (xPos - prevX) / 2; + const startPos = hoveredIndex === 0 ? 0 : xPos; + const endPos = + (hoveredIndex === chartData.length - 1 + ? xAxis.scale.range()[1] + : xPos + (nextX - xPos) / 2) + halfWidth; + + return ( + + + + + + ); + }} + /> @@ -920,6 +1129,7 @@ interface TrafficTrendsChartProps { chartData: ChartDataRow[]; dateDiff: number; dateRange: DateRange; + hasRevenueData?: boolean; isError: boolean; isLoading: boolean; isMobile: boolean; diff --git a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx index c6ee9138d..f76625c5d 100644 --- a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx @@ -1,6 +1,7 @@ "use client"; import { ChartLineIcon } from "@phosphor-icons/react"; +import dayjs from "dayjs"; import { useAtom } from "jotai"; import { useMemo } from "react"; import { SkeletonChart } from "@/components/charts/skeleton-chart"; @@ -97,9 +98,11 @@ const REVENUE_METRICS: RevenueChartMetric[] = [ minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(Math.abs(v)), - }, + } ]; +const CURRENCY_METRICS = ["revenue", "avg_transaction", "refunds"]; + interface RevenueChartProps { className?: string; data: RevenueChartDataPoint[]; @@ -107,9 +110,21 @@ interface RevenueChartProps { isLoading: boolean; } +function formatChartDate(value: string, granularity?: string): string { + const parsed = dayjs(value); + if (!parsed.isValid()) { + return value; + } + if (granularity === "hour" || granularity === "hourly") { + return parsed.format("MMM D, h:mm A"); + } + return parsed.format("MMM D, YYYY"); +} + export function RevenueChart({ data, isLoading, + granularity, height = 350, className, }: RevenueChartProps) { @@ -204,13 +219,31 @@ export function RevenueChart({ axisLine={false} dataKey="date" tick={chartAxisTickDefault} + tickFormatter={(value) => formatChartDate(value, granularity)} tickLine={false} /> + new Intl.NumberFormat("en-US", { + notation: "compact", + style: "currency", + currency: "USD", + }).format(value) + } tickLine={false} width={chartAxisYWidthDefault} + yAxisId="currency" + /> + ( @@ -274,8 +307,11 @@ export function RevenueChart({ key={metric.key} name={metric.label} stroke={metric.color} - strokeWidth={2.5} + strokeWidth={metric.key === "revenue" ? 3 : 2} type="monotone" + yAxisId={ + CURRENCY_METRICS.includes(metric.key) ? "currency" : "count" + } /> ))} @@ -283,4 +319,4 @@ export function RevenueChart({ ); -} +} \ No newline at end of file diff --git a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx index e04cc1433..db5a08a32 100644 --- a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx @@ -45,6 +45,7 @@ import { cn } from "@/lib/utils"; import { addDynamicFilterAtom, dynamicQueryFiltersAtom, + type TimeGranularity, } from "@/stores/jotai/filterAtoms"; import { WebsitePageHeader } from "../../_components/website-page-header"; import { RevenueAttributionTables } from "./revenue-attribution-tables"; @@ -107,28 +108,36 @@ function padTimeSeriesData( data: T[], startDate: string, endDate: string, - defaultValues: Omit -): T[] { - if (data.length === 0) { - return []; - } + defaultValues: Omit, + granularity: TimeGranularity = "daily" +) { + const unit = granularity === "hourly" ? "hour" : "day"; + const format = unit === "hour" ? "YYYY-MM-DD HH:00:00" : "YYYY-MM-DD"; + + // Use numeric timestamps for robust matching + const dataMap = new Map( + data.map((d) => [dayjs(d.date).startOf(unit).valueOf(), d]) + ); - const dataMap = new Map(data.map((d) => [d.date, d])); const result: T[] = []; - let current = dayjs(startDate); - const end = dayjs(endDate); + let current = dayjs(startDate).startOf(unit); + const end = + unit === "hour" + ? dayjs(endDate).endOf("day") + : dayjs(endDate).startOf(unit); - while (current.isBefore(end) || current.isSame(end, "day")) { - const dateStr = current.format("YYYY-MM-DD"); - const existing = dataMap.get(dateStr); + while (current.isBefore(end) || current.isSame(end, unit)) { + const timestamp = current.valueOf(); + const dateStr = current.format(format); + const existing = dataMap.get(timestamp); if (existing) { - result.push(existing); + result.push({ ...existing, date: dateStr }); } else { result.push({ date: dateStr, ...defaultValues } as T); } - current = current.add(1, "day"); + current = current.add(1, unit); } return result; @@ -337,7 +346,7 @@ function RevenueSettingsSheet({

@@ -667,7 +676,10 @@ export function RevenueContent({ websiteId }: RevenueContentProps) { ) ?? []) as RevenueTimeSeries[]; const overview = overviewData[0]; - const hasData = overview && overview.total_transactions > 0; + const visitors = overview?.total_visitors ?? 0; + const totalRevenue = Number(overview?.total_revenue ?? 0); + const hasData = + overview && (overview.total_transactions > 0 || totalRevenue > 0); const isConfigured = config?.stripeConfigured || config?.paddleConfigured; const paddedTimeSeriesData = useMemo(() => { @@ -681,9 +693,15 @@ export function RevenueContent({ websiteId }: RevenueContentProps) { customers: 0, refund_amount: 0, refund_count: 0, - } + }, + dateRange.granularity ); - }, [timeSeriesData, dateRange.start_date, dateRange.end_date]); + }, [ + timeSeriesData, + dateRange.start_date, + dateRange.end_date, + dateRange.granularity, + ]); const chartData = useMemo(() => { return paddedTimeSeriesData.map((row) => ({ @@ -716,6 +734,7 @@ export function RevenueContent({ websiteId }: RevenueContentProps) { return "Not configured"; }; + return ( <>
@@ -807,6 +826,7 @@ export function RevenueContent({ websiteId }: RevenueContentProps) { @@ -846,4 +866,4 @@ export function RevenueContent({ websiteId }: RevenueContentProps) { /> ); -} +} \ No newline at end of file diff --git a/apps/dashboard/app/globals.css b/apps/dashboard/app/globals.css index ee114abb0..b30707f0e 100644 --- a/apps/dashboard/app/globals.css +++ b/apps/dashboard/app/globals.css @@ -46,6 +46,7 @@ --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-chart-6: var(--chart-6); /* Sidebar Colors */ --color-sidebar: var(--sidebar); @@ -353,6 +354,7 @@ --chart-3: #e3a514; --chart-4: #2d9cdb; --chart-5: #27ae60; + --chart-6: #e78468; --sidebar: #fafafb; --sidebar-foreground: #27282d; diff --git a/apps/dashboard/components/charts/metrics-constants.ts b/apps/dashboard/components/charts/metrics-constants.ts index 7586c6454..28d8475e0 100644 --- a/apps/dashboard/components/charts/metrics-constants.ts +++ b/apps/dashboard/components/charts/metrics-constants.ts @@ -10,6 +10,7 @@ import { Zap, } from "lucide-react"; import { formatLocaleNumber } from "@/lib/format-locale-number"; +import { formatCurrency } from "@/lib/formatters"; import { formatDuration } from "@/lib/utils"; const createColorSet = ( @@ -55,6 +56,19 @@ export const METRIC_COLORS = { "#fee2e2", "from-red-500/20 to-red-600/5" ), + revenue: createColorSet( + "#e78468", + "#059669", + "#d1fae5", + "from-emerald-500/20 to-emerald-600/5" + ), + refunds: createColorSet( + "#e78468", + "#e11d48", + "#ffe4e6", + "from-rose-500/20 to-rose-600/5" + ), + // Core Web Vitals avg_fcp: createColorSet( "#06b6d4", "#0891b2", @@ -154,6 +168,9 @@ export interface ChartDataRow { visitors?: number; /** Stable category for Recharts X-axis; usually rawDate (YYYY-MM-DD or hourly key) */ xKey?: string; + // Revenue metrics + revenue?: number; + refunds?: number; [key: string]: unknown; } @@ -350,9 +367,30 @@ export const ERROR_METRICS: MetricConfig[] = [ ), ]; +// Revenue metrics +export const REVENUE_METRICS: MetricConfig[] = [ + createMetric( + "revenue", + "Revenue", + "revenue", + TrendingUp, + (value) => formatCurrency(value, "USD"), + "analytics" + ), + createMetric( + "refunds", + "Refunds", + "revenue", + TrendingUp, + (value) => formatCurrency(value, "USD"), + "analytics" + ), +]; + export const METRICS = [ ...ANALYTICS_METRICS, ...PERFORMANCE_METRICS, ...CORE_WEB_VITALS_METRICS, ...ERROR_METRICS, + ...REVENUE_METRICS, ]; diff --git a/apps/dashboard/lib/chart-presentation.ts b/apps/dashboard/lib/chart-presentation.ts index 705d50f66..4ec335ee4 100644 --- a/apps/dashboard/lib/chart-presentation.ts +++ b/apps/dashboard/lib/chart-presentation.ts @@ -95,6 +95,7 @@ export const chartSeriesPalette = [ "var(--color-chart-3)", "var(--color-chart-4)", "var(--color-chart-5)", + "var(--color-chart-6)", ] as const; export function chartSeriesColorAtIndex(index: number): string { diff --git a/apps/dashboard/stores/jotai/chartAtoms.ts b/apps/dashboard/stores/jotai/chartAtoms.ts index e85e16428..05b1718d6 100644 --- a/apps/dashboard/stores/jotai/chartAtoms.ts +++ b/apps/dashboard/stores/jotai/chartAtoms.ts @@ -7,6 +7,8 @@ export interface MetricVisibilityState { pageviews: boolean; sessions: boolean; visitors: boolean; + revenue: boolean; + refunds: boolean; } const defaultVisibleMetrics: MetricVisibilityState = { @@ -15,6 +17,8 @@ const defaultVisibleMetrics: MetricVisibilityState = { sessions: false, bounce_rate: false, median_session_duration: false, + revenue: true, + refunds: false, }; export const metricVisibilityAtom = atomWithStorage( diff --git a/package.json b/package.json index 194933b1c..8ddd883f7 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "db:push": "dotenv -- turbo run db:push", "db:migrate": "dotenv -- turbo run db:migrate", "db:deploy": "dotenv -- turbo run db:deploy", - "db:seed": "dotenv -- sh -c 'cd packages/db && bun run db:seed \"$@\"' --", + "db:seed": "dotenv -- bun run packages/db/src/seed.ts", "email:dev": "dotenv -- sh -c 'cd packages/email && bun run dev'", "sdk:build": "turbo run build --filter @databuddy/sdk --filter @databuddy/cache", "dev:dashboard": "dotenv -- turbo dev --filter @databuddy/dashboard --filter @databuddy/api", diff --git a/packages/db/src/clickhouse/client.ts b/packages/db/src/clickhouse/client.ts index ddcefd50a..5cbe9cad2 100644 --- a/packages/db/src/clickhouse/client.ts +++ b/packages/db/src/clickhouse/client.ts @@ -13,6 +13,7 @@ export const TABLE_NAMES = { error_spans: "analytics.error_spans", web_vitals_spans: "analytics.web_vitals_spans", custom_events: "analytics.custom_events", + revenue: "analytics.revenue", ai_call_spans: "observability.ai_call_spans", ai_traffic_spans: "analytics.ai_traffic_spans", }; diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index b889701dc..6791b34e5 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -4,8 +4,10 @@ import { clickHouse, TABLE_NAMES } from "./clickhouse/client"; import { db } from "./client"; import { websites } from "./drizzle/schema"; -const clientId = process.argv[2] || faker.string.uuid(); -const eventCount = Number(process.argv[3]) || 10_000; +const generateRevenue = process.argv.includes("--rev"); +const args = process.argv.filter((arg) => !arg.startsWith("--")); +const clientId = args[2] || faker.string.uuid(); +const eventCount = Number(args[3]) || 10_000; const PATHS = [ "/", @@ -147,8 +149,8 @@ function generatePageTitle(path: string): string { downlink: undefined, time_on_page: isPageExit ? Math.round( - faker.number.float({ min: 5, max: 600, fractionDigits: 1 }) - ) + faker.number.float({ min: 5, max: 600, fractionDigits: 1 }) + ) : undefined, scroll_depth: isPageExit ? faker.number.float({ min: 10, max: 100, fractionDigits: 1 }) @@ -339,6 +341,53 @@ function generatePageTitle(path: string): string { webVitals.sort((a, b) => a.timestamp - b.timestamp); + const revenueTransactions = generateRevenue + ? Array.from({ length: Math.floor(eventCount / 50) }, (_, index) => { + const sessionIndex = Math.floor( + index / (Math.floor(eventCount / 50) / TOTAL_SESSIONS) + ); + const session = + SESSION_POOL[Math.min(sessionIndex, SESSION_POOL.length - 1)]; + + const maxSessionDuration = 2 * 60 * 60 * 1000; + const sessionProgress = + (index % Math.ceil(Math.floor(eventCount / 50) / TOTAL_SESSIONS)) / + Math.ceil(Math.floor(eventCount / 50) / TOTAL_SESSIONS); + const timestamp = + session.sessionStartTime + sessionProgress * maxSessionDuration; + + const amount = faker.number.int({ min: 40, max: 50 }); + const isRefund = faker.helpers.maybe(() => true, { probability: 0.08 }); + const status = isRefund ? "refunded" : "completed"; + const type = isRefund + ? "refund" + : faker.helpers.arrayElement(["sale", "subscription"]); + + return { + owner_id: clientId, + website_id: clientId, + transaction_id: `txn_${faker.string.uuid()}`, + provider: faker.helpers.arrayElement(["stripe", "paddle"]), + type, + status, + amount, + original_amount: amount, + original_currency: "USD", + currency: "USD", + anonymous_id: session.anonymousId, + session_id: session.sessionId, + customer_id: `cus_${faker.string.uuid()}`, + product_id: `prod_${faker.string.uuid()}`, + product_name: faker.commerce.productName(), + metadata: "{}", + created: Math.floor(timestamp / 1000), + synced_at: Math.floor(Date.now() / 1000), + }; + }) + : []; + + revenueTransactions.sort((a, b) => a.created - b.created); + console.log( `Generating seed data for client: ${clientId} on domain: ${domain}` ); @@ -367,9 +416,21 @@ function generatePageTitle(path: string): string { format: "JSONEachRow", values: webVitals, }), + ...(generateRevenue && revenueTransactions.length > 0 + ? [ + clickHouse.insert({ + table: TABLE_NAMES.revenue, + format: "JSONEachRow", + values: revenueTransactions, + }), + ] + : []), ]); console.log( `Inserted ${events.length} events, ${outgoingLinks.length} outgoing links, ${errors.length} errors, ${webVitals.length} web vitals for client ${clientId}` ); + if (generateRevenue) { + console.log(`Inserted ${revenueTransactions.length} revenue transactions`); + } })(); diff --git a/packages/tracker/src/index.ts b/packages/tracker/src/index.ts index 466c910ed..14348fc73 100644 --- a/packages/tracker/src/index.ts +++ b/packages/tracker/src/index.ts @@ -53,7 +53,7 @@ export class Databuddy extends BaseTracker { this.flushTrack(), this.flushVitals(), this.flushErrors(), - ]).catch(() => {}); + ]).catch(() => { }); }, clear: () => this.clear(), setGlobalProperties: (props: Record) => @@ -99,6 +99,32 @@ export class Databuddy extends BaseTracker { const interactionCleanup = initInteractionTracking(this); this.cleanupFns.push(interactionCleanup); } + this.checkCheckoutSession(); + } + + private checkCheckoutSession(): void { + if (this.isServer()) { + return; + } + + const urlParams = new URLSearchParams(window.location.search); + const sessionId = urlParams.get("session_id"); + + if (sessionId && (sessionId.startsWith("cs_") || sessionId.startsWith("checkout_"))) { + this.track("checkout_session", { + checkout_session_id: sessionId, + provider: "stripe", + }); + } + + const paddleCheckoutId = urlParams.get("checkout_id"); + if (paddleCheckoutId) { + this.track("checkout_session", { + checkout_session_id: paddleCheckoutId, + provider: "paddle", + }); + } + this.checkCheckoutSession(); } trackScreenViews() { @@ -214,7 +240,7 @@ export class Databuddy extends BaseTracker { if (this.sendBeacon(queue, endpoint)) { queue.length = 0; } else { - fallback().catch(() => {}); + fallback().catch(() => { }); } } @@ -364,7 +390,7 @@ export class Databuddy extends BaseTracker { sessionStorage.removeItem("did_session"); sessionStorage.removeItem("did_session_timestamp"); sessionStorage.removeItem("did_session_start"); - } catch {} + } catch { } } this.clearUrlParamStorage(); this.anonymousId = this.generateAnonymousId(); @@ -420,11 +446,11 @@ function initializeDatabuddy() { if (isOptedOut()) { window.databuddy = { - track: () => {}, - screenView: () => {}, - clear: () => {}, - flush: () => {}, - setGlobalProperties: () => {}, + track: () => { }, + screenView: () => { }, + clear: () => { }, + flush: () => { }, + setGlobalProperties: () => { }, options: { clientId: "", disabled: true }, }; window.db = window.databuddy; @@ -444,7 +470,7 @@ if (typeof window !== "undefined") { try { localStorage.setItem("databuddy_opt_out", "true"); localStorage.setItem("databuddy_disabled", "true"); - } catch {} + } catch { } window.databuddyOptedOut = true; window.databuddyDisabled = true; if (window.databuddy) { @@ -456,7 +482,7 @@ if (typeof window !== "undefined") { try { localStorage.removeItem("databuddy_opt_out"); localStorage.removeItem("databuddy_disabled"); - } catch {} + } catch { } window.databuddyOptedOut = false; window.databuddyDisabled = false; diff --git a/setup.ts b/setup.ts index 03943cbc1..7668ff47f 100755 --- a/setup.ts +++ b/setup.ts @@ -310,7 +310,7 @@ async function main() { log(chalk.cyan(" 2. Run 'bun run dev' to start development servers")); log( chalk.cyan( - " 3. (Optional) Run 'bun run db:seed [DOMAIN] [EVENT_COUNT]' to seed sample data" + " 3. (Optional) Run 'bun run db:seed [EVENT_COUNT] [--revenue]' to seed sample data" ) ); log(chalk.bold("\nHappy coding! 🎉"));