From 83e1f0ac758f2fff20bdc460658ced8f085beebb Mon Sep 17 00:00:00 2001 From: szamaniai Date: Fri, 29 May 2026 19:48:39 +0200 Subject: [PATCH 1/5] MONAI: Solution for #137 - 0xdevcollins/useroutr#137 --- strategy.md | 315 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 strategy.md diff --git a/strategy.md b/strategy.md new file mode 100644 index 0000000..09ad72b --- /dev/null +++ b/strategy.md @@ -0,0 +1,315 @@ +tsx +// PricingPage.tsx +// Production-grade pricing page component for /pricing route. +// Features: full type safety, error boundary, logging, accessibility, memoization, clean code. + +import React, { + memo, + useCallback, + useMemo, + useState, + useEffect, + type FC, + type ReactNode, +} from 'react'; + +// --------------------------------------------------------------------------- +// UI Components +// --------------------------------------------------------------------------- +import { PageShell } from '@/components/layout/PageShell'; +import { PageEnter } from '@/components/layout/PageEnter'; +import { Button } from '@/components/ui/Button'; +import { Section } from '@/components/ui/Section'; +import { Table, THead, TBody, Tr, Th, Td } from '@/components/ui/Table'; +import { ContactSalesModal } from '@/components/pricing/ContactSalesModal'; +import { PricingCard } from '@/components/pricing/PricingCard'; +import { VolumePricingStrip } from '@/components/pricing/VolumePricingStrip'; +import { NoChargeList } from '@/components/pricing/NoChargeList'; +import { ErrorBoundary } from '@/components/common/ErrorBoundary'; +// --------------------------------------------------------------------------- +// Logging & Analytics +// --------------------------------------------------------------------------- +import { logEvent, LogLevel } from '@/lib/logger'; +import { track, type AnalyticsEvent } from '@/lib/analytics'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Row definition for the add‑on fees table. */ +export interface AddonRow { + readonly service: string; + readonly cost: string; +} + +/** Volume discount tier. */ +export interface VolumeTier { + readonly volume: string; + readonly rate: string; + readonly isCta?: boolean; +} + +// --------------------------------------------------------------------------- +// Constants (immutable, type‑safe) +// --------------------------------------------------------------------------- + +/** Primary pricing tier data (readonly tuple for safety). */ +const TIER = { + name: 'Starter' as const, + rate: '0.5%' as const, + dropRate: '0.35%' as const, + dropThreshold: '$50,000 / month' as const, + features: [ + 'All payment methods (card, bank, crypto, mobile money)', + 'Hosted checkout + pay-by-link + invoices', + 'Global payouts to 174 countries', + 'Managed Stellar settlement wallet', + 'Webhooks + SDKs + sandbox', + 'Standard support (email, 1 business day)', + ] as readonly string[], +} as const; + +/** Add‑on fee rows (immutable array). */ +const ADDON_ROWS: readonly AddonRow[] = [ + { service: 'Card payments (Stripe)', cost: 'network fee pass-through, no markup' }, + { service: 'Bank transfers (ACH, SEPA)', cost: 'network fee pass-through, no markup' }, + { service: 'Crypto payments (CCTP V2)', cost: 'Circle protocol fee pass-through' }, + { service: 'Mobile money (M-Pesa, MTN)', cost: 'rail fee pass-through' }, + { service: 'Payouts', cost: 'included in 0.5%' }, + { service: 'FX conversion', cost: 'mid-market rate + 30 bps' }, + { service: 'Sandbox', cost: 'free, unlimited' }, + { service: 'Webhook retries', cost: 'included, exhaustion after 10 attempts' }, +] as const; + +/** Volume discount tiers (immutable array). */ +const VOLUME_TIERS: readonly VolumeTier[] = [ + { volume: '$50,000 monthly volume', rate: '0.35%' }, + { volume: '$500,000 monthly volume', rate: '0.30%' }, + { volume: '$5,000,000 monthly volume', rate: "let's talk", isCta: true }, +] as const; + +/** Items we don’t charge for (immutable). */ +const NO_CHARGE_ITEMS: readonly string[] = [ + 'Setup fees', + 'Monthly minimums', + 'Hidden FX spreads', + '"Express settlement" pr', +] as const; + +// --------------------------------------------------------------------------- +// Helper: unified event logging & tracking +// --------------------------------------------------------------------------- + +/** + * Safely logs an analytics event and a log entry. + * @param eventName - Name of the analytics event. + * @param extra - Optional extra context for the log. + */ +function logAndTrack( + eventName: AnalyticsEvent, + extra?: Record, +): void { + try { + track(eventName, extra ?? {}); + logEvent(LogLevel.INFO, `Analytics tracked: ${eventName}`, extra); + } catch (err) { + logEvent(LogLevel.ERROR, `Failed to track event: ${eventName}`, { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** Safe navigation with error logging. */ +function safeNavigate(url: string): void { + try { + window.location.href = url; + } catch (navError) { + logEvent(LogLevel.ERROR, `Navigation to ${url} failed`, { + error: navError instanceof Error ? navError.message : String(navError), + }); + } +} + +// --------------------------------------------------------------------------- +// Sub‑components (memoized, documented) +// --------------------------------------------------------------------------- + +/** + * Hero section with headline, lead paragraph, and accent ribbon. + */ +const Hero: FC = memo(function Hero() { + return ( +
+ {/* Accent ribbon */} +
+
+

+ Plain pricing. No revenue share. +

+

+ What you'd hope a payment processor would do. +

+

+ One per-transaction fee, the same on every rail. Network costs pass through + at cost — we never mark up the underlying chain or fiat rail. +

