@wakastellar/ui 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/components/index.d.ts +15 -0
  2. package/dist/components/waka-ad-banner/index.d.ts +36 -0
  3. package/dist/components/waka-ad-fallback/index.d.ts +33 -0
  4. package/dist/components/waka-ad-inline/index.d.ts +15 -0
  5. package/dist/components/waka-ad-interstitial/index.d.ts +26 -0
  6. package/dist/components/waka-ad-placeholder/index.d.ts +17 -0
  7. package/dist/components/waka-ad-provider/index.d.ts +103 -0
  8. package/dist/components/waka-ad-sidebar/index.d.ts +18 -0
  9. package/dist/components/waka-ad-sticky-footer/index.d.ts +17 -0
  10. package/dist/components/waka-content-recommendation/index.d.ts +23 -0
  11. package/dist/components/waka-outstream-video/index.d.ts +24 -0
  12. package/dist/components/waka-sponsored-badge/index.d.ts +20 -0
  13. package/dist/components/waka-sponsored-card/index.d.ts +25 -0
  14. package/dist/components/waka-sponsored-feed/index.d.ts +31 -0
  15. package/dist/components/waka-video-ad/index.d.ts +32 -0
  16. package/dist/components/waka-video-overlay/index.d.ts +26 -0
  17. package/dist/index.cjs.js +177 -171
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.es.js +14535 -12812
  20. package/dist/utils/security.d.ts +96 -0
  21. package/package.json +4 -4
  22. package/src/blocks/sidebar/index.tsx +6 -6
  23. package/src/components/DataTable/templates/index.tsx +3 -2
  24. package/src/components/index.ts +94 -0
  25. package/src/components/waka-3d-pie-chart/index.tsx +11 -11
  26. package/src/components/waka-achievement-unlock/index.tsx +16 -16
  27. package/src/components/waka-ad-banner/index.tsx +275 -0
  28. package/src/components/waka-ad-fallback/index.tsx +181 -0
  29. package/src/components/waka-ad-inline/index.tsx +103 -0
  30. package/src/components/waka-ad-interstitial/index.tsx +278 -0
  31. package/src/components/waka-ad-placeholder/index.tsx +84 -0
  32. package/src/components/waka-ad-provider/index.tsx +329 -0
  33. package/src/components/waka-ad-sidebar/index.tsx +113 -0
  34. package/src/components/waka-ad-sticky-footer/index.tsx +125 -0
  35. package/src/components/waka-badge-showcase/index.tsx +12 -11
  36. package/src/components/waka-command-bar/index.tsx +2 -1
  37. package/src/components/waka-content-recommendation/index.tsx +294 -0
  38. package/src/components/waka-cost-breakdown/index.tsx +10 -10
  39. package/src/components/waka-funnel-chart/index.tsx +8 -8
  40. package/src/components/waka-health-pulse/index.tsx +6 -6
  41. package/src/components/waka-leaderboard/index.tsx +9 -9
  42. package/src/components/waka-loot-box/index.tsx +20 -20
  43. package/src/components/waka-outstream-video/index.tsx +240 -0
  44. package/src/components/waka-player-card/index.tsx +5 -5
  45. package/src/components/waka-quota-bar/index.tsx +4 -4
  46. package/src/components/waka-radar-score/index.tsx +10 -10
  47. package/src/components/waka-scratch-card/index.tsx +5 -4
  48. package/src/components/waka-server-rack/index.tsx +28 -27
  49. package/src/components/waka-sponsored-badge/index.tsx +97 -0
  50. package/src/components/waka-sponsored-card/index.tsx +275 -0
  51. package/src/components/waka-sponsored-feed/index.tsx +127 -0
  52. package/src/components/waka-spotlight/index.tsx +2 -1
  53. package/src/components/waka-success-explosion/index.tsx +4 -4
  54. package/src/components/waka-video-ad/index.tsx +406 -0
  55. package/src/components/waka-video-overlay/index.tsx +257 -0
  56. package/src/components/waka-xp-bar/index.tsx +13 -13
  57. package/src/styles/base.css +16 -0
  58. package/src/styles/tailwind.preset.js +12 -0
  59. package/src/styles/themes/forest.css +16 -0
  60. package/src/styles/themes/monochrome.css +16 -0
  61. package/src/styles/themes/perpetuity.css +16 -0
  62. package/src/styles/themes/sunset.css +16 -0
  63. package/src/styles/themes/twilight.css +16 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Security utilities for sanitizing user input and preventing XSS/injection attacks.
