@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.
- package/dist/components/index.d.ts +15 -0
- package/dist/components/waka-ad-banner/index.d.ts +36 -0
- package/dist/components/waka-ad-fallback/index.d.ts +33 -0
- package/dist/components/waka-ad-inline/index.d.ts +15 -0
- package/dist/components/waka-ad-interstitial/index.d.ts +26 -0
- package/dist/components/waka-ad-placeholder/index.d.ts +17 -0
- package/dist/components/waka-ad-provider/index.d.ts +103 -0
- package/dist/components/waka-ad-sidebar/index.d.ts +18 -0
- package/dist/components/waka-ad-sticky-footer/index.d.ts +17 -0
- package/dist/components/waka-content-recommendation/index.d.ts +23 -0
- package/dist/components/waka-outstream-video/index.d.ts +24 -0
- package/dist/components/waka-sponsored-badge/index.d.ts +20 -0
- package/dist/components/waka-sponsored-card/index.d.ts +25 -0
- package/dist/components/waka-sponsored-feed/index.d.ts +31 -0
- package/dist/components/waka-video-ad/index.d.ts +32 -0
- package/dist/components/waka-video-overlay/index.d.ts +26 -0
- package/dist/index.cjs.js +177 -171
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +14535 -12812
- package/dist/utils/security.d.ts +96 -0
- package/package.json +4 -4
- package/src/blocks/sidebar/index.tsx +6 -6
- package/src/components/DataTable/templates/index.tsx +3 -2
- package/src/components/index.ts +94 -0
- package/src/components/waka-3d-pie-chart/index.tsx +11 -11
- package/src/components/waka-achievement-unlock/index.tsx +16 -16
- package/src/components/waka-ad-banner/index.tsx +275 -0
- package/src/components/waka-ad-fallback/index.tsx +181 -0
- package/src/components/waka-ad-inline/index.tsx +103 -0
- package/src/components/waka-ad-interstitial/index.tsx +278 -0
- package/src/components/waka-ad-placeholder/index.tsx +84 -0
- package/src/components/waka-ad-provider/index.tsx +329 -0
- package/src/components/waka-ad-sidebar/index.tsx +113 -0
- package/src/components/waka-ad-sticky-footer/index.tsx +125 -0
- package/src/components/waka-badge-showcase/index.tsx +12 -11
- package/src/components/waka-command-bar/index.tsx +2 -1
- package/src/components/waka-content-recommendation/index.tsx +294 -0
- package/src/components/waka-cost-breakdown/index.tsx +10 -10
- package/src/components/waka-funnel-chart/index.tsx +8 -8
- package/src/components/waka-health-pulse/index.tsx +6 -6
- package/src/components/waka-leaderboard/index.tsx +9 -9
- package/src/components/waka-loot-box/index.tsx +20 -20
- package/src/components/waka-outstream-video/index.tsx +240 -0
- package/src/components/waka-player-card/index.tsx +5 -5
- package/src/components/waka-quota-bar/index.tsx +4 -4
- package/src/components/waka-radar-score/index.tsx +10 -10
- package/src/components/waka-scratch-card/index.tsx +5 -4
- package/src/components/waka-server-rack/index.tsx +28 -27
- package/src/components/waka-sponsored-badge/index.tsx +97 -0
- package/src/components/waka-sponsored-card/index.tsx +275 -0
- package/src/components/waka-sponsored-feed/index.tsx +127 -0
- package/src/components/waka-spotlight/index.tsx +2 -1
- package/src/components/waka-success-explosion/index.tsx +4 -4
- package/src/components/waka-video-ad/index.tsx +406 -0
- package/src/components/waka-video-overlay/index.tsx +257 -0
- package/src/components/waka-xp-bar/index.tsx +13 -13
- package/src/styles/base.css +16 -0
- package/src/styles/tailwind.preset.js +12 -0
- package/src/styles/themes/forest.css +16 -0
- package/src/styles/themes/monochrome.css +16 -0
- package/src/styles/themes/perpetuity.css +16 -0
- package/src/styles/themes/sunset.css +16 -0
- 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.
|
|
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.
|
|
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.
|
|
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(
|
|
560
|
-
"--sidebar-text": textColor || "hsl(
|
|
561
|
-
"--sidebar-active": activeColor || "hsl(
|
|
562
|
-
"--sidebar-active-foreground": "hsl(
|
|
563
|
-
"--sidebar-hover": hoverColor || "
|
|
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 =
|
|
721
|
+
const regex = createHighlightRegex(options.highlight)
|
|
721
722
|
const parts = displayValue.split(regex)
|
|
722
723
|
|
|
723
724
|
return (
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
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: "
|
|
76
|
+
glowColor: "hsl(var(--muted-foreground))",
|
|
77
77
|
borderColor: "border-slate-400",
|
|
78
|
-
bgColor: "bg-slate-100",
|
|
79
|
-
textColor: "text-slate-600",
|
|
80
|
-
particleColors: ["
|
|
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: "
|
|
86
|
+
glowColor: "hsl(var(--info))",
|
|
87
87
|
borderColor: "border-blue-400",
|
|
88
|
-
bgColor: "bg-blue-100",
|
|
89
|
-
textColor: "text-blue-600",
|
|
90
|
-
particleColors: ["
|
|
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: "
|
|
96
|
+
glowColor: "hsl(var(--chart-2))",
|
|
97
97
|
borderColor: "border-purple-400",
|
|
98
|
-
bgColor: "bg-purple-100",
|
|
99
|
-
textColor: "text-purple-600",
|
|
100
|
-
particleColors: ["
|
|
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: "
|
|
106
|
+
glowColor: "hsl(var(--warning))",
|
|
107
107
|
borderColor: "border-amber-400",
|
|
108
|
-
bgColor: "bg-amber-100",
|
|
109
|
-
textColor: "text-amber-600",
|
|
110
|
-
particleColors: ["
|
|
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
|