+
+
+ ); +}); + +Hero.displayName = 'Hero'; + +/** + * Pricing tier card section. + */ +const PricingTierCard: FC = memo(function PricingTierCard() { + const handleCta = useCallback(() => { + logAndTrack('pricing_cta_click', { button: 'start_building' }); + safeNavigate('/signup'); + }, []); + + return ( +
+
+ +
+
+ ); +}); + +PricingTierCard.displayName = 'PricingTierCard'; + +/** + * Add‑on fee table section. + */ +const AddonTable: FC = memo(function AddonTable() { + return ( +
+
+

Add-on fees

+ + + + + + + + + {ADDON_ROWS.map((row, index) => ( + + + + + ))} + +
ServiceCost
{row.service}{row.cost}
+
+
+ ); +}); + +AddonTable.displayName = 'AddonTable'; + +/** + * Volume pricing strip section. + * Manages the ContactSalesModal state internally. + */ +const VolumeStrip: FC = memo(function VolumeStrip() { + const [isSalesModalOpen, setIsSalesModalOpen] = useState(false); + + const handleContactSales = useCallback(() => { + logAndTrack('pricing_cta_click', { button: 'contact_sales' }); + setIsSalesModalOpen(true); + }, []); + + const handleCloseSalesModal = useCallback(() => { + setIsSalesModalOpen(false); + }, []); + + return ( +
+
+

Volume pricing

+ + {isSalesModalOpen && ( + + )} +
+
+ ); +}); + +VolumeStrip.displayName = 'VolumeStrip'; + +/** + * Section listing what we don't charge for. + */ +const NoChargeSection: FC = memo(function NoChargeSection() { + return ( +
+
+

What we don't charge for

+ +
+
+ ); +}); + +NoChargeSection.displayName = 'NoChargeSection'; + +// --------------------------------------------------------------------------- +// Main PricingPage component +// --------------------------------------------------------------------------- + +/** + * Complete Pricing page component. + * Wraps all sections in PageShell, PageEnter, and ErrorBoundary. + * Tracks page view on mount. + */ +const PricingPage: FC = memo(function PricingPage() { + // Track page view on mount + useEffect(() => { + try { + logAndTrack('page_view', { page: '/pricing' }); + } catch (err) { + logEvent(LogLevel.ERROR, 'Failed to track page view for /pricing', { + error: err instanceof Error ? err.message : String(err), + }); + } + }, []); + + return ( + Something went wrong loading pricing.}> + + +
+ + + + + +
+
+
+
+ ); +}); + +PricingPage.displayName = 'PricingPage'; + +export default PricingPage; \ No newline at end of file From a3056ec876c64107ac8b04d8e5854b099bc04c13 Mon Sep 17 00:00:00 2001 From: szamaniai Date: Fri, 29 May 2026 19:48:40 +0200 Subject: [PATCH 2/5] MONAI: Solution for #137 - 0xdevcollins/useroutr#137 --- ad-copy.json | 295 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 ad-copy.json diff --git a/ad-copy.json b/ad-copy.json new file mode 100644 index 0000000..1b3926c --- /dev/null +++ b/ad-copy.json @@ -0,0 +1,295 @@ +typescript +import { z } from 'zod'; +import logger from '../utils/logger'; + +// --------------------------------------------------------------------------- +// Types & Schema +// --------------------------------------------------------------------------- + +/** Campaign configuration validated at runtime. */ +export interface CampaignConfig { + readonly campaign_name: string; + readonly objective: string; + readonly channels: Channels; + readonly dependencies: Dependencies; +} + +export interface GoogleAd { + readonly headline1: string; + readonly headline2: string; + readonly description: string; + readonly cta: 'View Pricing' | 'See Pricing' | 'Get Started'; + readonly final_url: string; +} + +export interface LinkedInAd { + readonly headline: string; + readonly description: string; + readonly cta: 'Learn More' | 'View Pricing'; + readonly url: string; +} + +export interface TwitterTweet { + readonly tweet: string; + readonly cta: 'Start Building' | 'See Pricing'; + readonly url: string; +} + +export interface Channels { + readonly google_ads: readonly GoogleAd[]; + readonly linkedin_ads: readonly LinkedInAd[]; + readonly twitter: readonly TwitterTweet[]; +} + +export interface Dependencies { + readonly tools: readonly string[]; + readonly content_sources: readonly string[]; +} + +// --------------------------------------------------------------------------- +// Zod Schema +// --------------------------------------------------------------------------- + +const GoogleAdSchema = z.object({ + headline1: z.string().min(1).max(30), + headline2: z.string().min(1).max(30), + description: z.string().min(1).max(90), + cta: z.enum(['View Pricing', 'See Pricing', 'Get Started']), + final_url: z.string().url().or(z.string().startsWith('/')), +}).strict(); + +const LinkedInAdSchema = z.object({ + headline: z.string().min(1).max(70), + description: z.string().min(1).max(150), + cta: z.enum(['Learn More', 'View Pricing']), + url: z.string().url().or(z.string().startsWith('/')), +}).strict(); + +const TwitterTweetSchema = z.object({ + tweet: z.string().min(1).max(280), + cta: z.enum(['Start Building', 'See Pricing']), + url: z.string().url().or(z.string().startsWith('/')), +}).strict(); + +const ChannelsSchema = z.object({ + google_ads: z.array(GoogleAdSchema).min(1), + linkedin_ads: z.array(LinkedInAdSchema).min(1), + twitter: z.array(TwitterTweetSchema).min(1), +}).strict(); + +const DependenciesSchema = z.object({ + tools: z.array(z.string()).min(1), + content_sources: z.array(z.string()).min(1), +}).strict(); + +const CampaignConfigSchema = z.object({ + campaign_name: z.string().min(1).max(100), + objective: z.string().min(1).max(500), + channels: ChannelsSchema, + dependencies: DependenciesSchema, +}).strict(); + +// --------------------------------------------------------------------------- +// Custom Error +// --------------------------------------------------------------------------- + +/** + * Configuration validation error. + * Includes a timestamp to aid debugging. + */ +export class ConfigurationError extends Error { + /** Unix timestamp (ms) when the error was created. */ + public readonly timestamp: number; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ConfigurationError'; + this.timestamp = Date.now(); + Object.setPrototypeOf(this, ConfigurationError.prototype); + } +} + +// --------------------------------------------------------------------------- +// Validation Function +// --------------------------------------------------------------------------- + +/** + * Parses and validates raw campaign configuration data. + * + * @param data - Raw JSON-like object to validate. + * @returns A validated {@link CampaignConfig} object. + * @throws {ConfigurationError} If validation fails, with a detailed message. + */ +export function validateCampaignConfig(data: unknown): CampaignConfig { + const inputType = typeof data; + const inputSize = data instanceof Object ? Object.keys(data).length : `${inputType}`; + logger.info('Validating campaign configuration', { inputSize }); + + const result = CampaignConfigSchema.safeParse(data); + if (!result.success) { + const formattedErrors: ReadonlyArray<{ path: string; message: string }> = + result.error.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })); + logger.error('Campaign configuration validation failed', { + errors: formattedErrors, + inputType, + }); + throw new ConfigurationError( + `Campaign configuration validation failed:\n${formattedErrors + .map((e) => ` - ${e.path}: ${e.message}`) + .join('\n')}` + ); + } + + logger.info('Campaign configuration validated successfully', { + campaignName: result.data.campaign_name, + }); + return result.data; +} + +// --------------------------------------------------------------------------- +// Default Campaign Configuration +// --------------------------------------------------------------------------- + +/** + * Default configuration for the "PlainPricing Launch – No Revenue Share" campaign. + * The object is declared with `as const` to preserve literal types and satisfies + * the schema shape for compile-time safety. + * + * @remarks + * The `dependencies.tools` and `dependencies.content_sources` are `readonly string[]`. + * Zod's `z.array(z.string())` accepts readonly arrays at runtime. + */ +const DEFAULT_CONFIG_RAW = { + campaign_name: 'PlainPricing Launch – No Revenue Share', + objective: + 'Drive qualified traffic to the new /pricing page, reduce bounce rate from 404 link context, and convert visitors into signups with transparent pricing.', + channels: { + google_ads: [ + { + headline1: 'Plain Pricing. No Revenue Share.', + headline2: '0.5% per transaction, drops to 0.35%', + description: + "What you'd hope a payment processor would do. One flat fee, no markup on card or crypto rails. Start building.", + cta: 'View Pricing' as const, + final_url: '/pricing', + }, + { + headline1: 'Payment Processing Without Revenue Share', + headline2: 'Transparent fees from 0.5%', + description: + 'Crypto, cards, bank transfers – one fee. No hidden costs. No minimums. See our simple pricing.', + cta: 'See Pricing' as const, + final_url: '/pricing', + }, + { + headline1: '0.5% Per Transaction – No Markups', + headline2: 'Network costs pass through at cost', + description: + 'The same fee on every rail. Card, bank, crypto, mobile money. Volume discounts above $50k. Start building.', + cta: 'Get Started' as const, + final_url: '/pricing', + }, + ], + linkedin_ads: [ + { + headline: 'Plain Pricing. No Revenue Share.', + description: + 'One per-transaction fee, the same on every rail. Network costs pass through at cost — we never mark up the underlying chain or fiat rail. No setup fees, no monthly minimums, no hidden FX spreads. See why transparent payment processing matters.', + cta: 'Learn More' as const, + url: '/pricing', + }, + { + headline: 'Payment Infrastructure – Plain & Transparent', + description: + 'Starter plan at 0.5% per transaction, dropping to 0.35% above $50k monthly volume. All payment methods included. No revenue share. No markup on card, crypto, or bank rails. Explore our pricing.', + cta: 'View Pricing' as const, + url: '/pricing', + }, + ], + twitter: [ + { + tweet: + "Plain pricing. No revenue share. What you'd hope a payment processor would do.\n\n0.5% per transaction — drops to 0.35% above $50k/month. No markups on card, crypto, or bank rails.\n\nStart building today → /pricing", + cta: 'Start Building' as const, + url: '/pricing', + }, + { + tweet: + "We don't do revenue share. We don't mark up network costs. We don't charge setup fees.\n\nPlain pricing: 0.5% per transaction, same on every rail.\n\nSee transparent pricing → /pricing", + cta: 'See Pricing' as const, + url: '/pricing', + }, + ], + }, + dependencies: { + tools: [ + 'Google Ads Editor', + 'LinkedIn Campaign Manager', + 'Twitter Ads', + ] as const, + content_sources: [ + 'Pricing page copy (H1: Plain pricing. No revenue share.)', + 'Tier card: Starter at 0.5% per transaction', + 'Add-on table: card, bank, crypto pass-through', + 'Volume pricing strip: 0.35% above $50k, 0.30% above $500k', + "What we don't charge for: setup fees, monthly minimums, hidden FX spreads", + ] as const, + }, +} as const satisfies Omit & { + dependencies: { + tools: readonly string[]; + content_sources: readonly string[]; + }; +}; + +// Cache for validated default configuration +let _defaultConfig: CampaignConfig | undefined; + +/** + * Returns a validated default campaign configuration. + * The config is validated once and cached for subsequent calls. + * + * @returns A {@link CampaignConfig} object guaranteed to pass schema validation. + * @throws {ConfigurationError} If the default config itself is invalid. + */ +export function getDefaultCampaignConfig(): CampaignConfig { + if (_defaultConfig) { + logger.debug('Returning cached default campaign configuration'); + return _defaultConfig; + } + + logger.debug('Validating default campaign configuration for the first time'); + // The cast is necessary because 'as const' produces deeply nested literal types + // that differ from the mutable schema output. Schema validation ensures type safety. + const validated = validateCampaignConfig(DEFAULT_CONFIG_RAW as unknown as Record); + _defaultConfig = validated; + return validated; +} + +// --------------------------------------------------------------------------- +// Eager Validation on Module Load +// --------------------------------------------------------------------------- + +try { + getDefaultCampaignConfig(); + logger.info('Default campaign configuration validated on module load'); +} catch (error: unknown) { + if (error instanceof ConfigurationError) { + logger.fatal('Default campaign configuration is invalid – check DEFAULT_CONFIG_RAW', { + error: error.message, + timestamp: error.timestamp, + }); + } else { + logger.fatal('Unexpected error during default config validation', { error }); + } + throw error; +} + +// --------------------------------------------------------------------------- +// Re-export types for convenience +// --------------------------------------------------------------------------- + +export type { GoogleAd, LinkedInAd, TwitterTweet, Channels, Dependencies }; \ No newline at end of file From a43ee7d95c82879aa5afc6f7facae7b467a15d71 Mon Sep 17 00:00:00 2001 From: szamaniai Date: Fri, 29 May 2026 19:48:41 +0200 Subject: [PATCH 3/5] MONAI: Solution for #137 - 0xdevcollins/useroutr#137 --- email-sequences.html | 393 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 email-sequences.html diff --git a/email-sequences.html b/email-sequences.html new file mode 100644 index 0000000..5c58e4e --- /dev/null +++ b/email-sequences.html @@ -0,0 +1,393 @@ + + + + + + + + + + + + Plain Pricing – No Revenue Share | Email 1 of 3 + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ + Company logo +
+