3
+ *
4
+ * @module security
5
+ */
6
+ /**
7
+ * Validates and sanitizes a URL to prevent javascript: protocol attacks.
8
+ *
9
+ * @param url - The URL to validate
10
+ * @returns The sanitized URL if safe, or undefined if potentially malicious
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const safeUrl = sanitizeUrl("https://example.com") // "https://example.com"
15
+ * const blocked = sanitizeUrl("javascript:alert(1)") // undefined
16
+ * ```
17
+ *
18
+ * @security
19
+ * This function blocks:
20
+ * - javascript: protocol URLs
21
+ * - data: protocol URLs (except data:image for specific use cases)
22
+ * - vbscript: protocol URLs
23
+ * - URLs with encoded dangerous protocols
24
+ */
25
+ export declare function sanitizeUrl(url: string | undefined | null): string | undefined;
26
+ /**
27
+ * Safely navigates to a URL after validation.
28
+ * Use this instead of directly assigning to window.location.href.
29
+ *
30
+ * @param url - The URL to navigate to
31
+ * @returns true if navigation was performed, false if blocked
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * // Safe navigation
36
+ * safeNavigate("https://example.com") // navigates
37
+ * safeNavigate("javascript:alert(1)") // blocked, returns false
38
+ * ```
39
+ */
40
+ export declare function safeNavigate(url: string | undefined | null): boolean;
41
+ /**
42
+ * Escapes special regex characters in a string.
43
+ * Use this when creating RegExp from user input to prevent ReDoS attacks.
44
+ *
45
+ * @param str - The string to escape
46
+ * @returns The escaped string safe for use in RegExp
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * const pattern = escapeRegex("user.name") // "user\\.name"
51
+ * const regex = new RegExp(pattern, "i")
52
+ * ```
53
+ *
54
+ * @security
55
+ * This prevents:
56
+ * - ReDoS (Regular Expression Denial of Service) attacks
57
+ * - Unintended regex matching due to special characters
58
+ */
59
+ export declare function escapeRegex(str: string): string;
60
+ /**
61
+ * Creates a safe RegExp from user input by escaping special characters.
62
+ *
63
+ * @param pattern - The pattern string (will be escaped)
64
+ * @param flags - Optional regex flags
65
+ * @returns A safe RegExp instance
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * const regex = createSafeRegex("search.term", "gi")
70
+ * // Matches literal "search.term" not "search" + any char + "term"
71
+ * ```
72
+ */
73
+ export declare function createSafeRegex(pattern: string, flags?: string): RegExp;
74
+ /**
75
+ * Creates a RegExp for highlighting text in search results.
76
+ * Escapes the search term and wraps in capture group for splitting.
77
+ *
78
+ * @param searchTerm - The term to highlight
79
+ * @returns A case-insensitive RegExp for highlighting
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * const regex = createHighlightRegex("search")
84
+ * const parts = "Search results".split(regex)
85
+ * // ["", "Search", " results"]
86
+ * ```
87
+ */
88
+ export declare function createHighlightRegex(searchTerm: string): RegExp;
89
+ /**
90
+ * Sanitizes HTML content by escaping dangerous characters.
91
+ * Use when displaying user-generated content.
92
+ *
93
+ * @param html - The HTML string to sanitize
94
+ * @returns Escaped HTML safe for display
95
+ */
96
+ export declare function escapeHtml(html: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wakastellar/ui",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Zero-config UI Library for Next.js with TweakCN theming and i18n support",
5
5
  "keywords": [
6
6
  "ui",
@@ -107,7 +107,7 @@
107
107
  "@tiptap/extension-underline": "^2.0.0 || ^3.0.0",
108
108
  "@tiptap/react": "^2.0.0 || ^3.0.0",
109
109
  "@tiptap/starter-kit": "^2.0.0 || ^3.0.0",
110
- "jspdf": "*",
110
+ "jspdf": ">=4.0.0",
111
111
  "jspdf-autotable": "*",
112
112
  "next": ">=14.0.0",
113
113
  "react": ">=18.0.0",
@@ -165,12 +165,12 @@
165
165
  "@vitest/coverage-v8": "^3.2.4",
166
166
  "@vitest/ui": "^3.2.4",
167
167
  "autoprefixer": "^10.4.16",
168
- "esbuild": "^0.24.2",
168
+ "esbuild": "^0.25.0",
169
169
  "jsdom": "^23.0.1",
170
170
  "playwright": "^1.56.0",
171
171
  "postcss": "^8.4.31",
172
172
  "react-hook-form": "^7.70.0",
173
- "storybook": "^9.1.10",
173
+ "storybook": "^9.1.17",
174
174
  "tailwindcss": "^4.1.8",
175
175
  "typescript": "^5.5.0",
176
176
  "vite": "^7.1.9",
@@ -554,13 +554,13 @@ export function WakaSidebar({
554
554
  onClose: () => setIsOpen(false),
555
555
  }
556
556
 
557
- // Styles personnalisés
557
+ // Styles personnalisés - utilise les variables CSS du thème par défaut
558
558
  const customStyles = {
559
- "--sidebar-bg": backgroundColor || "hsl(222 47% 11%)",
560
- "--sidebar-text": textColor || "hsl(210 40% 96%)",
561
- "--sidebar-active": activeColor || "hsl(187 85% 43%)",
562
- "--sidebar-active-foreground": "hsl(222 47% 11%)",
563
- "--sidebar-hover": hoverColor || "rgba(255, 255, 255, 0.1)",
559
+ "--sidebar-bg": backgroundColor || "hsl(var(--sidebar-background, var(--card)))",
560
+ "--sidebar-text": textColor || "hsl(var(--sidebar-foreground, var(--card-foreground)))",
561
+ "--sidebar-active": activeColor || "hsl(var(--sidebar-primary, var(--primary)))",
562
+ "--sidebar-active-foreground": "hsl(var(--sidebar-primary-foreground, var(--primary-foreground)))",
563
+ "--sidebar-hover": hoverColor || "hsl(var(--sidebar-accent, var(--accent)))",
564
564
  } as React.CSSProperties
565
565
 
566
566
  const sidebarClasses = cn(
@@ -28,6 +28,7 @@ import {
28
28
  AlertDialogTitle,
29
29
  } from "../../alert-dialog"
30
30
  import { cn } from "../../../utils/cn"
31
+ import { createHighlightRegex } from "../../../utils/security"
31
32
  import { formatters } from "../formatters"
32
33
  import type { ColumnTemplate, ColumnTemplateOptions, ColumnTemplateAction } from "../types"
33
34
 
@@ -715,9 +716,9 @@ export const textTemplate: ColumnTemplate<unknown, unknown> = {
715
716
  ? formatters.truncate(strValue, options.maxLength)
716
717
  : strValue
717
718
 
718
- // Highlight si spécifié
719
+ // Highlight si spécifié (with escaped regex for security)
719
720
  if (options?.highlight) {
720
- const regex = new RegExp(`(${options.highlight})`, "gi")
721
+ const regex = createHighlightRegex(options.highlight)
721
722
  const parts = displayValue.split(regex)
722
723
 
723
724
  return (
@@ -555,3 +555,97 @@ export {
555
555
  type NodeType,
556
556
  type WakaQueryExplainProps,
557
557
  } from './waka-query-explain'
558
+
559
+ // Advertising Components
560
+ export {
561
+ WakaAdProvider,
562
+ useAdContext,
563
+ useAdVisibility,
564
+ useAdConsent,
565
+ useAdSlot,
566
+ AD_SIZES,
567
+ type AdNetwork,
568
+ type AdSize,
569
+ type AdPosition,
570
+ type AdSlot,
571
+ type AdConfig,
572
+ type AdEvent,
573
+ type CustomAd,
574
+ } from './waka-ad-provider'
575
+
576
+ export {
577
+ WakaAdBanner,
578
+ type WakaAdBannerProps,
579
+ } from './waka-ad-banner'
580
+
581
+ export {
582
+ WakaAdPlaceholder,
583
+ type WakaAdPlaceholderProps,
584
+ } from './waka-ad-placeholder'
585
+
586
+ export {
587
+ WakaAdFallback,
588
+ type WakaAdFallbackProps,
589
+ type AdFallbackVariant,
590
+ } from './waka-ad-fallback'
591
+
592
+ export {
593
+ WakaSponsoredBadge,
594
+ type WakaSponsoredBadgeProps,
595
+ type SponsoredBadgeVariant,
596
+ type SponsoredBadgeSize,
597
+ } from './waka-sponsored-badge'
598
+
599
+ export {
600
+ WakaAdSidebar,
601
+ type WakaAdSidebarProps,
602
+ } from './waka-ad-sidebar'
603
+
604
+ export {
605
+ WakaAdInline,
606
+ type WakaAdInlineProps,
607
+ } from './waka-ad-inline'
608
+
609
+ export {
610
+ WakaAdInterstitial,
611
+ type WakaAdInterstitialProps,
612
+ } from './waka-ad-interstitial'
613
+
614
+ export {
615
+ WakaAdStickyFooter,
616
+ type WakaAdStickyFooterProps,
617
+ } from './waka-ad-sticky-footer'
618
+
619
+ export {
620
+ WakaSponsoredCard,
621
+ type WakaSponsoredCardProps,
622
+ type SponsoredCardVariant,
623
+ } from './waka-sponsored-card'
624
+
625
+ export {
626
+ WakaSponsoredFeed,
627
+ type WakaSponsoredFeedProps,
628
+ } from './waka-sponsored-feed'
629
+
630
+ export {
631
+ WakaContentRecommendation,
632
+ type WakaContentRecommendationProps,
633
+ type RecommendationLayout,
634
+ } from './waka-content-recommendation'
635
+
636
+ export {
637
+ WakaVideoAd,
638
+ type WakaVideoAdProps,
639
+ } from './waka-video-ad'
640
+
641
+ export {
642
+ WakaOutstreamVideo,
643
+ type WakaOutstreamVideoProps,
644
+ } from './waka-outstream-video'
645
+
646
+ export {
647
+ WakaVideoOverlay,
648
+ type WakaVideoOverlayProps,
649
+ type OverlayPosition,
650
+ type OverlayTrigger,
651
+ } from './waka-video-overlay'
@@ -127,18 +127,18 @@ export function Waka3DPieChart({
127
127
  })
128
128
  }, [data, total])
129
129
 
130
- // Default colors
130
+ // Default colors - using CSS variables for theme support
131
131
  const defaultColors = [
132
- "#3b82f6", // blue
133
- "#ef4444", // red
134
- "#22c55e", // green
135
- "#f59e0b", // amber
136
- "#8b5cf6", // violet
137
- "#ec4899", // pink
138
- "#14b8a6", // teal
139
- "#f97316", // orange
140
- "#6366f1", // indigo
141
- "#84cc16", // lime
132
+ "hsl(var(--chart-1))",
133
+ "hsl(var(--chart-2))",
134
+ "hsl(var(--chart-3))",
135
+ "hsl(var(--chart-4))",
136
+ "hsl(var(--chart-5))",
137
+ "hsl(var(--primary))",
138
+ "hsl(var(--info))",
139
+ "hsl(var(--warning))",
140
+ "hsl(var(--success))",
141
+ "hsl(var(--destructive))",
142
142
  ]
143
143
 
144
144
  const getSliceColor = (index: number, slice: PieSlice) => {
@@ -73,41 +73,41 @@ const rarityConfig = {
73
73
  common: {
74
74
  gradient: "from-slate-400 to-slate-600",
75
75
  glow: "shadow-slate-400/50",
76
- glowColor: "#94a3b8",
76
+ glowColor: "hsl(var(--muted-foreground))",
77
77
  borderColor: "border-slate-400",
78
- bgColor: "bg-slate-100",
79
- textColor: "text-slate-600",
80
- particleColors: ["#94a3b8", "#cbd5e1", "#64748b"],
78
+ bgColor: "bg-slate-100 dark:bg-slate-900",
79
+ textColor: "text-slate-600 dark:text-slate-400",
80
+ particleColors: ["hsl(var(--muted-foreground))", "hsl(var(--muted))", "hsl(var(--border))"],
81
81
  label: "Common",
82
82
  },
83
83
  rare: {
84
84
  gradient: "from-blue-400 to-blue-600",
85
85
  glow: "shadow-blue-400/50",
86
- glowColor: "#60a5fa",
86
+ glowColor: "hsl(var(--info))",
87
87
  borderColor: "border-blue-400",
88
- bgColor: "bg-blue-100",
89
- textColor: "text-blue-600",
90
- particleColors: ["#60a5fa", "#93c5fd", "#3b82f6"],
88
+ bgColor: "bg-blue-100 dark:bg-blue-950",
89
+ textColor: "text-blue-600 dark:text-blue-400",
90
+ particleColors: ["hsl(var(--info))", "hsl(var(--chart-1))", "hsl(var(--primary))"],
91
91
  label: "Rare",
92
92
  },
93
93
  epic: {
94
94
  gradient: "from-purple-400 to-purple-600",
95
95
  glow: "shadow-purple-400/50",
96
- glowColor: "#a855f7",
96
+ glowColor: "hsl(var(--chart-2))",
97
97
  borderColor: "border-purple-400",
98
- bgColor: "bg-purple-100",
99
- textColor: "text-purple-600",
100
- particleColors: ["#a855f7", "#c084fc", "#9333ea"],
98
+ bgColor: "bg-purple-100 dark:bg-purple-950",
99
+ textColor: "text-purple-600 dark:text-purple-400",
100
+ particleColors: ["hsl(var(--chart-2))", "hsl(var(--chart-3))", "hsl(var(--primary))"],
101
101
  label: "Epic",
102
102
  },
103
103
  legendary: {
104
104
  gradient: "from-amber-400 via-orange-500 to-red-500",
105
105
  glow: "shadow-amber-400/50",
106
- glowColor: "#fbbf24",
106
+ glowColor: "hsl(var(--warning))",
107
107
  borderColor: "border-amber-400",
108
- bgColor: "bg-amber-100",
109
- textColor: "text-amber-600",
110
- particleColors: ["#fbbf24", "#f59e0b", "#ef4444", "#fcd34d"],
108
+ bgColor: "bg-amber-100 dark:bg-amber-950",
109
+ textColor: "text-amber-600 dark:text-amber-400",
110
+ particleColors: ["hsl(var(--warning))", "hsl(var(--chart-4))", "hsl(var(--destructive))", "hsl(var(--chart-5))"],
111
111
  label: "Legendary",
112
112
  },
113
113
  }
@@ -0,0 +1,275 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { useRef, useEffect, useState, useCallback } from "react"
5
+ import { cn } from "../../utils/cn"
6
+ import { useAdContext, useAdVisibility, AD_SIZES, type AdSize, type AdSlot, type CustomAd } from "../waka-ad-provider"
7
+ import { WakaAdPlaceholder } from "../waka-ad-placeholder"
8
+ import { WakaAdFallback } from "../waka-ad-fallback"
9
+ import { WakaSponsoredBadge } from "../waka-sponsored-badge"
10
+
11
+ export interface WakaAdBannerProps {
12
+ /** Unique slot ID */
13
+ slotId: string
14
+ /** Ad size preset */
15
+ size?: AdSize
16
+ /** Custom width (when size is "custom") */
17
+ customWidth?: number
18
+ /** Custom height (when size is "custom") */
19
+ customHeight?: number
20
+ /** GPT ad unit path (for GPT network) */
21
+ adUnitPath?: string
22
+ /** Targeting parameters */
23
+ targeting?: Record<string, string | string[]>
24
+ /** Auto-refresh interval in seconds (0 = disabled) */
25
+ refreshInterval?: number
26
+ /** Enable lazy loading */
27
+ lazyLoad?: boolean
28
+ /** Fallback content when no ad available */
29
+ fallback?: React.ReactNode
30
+ /** Show sponsored badge */
31
+ showBadge?: boolean
32
+ /** Badge position */
33
+ badgePosition?: "top-left" | "top-right" | "bottom-left" | "bottom-right"
34
+ /** Custom class name */
35
+ className?: string
36
+ /** Callback when ad loads */
37
+ onLoad?: () => void
38
+ /** Callback when ad fails */
39
+ onError?: (error: Error) => void
40
+ /** Callback when ad is clicked */
41
+ onClick?: () => void
42
+ }
43
+
44
+ export function WakaAdBanner({
45
+ slotId,
46
+ size = "rectangle",
47
+ customWidth,
48
+ customHeight,
49
+ adUnitPath,
50
+ targeting,
51
+ refreshInterval = 0,
52
+ lazyLoad = true,
53
+ fallback,
54
+ showBadge = true,
55
+ badgePosition = "top-right",
56
+ className,
57
+ onLoad,
58
+ onError,
59
+ onClick,
60
+ }: WakaAdBannerProps) {
61
+ const containerRef = useRef<HTMLDivElement>(null)
62
+ const { config, isReady, hasConsent, registerSlot, unregisterSlot, getCustomAd, trackEvent } = useAdContext()
63
+ const { isVisible } = useAdVisibility(containerRef)
64
+
65
+ const [status, setStatus] = useState<"loading" | "loaded" | "error" | "empty">("loading")
66
+ const [customAd, setCustomAd] = useState<CustomAd | null>(null)
67
+
68
+ const dimensions = size === "custom"
69
+ ? { width: customWidth || 300, height: customHeight || 250 }
70
+ : AD_SIZES[size]
71
+
72
+ // Register slot
73
+ useEffect(() => {
74
+ const slot: AdSlot = {
75
+ id: slotId,
76
+ network: config.network,
77
+ size,
78
+ position: "inline",
79
+ targeting,
80
+ refreshInterval,
81
+ lazyLoad,
82
+ customWidth,
83
+ customHeight,
84
+ }
85
+ registerSlot(slot)
86
+ return () => unregisterSlot(slotId)
87
+ }, [slotId, config.network, size, targeting, refreshInterval, lazyLoad, customWidth, customHeight, registerSlot, unregisterSlot])
88
+
89
+ // Load ad based on network type
90
+ const loadAd = useCallback(async () => {
91
+ if (!isReady || hasConsent === false) return
92
+
93
+ setStatus("loading")
94
+
95
+ try {
96
+ if (config.network === "gpt" && window.googletag && adUnitPath) {
97
+ // GPT implementation
98
+ window.googletag.cmd.push(() => {
99
+ const slot = window.googletag.defineSlot(
100
+ adUnitPath,
101
+ [dimensions.width, dimensions.height],
102
+ slotId
103
+ )
104
+
105
+ if (slot && targeting) {
106
+ Object.entries(targeting).forEach(([key, value]) => {
107
+ window.googletag.pubads().setTargeting(key, value)
108
+ })
109
+ }
110
+
111
+ window.googletag.enableServices()
112
+ window.googletag.display(slotId)
113
+ })
114
+
115
+ setStatus("loaded")
116
+ trackEvent({ type: "loaded", slotId, timestamp: new Date() })
117
+ onLoad?.()
118
+ } else if (config.network === "custom") {
119
+ // Custom ad server
120
+ const ad = await getCustomAd(slotId)
121
+ if (ad) {
122
+ setCustomAd(ad)
123
+ setStatus("loaded")
124
+ trackEvent({ type: "loaded", slotId, timestamp: new Date() })
125
+ onLoad?.()
126
+
127
+ // Fire impression tracking
128
+ if (ad.impressionUrl) {
129
+ fetch(ad.impressionUrl, { mode: "no-cors" }).catch(() => {})
130
+ }
131
+ ad.trackingPixels?.forEach((url) => {
132
+ const img = new Image()
133
+ img.src = url
134
+ })
135
+ } else {
136
+ setStatus("empty")
137
+ trackEvent({ type: "empty", slotId, timestamp: new Date() })
138
+ }
139
+ }
140
+ } catch (error) {
141
+ setStatus("error")
142
+ trackEvent({ type: "error", slotId, timestamp: new Date(), data: { error } })
143
+ onError?.(error as Error)
144
+ }
145
+ }, [isReady, hasConsent, config.network, adUnitPath, dimensions, slotId, targeting, getCustomAd, trackEvent, onLoad, onError])
146
+
147
+ // Initial load (with lazy loading support)
148
+ useEffect(() => {
149
+ if (lazyLoad) {
150
+ if (isVisible) {
151
+ loadAd()
152
+ }
153
+ } else {
154
+ loadAd()
155
+ }
156
+ }, [lazyLoad, isVisible, loadAd])
157
+
158
+ // Auto-refresh
159
+ useEffect(() => {
160
+ if (refreshInterval > 0 && status === "loaded" && isVisible) {
161
+ const interval = setInterval(() => {
162
+ loadAd()
163
+ }, refreshInterval * 1000)
164
+ return () => clearInterval(interval)
165
+ }
166
+ }, [refreshInterval, status, isVisible, loadAd])
167
+
168
+ // Track viewability
169
+ useEffect(() => {
170
+ if (isVisible && status === "loaded") {
171
+ trackEvent({ type: "viewable", slotId, timestamp: new Date() })
172
+ }
173
+ }, [isVisible, status, slotId, trackEvent])
174
+
175
+ const handleClick = () => {
176
+ trackEvent({ type: "click", slotId, timestamp: new Date() })
177
+ onClick?.()
178
+
179
+ if (customAd?.clickUrl) {
180
+ fetch(customAd.clickUrl, { mode: "no-cors" }).catch(() => {})
181
+ }
182
+ if (customAd?.targetUrl) {
183
+ window.open(customAd.targetUrl, "_blank", "noopener,noreferrer")
184
+ }
185
+ }
186
+
187
+ // Render based on status
188
+ const renderContent = () => {
189
+ if (hasConsent === false) {
190
+ return fallback || <WakaAdFallback width={dimensions.width} height={dimensions.height} />
191
+ }
192
+
193
+ if (status === "loading") {
194
+ return <WakaAdPlaceholder width={dimensions.width} height={dimensions.height} />
195
+ }
196
+
197
+ if (status === "error" || status === "empty") {
198
+ return fallback || <WakaAdFallback width={dimensions.width} height={dimensions.height} />
199
+ }
200
+
201
+ // GPT renders into the div directly
202
+ if (config.network === "gpt") {
203
+ return null
204
+ }
205
+
206
+ // Custom ad rendering
207
+ if (customAd) {
208
+ return (
209
+ <button
210
+ onClick={handleClick}
211
+ className="relative w-full h-full cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary"
212
+ >
213
+ {customAd.imageUrl && (
214
+ <img
215
+ src={customAd.imageUrl}
216
+ alt={customAd.title || "Advertisement"}
217
+ className="w-full h-full object-cover"
218
+ />
219
+ )}
220
+ {customAd.title && !customAd.imageUrl && (
221
+ <div className="flex flex-col items-center justify-center h-full p-4 bg-muted">
222
+ <p className="font-semibold text-center">{customAd.title}</p>
223
+ {customAd.description && (
224
+ <p className="text-sm text-muted-foreground text-center mt-1">{customAd.description}</p>
225
+ )}
226
+ {customAd.cta && (
227
+ <span className="mt-2 px-4 py-1 bg-primary text-primary-foreground rounded text-sm">
228
+ {customAd.cta}
229
+ </span>
230
+ )}
231
+ </div>
232
+ )}
233
+ </button>
234
+ )
235
+ }
236
+
237
+ return null
238
+ }
239
+
240
+ const badgePositionClasses = {
241
+ "top-left": "top-1 left-1",
242
+ "top-right": "top-1 right-1",
243
+ "bottom-left": "bottom-1 left-1",
244
+ "bottom-right": "bottom-1 right-1",
245
+ }
246
+
247
+ return (
248
+ <div
249
+ ref={containerRef}
250
+ id={slotId}
251
+ className={cn(
252
+ "relative overflow-hidden bg-muted/20 border border-border/50 rounded-md",
253
+ className
254
+ )}
255
+ style={{
256
+ width: dimensions.width,
257
+ height: dimensions.height,
258
+ maxWidth: "100%",
259
+ }}
260
+ data-ad-slot={slotId}
261
+ data-ad-size={size}
262
+ data-ad-status={status}
263
+ >
264
+ {renderContent()}
265
+
266
+ {showBadge && status === "loaded" && (
267
+ <div className={cn("absolute z-10", badgePositionClasses[badgePosition])}>
268
+ <WakaSponsoredBadge sponsor={customAd?.sponsor} />
269
+ </div>
270
+ )}
271
+ </div>
272
+ )
273
+ }
274
+
275
+ export default WakaAdBanner