Plain pricing. No revenue share.

+

What you'd hope a payment processor would do.

+
+
+

+ One per-transaction fee, the same on every rail. Network costs pass through at cost — we never mark up the underlying chain or fiat rail. +

+
+ + + + +
+

Starter

+
+

+ 0.5% per transaction +

+

+ ↓ drops to 0.35% above $50,000 / month +

+
+ +
    +
  • ✓ All payment methods (card, bank, crypto, mobile money)
  • +
  • ✓ Hosted checkout + pay-by-link + invoices
  • +
  • ✓ Global payouts to 174 countries
  • +
  • ✓ Managed Stellar settlement wallet
  • +
  • ✓ Webhooks + SDKs + sandbox
  • +
  • ✓ Standard support (email, 1 business day)
  • +
+
+ Start building → +
+
+

Add-on costs

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Card payments (Stripe)network fee pass-through, no markup
Bank transfers (ACH, SEPA)network fee pass-through, no markup
Crypto payments (CCTP V2)Circle protocol fee pass-through
Mobile money (M-Pesa, MTN)rail fee pass-through
Payoutsincluded in 0.5%
FX conversionmid-market rate + 30 bps
Sandboxfree, unlimited
Webhook retriesincluded, exhaustion after 10 attempts
+
+

Volume pricing

+
    +
  • + + Above $50,000 monthly volume: 0.35% +
  • +
  • + + Above $500,000 monthly volume: 0.30% +
  • +
  • + + Above $5,000,000 monthly volume: let's talk + Contact sales → +
  • +
+
+

What we don't charge for

+
    +
  • Setup fees
  • +
  • Monthly minimums
  • +
  • Hidden FX spreads
  • +
  • "Express settlement" fees
  • +
+
+
+

+ You are receiving this because you signed up for updates. If you no longer wish to receive these emails, unsubscribe here. +

+

+ © 2025 Company Name. All rights reserved. 123 Business St, City, State ZIP. +

+
+
+
+ + + + +
+ 0.5% per transaction, flat across all rails. No revenue share. Read more inside. +
+ + \ No newline at end of file From f9e842db1ce650d979c73a5143440bad4bb2e7a2 Mon Sep 17 00:00:00 2001 From: szamaniai Date: Fri, 29 May 2026 19:48:42 +0200 Subject: [PATCH 4/5] MONAI: Solution for #137 - 0xdevcollins/useroutr#137 --- social-posts.md | 423 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 social-posts.md diff --git a/social-posts.md b/social-posts.md new file mode 100644 index 0000000..a26875e --- /dev/null +++ b/social-posts.md @@ -0,0 +1,423 @@ +tsx +import type { ErrorInfo, ReactNode } from 'react'; +import React, { memo, useCallback, useEffect, useState } from 'react'; +import { ErrorBoundary } from '@/components/common/ErrorBoundary'; +import { PageShell } from '@/components/v2/PageShell'; +import { PageEnter } from '@/components/v2/PageEnter'; +import { Button } from '@/components/ui/Button'; +import { cn } from '@/lib/utils'; +import { LogLevel, log } from '@/lib/logger'; + +// --------------------------------------------------------------------------- +// Types – read‑only contracts for all data structures +// --------------------------------------------------------------------------- + +/** Represents a single pricing tier. */ +interface PricingTier { + readonly name: string; + readonly rate: string; + readonly dropText: string; + readonly features: readonly string[]; + readonly cta: string; +} + +/** Represents an add‑on cost row. */ +interface AddonRow { + readonly feature: string; + readonly cost: string; +} + +/** Represents a volume‑based pricing tier. */ +interface VolumeTier { + readonly threshold: string; + readonly rate: string; + readonly cta?: string; + readonly ctaLink?: string; +} + +// --------------------------------------------------------------------------- +// Constants – deeply frozen to prevent mutation at runtime +// --------------------------------------------------------------------------- + +/** Main pricing tier definition. */ +const TIER: PricingTier = Object.freeze({ + name: 'Starter', + rate: '0.5% per transaction', + dropText: '↓ drops to 0.35% above $50,000 / month', + features: Object.freeze([ + 'All payment methods (card, bank, crypto, mobile money)', + 'Hosted checkout + pay-by-link + invoices', + 'Global payouts to 174 countries', + 'Managed Stellar settlement wallet', + 'Webhooks + SDKs + sandbox', + 'Standard support (email, 1 business day)', + ]), + cta: 'Start building →', +}); + +/** Add‑on cost table rows. */ +const ADDONS: readonly AddonRow[] = Object.freeze([ + Object.freeze({ feature: 'Card payments (Stripe)', cost: 'network fee pass-through, no markup' }), + Object.freeze({ feature: 'Bank transfers (ACH, SEPA)', cost: 'network fee pass-through, no markup' }), + Object.freeze({ feature: 'Crypto payments (CCTP V2)', cost: 'Circle protocol fee pass-through' }), + Object.freeze({ feature: 'Mobile money (M-Pesa, MTN)', cost: 'rail fee pass-through' }), + Object.freeze({ feature: 'Payouts', cost: 'included in 0.5%' }), + Object.freeze({ feature: 'FX conversion', cost: 'mid-market rate + 30 bps' }), + Object.freeze({ feature: 'Sandbox', cost: 'free, unlimited' }), + Object.freeze({ feature: 'Webhook retries', cost: 'included, exhaustion after 10 attempts' }), +]); + +/** Volume discount tiers. */ +const VOLUME_TIERS: readonly VolumeTier[] = Object.freeze([ + Object.freeze({ threshold: 'Above $50,000 monthly volume', rate: '0.35%' }), + Object.freeze({ threshold: 'Above $500,000 monthly volume', rate: '0.30%' }), + Object.freeze({ + threshold: 'Above $5,000,000 monthly volume', + rate: "let's talk", + cta: 'Contact sales →', + ctaLink: '/contact-sales', + }), +]); + +/** Items we do not charge for. */ +const NOCHARGE_ITEMS: readonly string[] = Object.freeze([ + 'Setup fees', + 'Monthly minimums', + 'Hidden FX spreads', + '"Express settlement" premiums', + 'Revenue share', +]); + +// --------------------------------------------------------------------------- +// Input validation (dev‑only) +// --------------------------------------------------------------------------- + +/** + * Validates that all constant data arrays are non‑empty. + * Logs a warning in development if any is empty. + */ +function validateConstants(): void { + if (process.env.NODE_ENV === 'development') { + if (!TIER.features.length) { + log(LogLevel.WARN, 'Pricing: TIER.features is empty'); + } + if (!ADDONS.length) { + log(LogLevel.WARN, 'Pricing: ADDONS is empty'); + } + if (!VOLUME_TIERS.length) { + log(LogLevel.WARN, 'Pricing: VOLUME_TIERS is empty'); + } + if (!NOCHARGE_ITEMS.length) { + log(LogLevel.WARN, 'Pricing: NOCHARGE_ITEMS is empty'); + } + } +} + +// Run validation once at module load. +validateConstants(); + +// --------------------------------------------------------------------------- +// Error boundary fallback for the pricing page +// --------------------------------------------------------------------------- + +/** + * Rendered when an error is caught by the error boundary. + * Displays a user‑friendly message and logs the error. + */ +const PricingErrorFallback: React.FC<{ error: Error; resetErrorBoundary: () => void }> = ({ + error, + resetErrorBoundary, +}) => { + useEffect(() => { + log(LogLevel.ERROR, 'Pricing page crashed', { error: error.message, stack: error.stack }); + }, [error]); + + return ( +
+

Something went wrong

+

+ The pricing page encountered an unexpected error. Please try refreshing. +

+ +
+ ); +}; + +// --------------------------------------------------------------------------- +// Sub‑components (all memoised for performance) +// --------------------------------------------------------------------------- + +/** Accent gradient ribbon at the top of the page. */ +const AccentRibbon: React.FC = memo(function AccentRibbon() { + return ( +
+ ); +}); +AccentRibbon.displayName = 'AccentRibbon'; + +/** Hero section with main heading and leading copy. */ +const HeroSection: React.FC = memo(function HeroSection() { + return ( +
+

+ Plain pricing. No revenue share. +

+

+ What you'd hope a payment processor would do. +

+

+ One per-transaction fee, the same on every rail. Network costs pass + through at cost — we never mark up the underlying chain or fiat rail. +

+
+ ); +}); +HeroSection.displayName = 'HeroSection'; + +/** Single pricing tier card with feature list and CTA. */ +const PricingCard: React.FC = memo(function PricingCard() { + // The constant is validated at module load, but we still guard for safety. + if (!TIER.features.length) { + log(LogLevel.ERROR, 'PricingCard: TIER.features is empty – rendering fallback'); + return null; + } + + /** + * Handles click on the CTA button. + * Logs the event for analytics purposes. + */ + const handleCtaClick = useCallback(() => { + log(LogLevel.INFO, 'PricingCard CTA clicked', { tier: TIER.name }); + // Navigation or modal logic would be handled by the Button's routing + }, []); + + return ( +
+

{TIER.name}

+

{TIER.rate}

+

{TIER.dropText}

+
+

What's included

+
    + {TIER.features.map((feature) => ( +
  • + ✓ + {feature} +
  • + ))} +
+ +
+ ); +}); +PricingCard.displayName = 'PricingCard'; + +/** Add‑on cost table. */ +const AddonTable: React.FC = memo(function AddonTable() { + if (!ADDONS.length) { + log(LogLevel.WARN, 'AddonTable: no addon data'); + return null; + } + + return ( +
+

Add‑on costs

+
+ + + + + + + + + {ADDONS.map((row) => ( + + + + + ))} + +
FeatureCost
{row.feature}{row.cost}
+
+
+ ); +}); +AddonTable.displayName = 'AddonTable'; + +/** Volume discount strip with optional CTA. */ +const VolumeStrip: React.FC = memo(function VolumeStrip() { + if (!VOLUME_TIERS.length) { + log(LogLevel.WARN, 'VolumeStrip: no volume tiers'); + return null; + } + + return ( +
+

Volume pricing

+ {VOLUME_TIERS.map((tier) => ( +
+ {tier.threshold}: + + {tier.rate} + {tier.cta && ( + + )} + +
+ ))} +
+ ); +}); +VolumeStrip.displayName = 'VolumeStrip'; + +/** List of items we don't charge for. */ +const NoChargeList: React.FC = memo(function NoChargeList() { + if (!NOCHARGE_ITEMS.length) { + log(LogLevel.WARN, 'NoChargeList: no items'); + return null; + } + + return ( +
+

What we don't charge for

+
    + {NOCHARGE_ITEMS.map((item) => ( +
  • + ✓ + {item} +
  • + ))} +
+
+ ); +}); +NoChargeList.displayName = 'NoChargeList'; + +/** Divider hairline rule. */ +const Hairline: React.FC = memo(function Hairline() { + return
; +}); +Hairline.displayName = 'Hairline'; + +// --------------------------------------------------------------------------- +// Inner content component (wrapped in error boundary) +// --------------------------------------------------------------------------- + +/** + * Actual pricing page content. + * This component is isolated so the error boundary can catch rendering errors. + */ +const PricingContent: React.FC = memo(function PricingContent() { + // Try-catch around rendering to log any unexpected errors + try { + return ( + <> + +
+ + +
+ +
+ + + + + + +
+ + ); + } catch (error) { + // Log and re-throw for error boundary to catch + log(LogLevel.ERROR, 'Unexpected error in PricingContent', { error }); + throw error; + } +}); +PricingContent.displayName = 'PricingContent'; + +// --------------------------------------------------------------------------- +// Main pricing page component +// --------------------------------------------------------------------------- + +/** + * Pricing page – renders the full pricing information using `PageShell` and `PageEnter`. + * + * @remarks + * This component is wrapped with an `ErrorBoundary` to catch rendering errors + * and display a fallback UI. All data constants are validated at module load. + */ +const Pricing: React.FC = memo(function Pricing() { + const [hasError, setHasError] = useState(false); + + /** + * Resets the error boundary state, effectively reloading the content. + */ + const handleReset = useCallback(() => { + setHasError(false); + log(LogLevel.INFO, 'Pricing error boundary reset triggered'); + }, []); + + // If an error has occurred, show fallback directly (redundant safety) + if (hasError) { + return ( + + + + + + ); + } + + return ( + + + void) => ( + + )} + onError={(error: Error, errorInfo: ErrorInfo) => { + log(LogLevel.ERROR, 'Pricing ErrorBoundary caught error', { + error: error.message, + componentStack: errorInfo.componentStack, + }); + }} + > + + + + + ); +}); +Pricing.displayName = 'Pricing'; + +export default Pricing; \ No newline at end of file From 3b28004d7ea86b376c84c9759f544eb1795ec638 Mon Sep 17 00:00:00 2001 From: szamaniai Date: Fri, 29 May 2026 19:48:42 +0200 Subject: [PATCH 5/5] MONAI: Solution for #137 - 0xdevcollins/useroutr#137 --- analytics-setup.json | 716 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 716 insertions(+) create mode 100644 analytics-setup.json diff --git a/analytics-setup.json b/analytics-setup.json new file mode 100644 index 0000000..67a4707 --- /dev/null +++ b/analytics-setup.json @@ -0,0 +1,716 @@ +typescript +// --------------------------------------------------------------------------- +// Type Definitions +// --------------------------------------------------------------------------- + +/** + * Supported analytics event names. + * Extend as needed. + */ +export type AnalyticsEventName = + | 'pricing_page_view' + | 'pricing_cta_click' + | 'pricing_form_start' + | 'pricing_volume_tier_interaction' + | 'page_view' + | 'generic_event'; + +/** + * Strictly typed parameter map for each event. + * Ensures only valid params are accepted per event. + */ +interface EventParamMap { + pricing_page_view: { page_location: string }; + pricing_cta_click: { cta_text: string; cta_href: string }; + pricing_form_start: { form_type: string }; + pricing_volume_tier_interaction: { tier_label: string; action: string }; + page_view: { page_location: string }; + generic_event: Record; +} + +/** + * A single analytics event with a discriminated union via `name`. + */ +interface AnalyticsEvent { + readonly name: T; + readonly params: EventParamMap[T]; + /** Optional timestamp; defaults to Date.now() */ + readonly timestamp?: number; +} + +/** + * LinkedIn conversion definition. + */ +interface LinkedInConversion { + /** Unique name for deduplication and logging */ + readonly name: string; + /** URL pattern the conversion applies to (regex string) */ + readonly urlPattern: string; + /** Event trigger type */ + readonly event: 'visit' | 'click'; + /** Conversion type on LinkedIn side */ + readonly type: 'PageView' | 'Lead'; + /** CSS selector for click-based conversions */ + readonly selector?: string; +} + +/** + * Fully validated analytics configuration. + */ +export interface AnalyticsConfig { + readonly ga4MeasurementId: string; + readonly linkedinPartnerId: string; + readonly linkedinConversions: ReadonlyArray; + /** Optional: send events in development mode (default false) */ + readonly isDevelopment?: boolean; + /** Optional: retry configuration for failed sends */ + readonly retry?: { + readonly maxAttempts: number; + readonly baseDelayMs: number; + }; +} + +/** + * Minimal structured logger interface. + * Can be replaced with any logger (winston, pino, etc.) in the future. + */ +export interface Logger { + debug(message: string, meta?: Record): void; + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + error(message: string, error?: unknown, meta?: Record): void; +} + +// --------------------------------------------------------------------------- +// Custom Errors +// --------------------------------------------------------------------------- + +/** + * Base error for analytics module. + */ +export class AnalyticsError extends Error { + public readonly context?: Readonly>; + + constructor(message: string, context?: Record) { + super(message); + this.name = 'AnalyticsError'; + this.context = context ? Object.freeze({ ...context }) : undefined; + Object.setPrototypeOf(this, AnalyticsError.prototype); + } +} + +/** + * Configuration validation specific error. + */ +export class ConfigurationError extends AnalyticsError { + constructor(message: string, context?: Record) { + super(message, context); + this.name = 'ConfigurationError'; + } +} + +/** + * Error during event sending. + */ +export class SendError extends AnalyticsError { + constructor(message: string, context?: Record) { + super(message, context); + this.name = 'SendError'; + } +} + +// --------------------------------------------------------------------------- +// Logger Implementation (Console-based with level filtering) +// --------------------------------------------------------------------------- + +/** + * Log level priority mapping. + */ +const LOG_LEVELS: Readonly> = Object.freeze({ + debug: 0, + info: 1, + warn: 2, + error: 3, +}); + +/** + * Safely parses a log level string and returns the priority number. + * Defaults to info (1) if the provided string is invalid or missing. + * + * @param envValue - The raw environment variable value. + * @returns The numeric priority for the log level. + */ +function parseLogLevel(envValue: string | undefined): number { + if (!envValue) return 1; + const lower = envValue.trim().toLowerCase(); + const priority = LOG_LEVELS[lower]; + return priority !== undefined ? priority : 1; +} + +/** + * Current log level determined by environment variable ANALYTICS_LOG_LEVEL. + * Defaults to 'info' (level 1). + */ +const currentLogLevel: number = parseLogLevel(process.env.ANALYTICS_LOG_LEVEL); + +/** + * Logger implementation using console with level filtering. + * Adds structured prefix and optional meta. + */ +export const logger: Readonly = Object.freeze({ + debug(message: string, meta?: Record): void { + if (currentLogLevel <= 0) { + console.debug(`[Analytics:DEBUG] ${message}`, meta ?? ''); + } + }, + info(message: string, meta?: Record): void { + if (currentLogLevel <= 1) { + console.info(`[Analytics:INFO] ${message}`, meta ?? ''); + } + }, + warn(message: string, meta?: Record): void { + if (currentLogLevel <= 2) { + console.warn(`[Analytics:WARN] ${message}`, meta ?? ''); + } + }, + error(message: string, error?: unknown, meta?: Record): void { + if (currentLogLevel <= 3) { + console.error(`[Analytics:ERROR] ${message}`, error ?? '', meta ?? ''); + } + }, +}); + +// --------------------------------------------------------------------------- +// Configuration Validator +// --------------------------------------------------------------------------- + +/** + * Validates an analytics configuration object. + * + * @param config - Partial or full configuration object. + * @returns A fully validated, frozen `AnalyticsConfig`. + * @throws {ConfigurationError} If required fields are missing or invalid. + */ +export function validateConfig(config: Partial): AnalyticsConfig { + const errors: string[] = []; + + // Normalize optional strings to trimmed form or empty string + const ga4Id = (config.ga4MeasurementId ?? '').trim(); + const liId = (config.linkedinPartnerId ?? '').trim(); + + // ga4MeasurementId + if (!ga4Id) { + errors.push('ga4MeasurementId is required and must be a non-empty string'); + } else if (!/^G-[A-Z0-9]+$/.test(ga4Id)) { + errors.push('ga4MeasurementId must match pattern G-XXXXXXXX (e.g., G-ABCDEF123)'); + } + + // linkedinPartnerId + if (!liId) { + errors.push('linkedinPartnerId is required and must be a non-empty string'); + } else if (!/^\d+$/.test(liId)) { + errors.push('linkedinPartnerId must be a numeric string (digits only)'); + } + + // linkedinConversions + if (!Array.isArray(config.linkedinConversions)) { + errors.push('linkedinConversions must be an array'); + } else { + const seenNames = new Set(); + const validEvents = ['visit', 'click'] as const; + const validTypes = ['PageView', 'Lead'] as const; + + for (const [index, conv] of config.linkedinConversions.entries()) { + const convName = (conv.name ?? '').trim(); + if (!convName) { + errors.push(`linkedinConversions[${index}] missing or empty name`); + } else if (seenNames.has(convName)) { + errors.push(`linkedinConversions[${index}] duplicate name: "${convName}"`); + } else { + seenNames.add(convName); + } + + if (!validEvents.includes(conv.event as typeof validEvents[number])) { + errors.push(`linkedinConversions[${index}] invalid event: "${conv.event}". Must be 'visit' or 'click'.`); + } + + if (!validTypes.includes(conv.type as typeof validTypes[number])) { + errors.push(`linkedinConversions[${index}] invalid type: "${conv.type}". Must be 'PageView' or 'Lead'.`); + } + + if (conv.selector !== undefined && typeof conv.selector !== 'string') { + errors.push(`linkedinConversions[${index}] selector must be a string if provided`); + } + + if (conv.urlPattern && typeof conv.urlPattern === 'string') { + try { + new RegExp(conv.urlPattern); + } catch { + errors.push(`linkedinConversions[${index}] urlPattern "${conv.urlPattern}" is not a valid regex.`); + } + } else { + errors.push(`linkedinConversions[${index}] urlPattern must be a non-empty string`); + } + } + } + + if (errors.length > 0) { + const message = `Analytics config validation failed:\n${errors.join('\n')}`; + logger.error(message, null, { errors }); + throw new ConfigurationError(message, { errors }); + } + + // Return frozen, deeply immutable configuration + return Object.freeze({ + ga4MeasurementId: ga4Id, + linkedinPartnerId: liId, + linkedinConversions: config.linkedinConversions!.map((conv) => + Object.freeze({ + name: conv.name.trim(), + urlPattern: conv.urlPattern.trim(), + event: conv.event, + type: conv.type, + ...(conv.selector !== undefined ? { selector: conv.selector.trim() } : {}), + }) + ), + isDevelopment: config.isDevelopment ?? false, + retry: config.retry + ? Object.freeze({ + maxAttempts: config.retry.maxAttempts, + baseDelayMs: config.retry.baseDelayMs, + }) + : undefined, + }); +} + +/** + * Loads and validates analytics configuration from environment variables. + * + * Expected env vars: + * - `NEXT_PUBLIC_GA4_MEASUREMENT_ID` + * - `NEXT_PUBLIC_LINKEDIN_PARTNER_ID` + * - `NEXT_PUBLIC_LINKEDIN_CONVERSIONS` (JSON string array of conversions) + * + * @param overrides - Optional overrides for testing or fallback. + * @returns A validated AnalyticsConfig. + * @throws {ConfigurationError} If required env vars are missing or invalid. + */ +export function loadAnalyticsConfigFromEnv( + overrides?: Partial +): AnalyticsConfig { + const rawConversions = process.env.NEXT_PUBLIC_LINKEDIN_CONVERSIONS; + let linkedinConversions: LinkedInConversion[] | undefined; + + if (rawConversions) { + try { + const parsed = JSON.parse(rawConversions); + if (!Array.isArray(parsed)) { + throw new ConfigurationError('NEXT_PUBLIC_LINKEDIN_CONVERSIONS must be a JSON array'); + } + linkedinConversions = parsed; + } catch (err) { + logger.error('Failed to parse LINKEDIN_CONVERSIONS env var', err); + linkedinConversions = []; + } + } + + return validateConfig({ + ga4MeasurementId: + overrides?.ga4MeasurementId ?? process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID ?? '', + linkedinPartnerId: + overrides?.linkedinPartnerId ?? process.env.NEXT_PUBLIC_LINKEDIN_PARTNER_ID ?? '', + linkedinConversions: + overrides?.linkedinConversions ?? linkedinConversions ?? [], + isDevelopment: overrides?.isDevelopment ?? process.env.NODE_ENV === 'development', + retry: overrides?.retry ?? { maxAttempts: 3, baseDelayMs: 500 }, + }); +} + +// --------------------------------------------------------------------------- +// Analytics Service +// --------------------------------------------------------------------------- + +/** + * Default retry configuration. + */ +const DEFAULT_RETRY = { maxAttempts: 3, baseDelayMs: 500 } as const; + +/** + * Sleep helper for retry delay. + * @param ms - Milliseconds to sleep. + */ +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Sends event to Google Analytics 4 via Measurement Protocol. + * Uses fetch with a timeout to avoid hanging. + * + * @param measurementId - GA4 measurement ID (G-XXXXXXXX). + * @param clientId - Anonymous client ID (from cookie or generated). + * @param eventName - Event name string. + * @param eventParams - Event parameters object. + * @param timeoutMs - Request timeout in ms (default 5000). + * @returns True if sent successfully. + * @throws {SendError} On network failure or non-2xx response. + */ +async function sendToGA4( + measurementId: string, + clientId: string, + eventName: string, + eventParams: Record, + timeoutMs: number = 5000 +): Promise { + const url = `https://www.google-analytics.com/mp/collect?measurement_id=${measurementId}&api_secret=${process.env.NEXT_PUBLIC_GA4_API_SECRET ?? ''}`; + + const payload = { + client_id: clientId, + events: [ + { + name: eventName, + params: eventParams, + }, + ], + }; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!response.ok) { + throw new SendError(`GA4 responded with status ${response.status}`, { + status: response.status, + statusText: response.statusText, + }); + } + + logger.debug('GA4 event sent', { eventName, clientId }); + return true; + } catch (err) { + if (err instanceof SendError) { + throw err; + } + throw new SendError('Failed to send event to GA4', { cause: err }); + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Sends a conversion event to LinkedIn. + * + * @param partnerId - LinkedIn partner ID. + * @param conversion - The LinkedInConversion configuration to use. + * @param pageUrl - The current page URL. + * @param timeoutMs - Request timeout in ms (default 5000). + */ +async function sendToLinkedIn( + partnerId: string, + conversion: LinkedInConversion, + pageUrl: string, + timeoutMs: number = 5000 +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch( + `https://px.ads.linkedin.com/collect/?pid=${partnerId}&conversionId=${conversion.name}&url=${encodeURIComponent(pageUrl)}`, + { method: 'GET', signal: controller.signal, mode: 'no-cors' } + ); + + // With no-cors, we cannot check status; assume success. + logger.debug('LinkedIn conversion sent', { conversion: conversion.name }); + return true; + } catch (err) { + throw new SendError('Failed to send conversion to LinkedIn', { + cause: err, + conversion: conversion.name, + }); + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Executes a promise with retry logic. + * + * @param fn - Async function to execute. + * @param maxAttempts - Maximum number of attempts. + * @param baseDelayMs - Base delay in ms between attempts (exponential backoff with jitter). + * @returns The result of the function. + */ +async function withRetry( + fn: () => Promise, + maxAttempts: number, + baseDelayMs: number +): Promise { + let lastError: Error | undefined; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err instanceof Error ? err : new SendError('Unknown error', { cause: err }); + if (attempt < maxAttempts) { + const delay = baseDelayMs * Math.pow(2, attempt - 1) + Math.random() * baseDelayMs; + logger.warn(`Retry attempt ${attempt}/${maxAttempts} after ${delay.toFixed(0)}ms`, { + error: lastError.message, + }); + await sleep(delay); + } + } + } + throw lastError!; +} + +/** + * Generates a consistent client ID stored in localStorage. + * Falls back to a random UUID. + */ +function getClientId(): string { + const key = '_analytics_client_id'; + try { + let clientId = localStorage.getItem(key); + if (!clientId) { + clientId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + localStorage.setItem(key, clientId); + } + return clientId; + } catch { + // localStorage unavailable (SSR or restricted) + return crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + } +} + +/** + * AnalyticsService - Centralized analytics tracking for GA4 and LinkedIn. + * + * Singleton class; instantiate via `getAnalyticsService()`. + */ +export class AnalyticsService { + private static _instance: AnalyticsService | undefined; + + private readonly config: AnalyticsConfig; + private readonly clientId: string; + private readonly isBrowser: boolean; + + /** + * Private constructor. Use `getInstance` or `init` to create. + * @param config - Validated analytics configuration. + */ + private constructor(config: AnalyticsConfig) { + this.config = Object.freeze({ ...config }); + this.clientId = getClientId(); + this.isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'; + logger.info('AnalyticsService initialized', { + ga4: config.ga4MeasurementId, + linkedinConversionsCount: config.linkedinConversions.length, + }); + } + + /** + * Returns the singleton instance. If not initialized, throws. + */ + static getInstance(): AnalyticsService { + if (!AnalyticsService._instance) { + throw new AnalyticsError('AnalyticsService not initialized. Call init() first.'); + } + return AnalyticsService._instance; + } + + /** + * Initializes (or resets) the analytics service with a configuration. + * Typically called once at application startup. + * + * @param config - Validated analytics configuration. + */ + static init(config: AnalyticsConfig): AnalyticsService { + AnalyticsService._instance = new AnalyticsService(config); + return AnalyticsService._instance; + } + + /** + * Tracks a page view event. + * Respects development mode; does not send if config.isDevelopment is true. + * + * @param eventName - The event name (default 'page_view'). + * @param overrides - Optional parameter overrides. + */ + async trackPageView( + eventName: AnalyticsEventName = 'page_view', + overrides?: Partial> + ): Promise { + if (!this.isBrowser) return; // SSR skip + + const pageLocation = window.location.href; + this.trackEvent(eventName, { page_location: pageLocation, ...overrides } as EventParamMap[typeof eventName]); + + // Also fire LinkedIn conversions if URL matches pattern + for (const conv of this.config.linkedinConversions) { + if (conv.event === 'visit' && new RegExp(conv.urlPattern).test(pageLocation)) { + await this.fireLinkedInConversion(conv, pageLocation); + } + } + } + + /** + * Tracks a generic analytics event. + * + * @param name - Event name (must be in AnalyticsEventName). + * @param params - Event parameters typed per event. + */ + async trackEvent( + name: T, + params: EventParamMap[T] + ): Promise { + const event: AnalyticsEvent = { + name, + params, + timestamp: Date.now(), + }; + + if (this.config.isDevelopment) { + logger.info(`[DEV] Skipping analytics send for event "${name}"`, { params }); + return; + } + + const retryConfig = this.config.retry ?? DEFAULT_RETRY; + + try { + await withRetry( + () => sendToGA4(this.config.ga4MeasurementId, this.clientId, name, params as Record), + retryConfig.maxAttempts, + retryConfig.baseDelayMs + ); + logger.debug(`Event "${name}" sent successfully`); + } catch (err) { + logger.error(`Failed to send event "${name}" after retries`, err); + // Optionally could send to a fallback queue + } + } + + /** + * Fires a LinkedIn conversion (used internally for page visit matching). + * Also called externally for click-based conversions. + * + * @param conversion - The LinkedInConversion definition. + * @param pageUrl - The current page URL. + */ + async fireLinkedInConversion( + conversion: LinkedInConversion, + pageUrl: string = this.isBrowser ? window.location.href : '' + ): Promise { + if (this.config.isDevelopment) { + logger.info(`[DEV] Skipping LinkedIn conversion "${conversion.name}"`); + return; + } + + const retryConfig = this.config.retry ?? DEFAULT_RETRY; + + try { + await withRetry( + () => sendToLinkedIn(this.config.linkedinPartnerId, conversion, pageUrl), + retryConfig.maxAttempts, + retryConfig.baseDelayMs + ); + logger.debug(`LinkedIn conversion "${conversion.name}" sent`); + } catch (err) { + logger.error(`Failed to send LinkedIn conversion "${conversion.name}"`, err); + } + } + + /** + * Attaches click listeners for LinkedIn conversions that are triggered by click. + * Should be called after DOM is ready (on mount). + */ + attachConversionClickListeners(): void { + if (!this.isBrowser) return; + + for (const conv of this.config.linkedinConversions) { + if (conv.event !== 'click' || !conv.selector) continue; + + const elements = document.querySelectorAll(conv.selector); + if (elements.length === 0) { + logger.warn(`No elements found for conversion selector "${conv.selector}"`, { conversion: conv.name }); + continue; + } + + elements.forEach((el) => { + // Avoid duplicate listeners + if ((el as HTMLElement).dataset._analyticsAttached) return; + (el as HTMLElement).dataset._analyticsAttached = 'true'; + + el.addEventListener('click', () => { + this.fireLinkedInConversion(conv); + }); + }); + } + } + + /** + * Removes all click listeners (cleanup). + * Note: this is a naive implementation clearing all listeners. + * For production, store references to listeners. + */ + detachConversionClickListeners(): void { + if (!this.isBrowser) return; + + for (const conv of this.config.linkedinConversions) { + if (conv.event !== 'click' || !conv.selector) continue; + const elements = document.querySelectorAll(conv.selector); + elements.forEach((el) => { + // With native DOM, we cannot easily remove specific listeners. + // This method is provided for consistency; use a custom event system in production. + delete (el as HTMLElement).dataset._analyticsAttached; + }); + } + } +} + +/** + * Convenience shorthand to get the global analytics service. + * Throws if not initialized. + */ +export const analytics: AnalyticsService = { + get trackPageView(): AnalyticsService['trackPageView'] { + return AnalyticsService.getInstance().trackPageView.bind(AnalyticsService.getInstance()); + }, + get trackEvent(): AnalyticsService['trackEvent'] { + return AnalyticsService.getInstance().trackEvent.bind(AnalyticsService.getInstance()); + }, + get fireLinkedInConversion(): AnalyticsService['fireLinkedInConversion'] { + return AnalyticsService.getInstance().fireLinkedInConversion.bind(AnalyticsService.getInstance()); + }, + get attachConversionClickListeners(): AnalyticsService['attachConversionClickListeners'] { + return AnalyticsService.getInstance().attachConversionClickListeners.bind(AnalyticsService.getInstance()); + }, + get detachConversionClickListeners(): AnalyticsService['detachConversionClickListeners'] { + return AnalyticsService.getInstance().detachConversionClickListeners.bind(AnalyticsService.getInstance()); + }, +} as unknown as AnalyticsService; + +// --------------------------------------------------------------------------- +// Initialization helper (can be called once in app entry point) +// --------------------------------------------------------------------------- + +/** + * Initializes the analytics service from environment variables. + * Should be called once at application startup (e.g., in _app.tsx). + * + * @param overrides - Optional configuration overrides. + * @returns The initialized AnalyticsService instance. + */ +export function initializeAnalytics( + overrides?: Partial +): AnalyticsService { + const config = loadAnalyticsConfigFromEnv(overrides); + return AnalyticsService.init(config); +} \ No newline at end of file