From df273fcaeeae68d29cbd778c07214ed089a466b3 Mon Sep 17 00:00:00 2001 From: prosdevlab Date: Fri, 26 Dec 2025 13:20:10 -0800 Subject: [PATCH 1/4] fix: update BannerContent types and add CSS customization support - Fix BannerContent type to include buttons array, position, className, style - Make core import content types from plugins (single source of truth) - Update banner plugin to use .xp-* CSS classes instead of inline styles - Add className and style props for banner and buttons - Provide minimal, functional default styles with dark mode support - Add comprehensive tests for className and style customization - Update tests to check CSS classes instead of inline styles - Add customization documentation with three use cases (Tailwind, Design System, CSS Framework) --- .changeset/fix-banner-content-types.md | 17 + docs/pages/reference/plugins.mdx | 99 ++++++ packages/core/src/types.ts | 50 +-- packages/plugins/src/banner/banner.test.ts | 170 +++++++++- packages/plugins/src/banner/banner.ts | 358 ++++++++++++++------- packages/plugins/src/types.ts | 22 ++ tsconfig.json | 8 +- 7 files changed, 552 insertions(+), 172 deletions(-) create mode 100644 .changeset/fix-banner-content-types.md diff --git a/.changeset/fix-banner-content-types.md b/.changeset/fix-banner-content-types.md new file mode 100644 index 0000000..ec8b8c3 --- /dev/null +++ b/.changeset/fix-banner-content-types.md @@ -0,0 +1,17 @@ +--- +'@prosdevlab/experience-sdk': patch +'@prosdevlab/experience-sdk-plugins': patch +--- + +Fix BannerContent type definition and add CSS customization support: + +- Add `buttons` array property with variant and metadata support +- Add `position` property (top/bottom) +- Make `title` optional (message is the only required field) +- Add `className` and `style` props for banner and buttons +- Update banner plugin to use `.xp-*` CSS classes +- Provide minimal, functional default styles +- Aligns core types with banner plugin implementation + +This enables users to customize banners with Tailwind, design systems, or CSS frameworks while maintaining SDK's focus on targeting logic. + diff --git a/docs/pages/reference/plugins.mdx b/docs/pages/reference/plugins.mdx index 268dcce..ae68969 100644 --- a/docs/pages/reference/plugins.mdx +++ b/docs/pages/reference/plugins.mdx @@ -138,6 +138,105 @@ experiences.on('experiences:dismissed', ({ experienceId }) => { See [Banner Examples](/demo/banner) for complete usage examples. +#### Customization + +The Experience SDK focuses on **targeting logic**, not visual design. The banner plugin provides minimal, functional default styles that you can customize using CSS. + +**CSS Classes** + +The banner plugin uses the `.xp-*` namespace for all CSS classes: + +- `.xp-banner` - Main container +- `.xp-banner--top` - Top positioned banner +- `.xp-banner--bottom` - Bottom positioned banner +- `.xp-banner__container` - Inner wrapper +- `.xp-banner__content` - Content section +- `.xp-banner__title` - Optional title +- `.xp-banner__message` - Main message text +- `.xp-banner__buttons` - Buttons container +- `.xp-banner__button` - Individual button +- `.xp-banner__button--primary` - Primary button variant +- `.xp-banner__button--secondary` - Secondary button variant +- `.xp-banner__button--link` - Link button variant +- `.xp-banner__close` - Close button + +**Use Case 1: User with Tailwind** + +Add Tailwind classes via the `className` property: + +```typescript +experiences.register('flash-sale', { + type: 'banner', + content: { + message: 'Flash Sale: 50% Off Everything!', + className: 'bg-gradient-to-r from-blue-600 to-purple-600 text-white', + buttons: [{ + text: 'Shop Now', + url: '/shop', + variant: 'primary', + className: 'bg-white text-blue-600 hover:bg-gray-100' + }] + }, + targeting: { url: { contains: '/shop' } } +}); +``` + +**Use Case 2: User with Design System** + +Build your own plugin using your design system components: + +```typescript +import { MyBannerComponent } from '@your-org/design-system'; + +const myBannerPlugin: PluginFunction = (plugin, instance, config) => { + instance.on('experiences:evaluated', ({ decision, experience }) => { + if (decision.show && experience.type === 'banner') { + // Render using your React component + ReactDOM.render( + , + document.getElementById('banner-root') + ); + } + }); +}; + +experiences.use(myBannerPlugin); +``` + +**Use Case 3: User with CSS Framework** + +Add Bootstrap, Material UI, or other framework classes: + +```typescript +experiences.register('alert', { + type: 'banner', + content: { + message: 'Important notice', + className: 'alert alert-warning', + buttons: [{ + text: 'Learn More', + className: 'btn btn-primary' + }] + }, + targeting: {} +}); +``` + +**Inline Styles** + +For quick overrides, use the `style` property: + +```typescript +content: { + message: 'Flash Sale!', + style: { + background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)', + color: 'white', + padding: '24px' + } +} +``` + --- ### Frequency Plugin diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0bff975..06df9c5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -78,52 +78,24 @@ export interface FrequencyConfig { per: 'session' | 'day' | 'week'; } +// Import plugin-specific content types from plugins package +// (Core depends on plugins, so plugins owns these definitions) +import type { + BannerContent, + ModalContent, + TooltipContent, +} from '@prosdevlab/experience-sdk-plugins'; + /** * Experience Content (type-specific) * * Union type for all possible experience content types. + * Content types are defined in the plugins package. */ export type ExperienceContent = BannerContent | ModalContent | TooltipContent; -/** - * Banner Content - * - * Content for banner-type experiences. - */ -export interface BannerContent { - /** Banner title/heading */ - title: string; - /** Banner message/body text */ - message: string; - /** Whether the banner can be dismissed */ - dismissable?: boolean; -} - -/** - * Modal Content - * - * Content for modal-type experiences. - */ -export interface ModalContent { - /** Modal title */ - title: string; - /** Modal body content */ - body: string; - /** Optional action buttons */ - actions?: ModalAction[]; -} - -/** - * Tooltip Content - * - * Content for tooltip-type experiences. - */ -export interface TooltipContent { - /** Tooltip text */ - text: string; - /** Position relative to target element */ - position?: 'top' | 'bottom' | 'left' | 'right'; -} +// Re-export plugin content types for convenience +export type { BannerContent, ModalContent, TooltipContent }; /** * Modal Action Button diff --git a/packages/plugins/src/banner/banner.test.ts b/packages/plugins/src/banner/banner.test.ts index a35ff3b..6c09e7e 100644 --- a/packages/plugins/src/banner/banner.test.ts +++ b/packages/plugins/src/banner/banner.test.ts @@ -92,8 +92,8 @@ describe('Banner Plugin', () => { sdk.banner.show(experience); const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; - expect(banner.style.top).toBe('0px'); - expect(banner.style.bottom).toBe(''); + expect(banner.className).toContain('xp-banner--top'); + expect(banner.className).not.toContain('xp-banner--bottom'); }); it('should show banner at bottom position when configured', () => { @@ -113,8 +113,8 @@ describe('Banner Plugin', () => { customSdk.banner.show(experience); const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; - expect(banner.style.bottom).toBe('0px'); - expect(banner.style.top).toBe(''); + expect(banner.className).toContain('xp-banner--bottom'); + expect(banner.className).not.toContain('xp-banner--top'); }); it('should create banner with title and message', () => { @@ -500,8 +500,8 @@ describe('Banner Plugin', () => { const button = banner?.querySelector('button') as HTMLElement; expect(button).toBeTruthy(); - // Primary variant should have white text - expect(button.style.color).toContain('255'); // rgb(255, 255, 255) + // Primary variant should have the correct class + expect(button.className).toContain('xp-banner__button--primary'); }); it('should emit action event with variant and metadata', () => { @@ -667,7 +667,8 @@ describe('Banner Plugin', () => { sdk.banner.show(experience); const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; - expect(banner.style.zIndex).toBe('10000'); + // Default z-index is set via CSS, check that banner has the base class + expect(banner.className).toContain('xp-banner'); }); it('should apply custom z-index', () => { @@ -704,7 +705,9 @@ describe('Banner Plugin', () => { sdk.banner.show(experience); const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; - expect(banner.style.position).toBe('fixed'); + // Position is set via CSS class, check computed style + const computedStyle = window.getComputedStyle(banner); + expect(computedStyle.position).toBe('fixed'); }); it('should span full width', () => { @@ -721,8 +724,155 @@ describe('Banner Plugin', () => { sdk.banner.show(experience); const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; - expect(banner.style.left).toBe('0px'); - expect(banner.style.right).toBe('0px'); + // Width and positioning are set via CSS class, check computed styles + const computedStyle = window.getComputedStyle(banner); + expect(computedStyle.left).toBe('0px'); + expect(computedStyle.right).toBe('0px'); + expect(computedStyle.width).toBe('100%'); + }); + + it('should apply custom className to banner', () => { + const experience: Experience = { + id: 'test-banner', + type: 'banner', + targeting: {}, + content: { + message: 'Test message', + className: 'my-custom-banner custom-class', + }, + }; + + sdk.banner.show(experience); + + const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; + expect(banner.className).toContain('xp-banner'); + expect(banner.className).toContain('my-custom-banner'); + expect(banner.className).toContain('custom-class'); + }); + + it('should apply custom inline styles to banner', () => { + const experience: Experience = { + id: 'test-banner', + type: 'banner', + targeting: {}, + content: { + message: 'Test message', + style: { + backgroundColor: '#ff0000', + padding: '24px', + borderRadius: '8px', + }, + }, + }; + + sdk.banner.show(experience); + + const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; + expect(banner.style.backgroundColor).toBe('rgb(255, 0, 0)'); + expect(banner.style.padding).toBe('24px'); + expect(banner.style.borderRadius).toBe('8px'); + }); + + it('should apply custom className to buttons', () => { + const experience: Experience = { + id: 'test-banner', + type: 'banner', + targeting: {}, + content: { + message: 'Test message', + buttons: [ + { + text: 'Primary Button', + variant: 'primary', + className: 'my-primary-btn custom-btn', + }, + { + text: 'Secondary Button', + variant: 'secondary', + className: 'my-secondary-btn', + }, + ], + }, + }; + + sdk.banner.show(experience); + + const banner = document.querySelector('[data-experience-id="test-banner"]'); + const buttons = banner?.querySelectorAll('.xp-banner__button'); + + expect(buttons?.[0].className).toContain('xp-banner__button--primary'); + expect(buttons?.[0].className).toContain('my-primary-btn'); + expect(buttons?.[0].className).toContain('custom-btn'); + + expect(buttons?.[1].className).toContain('xp-banner__button--secondary'); + expect(buttons?.[1].className).toContain('my-secondary-btn'); + }); + + it('should apply custom inline styles to buttons', () => { + const experience: Experience = { + id: 'test-banner', + type: 'banner', + targeting: {}, + content: { + message: 'Test message', + buttons: [ + { + text: 'Styled Button', + variant: 'primary', + style: { + backgroundColor: '#00ff00', + color: '#000000', + fontWeight: 'bold', + }, + }, + ], + }, + }; + + sdk.banner.show(experience); + + const banner = document.querySelector('[data-experience-id="test-banner"]'); + const button = banner?.querySelector('.xp-banner__button') as HTMLElement; + + expect(button.style.backgroundColor).toBe('rgb(0, 255, 0)'); + expect(button.style.color).toBe('rgb(0, 0, 0)'); + expect(button.style.fontWeight).toBe('bold'); + }); + + it('should combine className and style props', () => { + const experience: Experience = { + id: 'test-banner', + type: 'banner', + targeting: {}, + content: { + message: 'Test message', + className: 'custom-banner', + style: { + backgroundColor: '#0000ff', + }, + buttons: [ + { + text: 'Button', + className: 'custom-button', + style: { + color: '#ffffff', + }, + }, + ], + }, + }; + + sdk.banner.show(experience); + + const banner = document.querySelector('[data-experience-id="test-banner"]') as HTMLElement; + expect(banner.className).toContain('xp-banner'); + expect(banner.className).toContain('custom-banner'); + expect(banner.style.backgroundColor).toBe('rgb(0, 0, 255)'); + + const button = banner.querySelector('.xp-banner__button') as HTMLElement; + expect(button.className).toContain('xp-banner__button'); + expect(button.className).toContain('custom-button'); + expect(button.style.color).toBe('rgb(255, 255, 255)'); }); }); }); diff --git a/packages/plugins/src/banner/banner.ts b/packages/plugins/src/banner/banner.ts index 1125855..b53935c 100644 --- a/packages/plugins/src/banner/banner.ts +++ b/packages/plugins/src/banner/banner.ts @@ -51,6 +51,200 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => { // Track multiple active banners by experience ID const activeBanners = new Map(); + /** + * Inject default banner styles if not already present + */ + function injectDefaultStyles(): void { + const styleId = 'xp-banner-styles'; + if (document.getElementById(styleId)) { + return; // Already injected + } + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .xp-banner { + position: fixed; + left: 0; + right: 0; + width: 100%; + padding: 16px 20px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + z-index: 10000; + background: #f9fafb; + color: #111827; + border-bottom: 1px solid #e5e7eb; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05); + } + + .xp-banner--top { + top: 0; + } + + .xp-banner--bottom { + bottom: 0; + border-bottom: none; + border-top: 1px solid #e5e7eb; + box-shadow: 0 -1px 3px 0 rgba(0, 0, 0, 0.05); + } + + .xp-banner__container { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + width: 100%; + } + + .xp-banner__content { + flex: 1; + min-width: 0; + } + + .xp-banner__title { + font-weight: 600; + margin-bottom: 4px; + margin-top: 0; + font-size: 14px; + } + + .xp-banner__message { + margin: 0; + font-size: 14px; + } + + .xp-banner__buttons { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + flex-shrink: 0; + } + + .xp-banner__button { + padding: 8px 16px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + } + + .xp-banner__button--primary { + background: #2563eb; + color: #ffffff; + } + + .xp-banner__button--primary:hover { + background: #1d4ed8; + } + + .xp-banner__button--secondary { + background: #ffffff; + color: #374151; + border: 1px solid #d1d5db; + } + + .xp-banner__button--secondary:hover { + background: #f9fafb; + } + + .xp-banner__button--link { + background: transparent; + color: #2563eb; + padding: 4px 8px; + font-weight: 400; + text-decoration: underline; + } + + .xp-banner__button--link:hover { + background: rgba(0, 0, 0, 0.05); + } + + .xp-banner__close { + background: transparent; + border: none; + color: #6b7280; + font-size: 24px; + line-height: 1; + cursor: pointer; + padding: 0; + margin: 0; + opacity: 0.7; + transition: opacity 0.2s; + flex-shrink: 0; + } + + .xp-banner__close:hover { + opacity: 1; + } + + @media (max-width: 640px) { + .xp-banner__container { + flex-direction: column; + align-items: stretch; + } + + .xp-banner__buttons { + width: 100%; + flex-direction: column; + } + + .xp-banner__button { + width: 100%; + } + } + + /* Dark mode support */ + @media (prefers-color-scheme: dark) { + .xp-banner { + background: #1f2937; + color: #f3f4f6; + border-bottom-color: #374151; + } + + .xp-banner--bottom { + border-top-color: #374151; + } + + .xp-banner__button--primary { + background: #3b82f6; + } + + .xp-banner__button--primary:hover { + background: #2563eb; + } + + .xp-banner__button--secondary { + background: #374151; + color: #f3f4f6; + border-color: #4b5563; + } + + .xp-banner__button--secondary:hover { + background: #4b5563; + } + + .xp-banner__button--link { + color: #93c5fd; + } + + .xp-banner__close { + color: #9ca3af; + } + } + `; + document.head.appendChild(style); + } + /** * Create banner DOM element */ @@ -61,79 +255,55 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => { const dismissable = content.dismissable ?? config.get('banner.dismissable') ?? true; const zIndex = config.get('banner.zIndex') ?? 10000; - // Detect dark mode - const isDarkMode = document.documentElement.classList.contains('dark'); - - // Theme-aware colors - professional subtle style - const bgColor = isDarkMode ? '#1f2937' : '#f9fafb'; - const textColor = isDarkMode ? '#f3f4f6' : '#111827'; - const borderColor = isDarkMode ? '#374151' : '#e5e7eb'; - const shadowColor = isDarkMode ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.05)'; + // Inject default styles if needed + injectDefaultStyles(); // Create banner container const banner = document.createElement('div'); banner.setAttribute('data-experience-id', experience.id); - // Add responsive media query styles - const styleId = `banner-responsive-${experience.id}`; - if (!document.getElementById(styleId)) { - const style = document.createElement('style'); - style.id = styleId; - style.textContent = ` - @media (max-width: 640px) { - [data-experience-id="${experience.id}"] { - flex-direction: column !important; - align-items: flex-start !important; - } - [data-experience-id="${experience.id}"] > div:last-child { - width: 100%; - flex-direction: column !important; - } - [data-experience-id="${experience.id}"] button { - width: 100%; - } - } - `; - document.head.appendChild(style); + // Build className: base classes + position + user's custom class + const baseClasses = ['xp-banner', `xp-banner--${position}`]; + if (content.className) { + baseClasses.push(content.className); } + banner.className = baseClasses.join(' '); - banner.style.cssText = ` - position: fixed; - ${position}: 0; - left: 0; - right: 0; - background: ${bgColor}; - color: ${textColor}; - padding: 16px 20px; - border-${position === 'top' ? 'bottom' : 'top'}: 1px solid ${borderColor}; - box-shadow: 0 ${position === 'top' ? '1' : '-1'}px 3px 0 ${shadowColor}; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 14px; - line-height: 1.5; - z-index: ${zIndex}; - display: flex; - align-items: center; - justify-content: space-between; - box-sizing: border-box; - `; + // Apply user's custom styles + if (content.style) { + Object.assign(banner.style, content.style); + } + + // Override z-index if configured + if (zIndex !== 10000) { + banner.style.zIndex = String(zIndex); + } + + // Create container + const container = document.createElement('div'); + container.className = 'xp-banner__container'; + banner.appendChild(container); // Create content container const contentDiv = document.createElement('div'); - contentDiv.style.cssText = 'flex: 1; margin-right: 20px;'; + contentDiv.className = 'xp-banner__content'; // Add title if present if (content.title) { - const title = document.createElement('div'); + const title = document.createElement('h3'); + title.className = 'xp-banner__title'; title.textContent = content.title; - title.style.cssText = 'font-weight: 600; margin-bottom: 4px;'; contentDiv.appendChild(title); } // Add message - const message = document.createElement('div'); + const message = document.createElement('p'); + message.className = 'xp-banner__message'; message.textContent = content.message; contentDiv.appendChild(message); + container.appendChild(contentDiv); + banner.appendChild(contentDiv); // Create button container for actions and/or dismiss @@ -145,6 +315,10 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => { flex-wrap: wrap; `; + // Create buttons container + const buttonsDiv = document.createElement('div'); + buttonsDiv.className = 'xp-banner__buttons'; + // Helper function to create button with variant styling function createButton(buttonConfig: { text: string; @@ -152,53 +326,25 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => { url?: string; variant?: 'primary' | 'secondary' | 'link'; metadata?: Record; + className?: string; + style?: Record; }): HTMLButtonElement { const button = document.createElement('button'); button.textContent = buttonConfig.text; const variant = buttonConfig.variant || 'primary'; - // Variant-based styling - let bg: string, hoverBg: string, textColor: string, border: string; - - if (variant === 'primary') { - bg = isDarkMode ? '#3b82f6' : '#2563eb'; - hoverBg = isDarkMode ? '#2563eb' : '#1d4ed8'; - textColor = '#ffffff'; - border = 'none'; - } else if (variant === 'secondary') { - bg = isDarkMode ? '#374151' : '#ffffff'; - hoverBg = isDarkMode ? '#4b5563' : '#f9fafb'; - textColor = isDarkMode ? '#f3f4f6' : '#374151'; - border = isDarkMode ? '1px solid #4b5563' : '1px solid #d1d5db'; - } else { - // 'link' - bg = 'transparent'; - hoverBg = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'; - textColor = isDarkMode ? '#93c5fd' : '#2563eb'; - border = 'none'; + // Build className: base class + variant + user's custom class + const buttonClasses = ['xp-banner__button', `xp-banner__button--${variant}`]; + if (buttonConfig.className) { + buttonClasses.push(buttonConfig.className); } + button.className = buttonClasses.join(' '); - button.style.cssText = ` - background: ${bg}; - border: ${border}; - color: ${textColor}; - padding: ${variant === 'link' ? '4px 8px' : '8px 16px'}; - font-size: 14px; - font-weight: ${variant === 'link' ? '400' : '500'}; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; - text-decoration: ${variant === 'link' ? 'underline' : 'none'}; - `; - - button.addEventListener('mouseenter', () => { - button.style.background = hoverBg; - }); - - button.addEventListener('mouseleave', () => { - button.style.background = bg; - }); + // Apply user's custom styles + if (buttonConfig.style) { + Object.assign(button.style, buttonConfig.style); + } button.addEventListener('click', () => { // Emit action event @@ -225,39 +371,17 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => { if (content.buttons && content.buttons.length > 0) { content.buttons.forEach((buttonConfig) => { const button = createButton(buttonConfig); - buttonContainer.appendChild(button); + buttonsDiv.appendChild(button); }); } // Add dismiss button if dismissable if (dismissable) { const closeButton = document.createElement('button'); + closeButton.className = 'xp-banner__close'; closeButton.innerHTML = '×'; closeButton.setAttribute('aria-label', 'Close banner'); - const closeColor = isDarkMode ? '#9ca3af' : '#6b7280'; - - closeButton.style.cssText = ` - background: transparent; - border: none; - color: ${closeColor}; - font-size: 24px; - line-height: 1; - cursor: pointer; - padding: 0; - margin: 0; - opacity: 0.7; - transition: opacity 0.2s; - `; - - closeButton.addEventListener('mouseenter', () => { - closeButton.style.opacity = '1'; - }); - - closeButton.addEventListener('mouseleave', () => { - closeButton.style.opacity = '0.7'; - }); - closeButton.addEventListener('click', () => { remove(experience.id); instance.emit('experiences:dismissed', { @@ -266,10 +390,10 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => { }); }); - buttonContainer.appendChild(closeButton); + buttonsDiv.appendChild(closeButton); } - banner.appendChild(buttonContainer); + container.appendChild(buttonsDiv); return banner; } diff --git a/packages/plugins/src/types.ts b/packages/plugins/src/types.ts index 4f50d8e..1f2ba19 100644 --- a/packages/plugins/src/types.ts +++ b/packages/plugins/src/types.ts @@ -20,9 +20,31 @@ export interface BannerContent { url?: string; variant?: 'primary' | 'secondary' | 'link'; metadata?: Record; + className?: string; + style?: Record; }>; dismissable?: boolean; position?: 'top' | 'bottom'; + className?: string; + style?: Record; +} + +/** + * Modal content configuration + */ +export interface ModalContent { + title: string; + message: string; + confirmText?: string; + cancelText?: string; +} + +/** + * Tooltip content configuration + */ +export interface TooltipContent { + message: string; + position?: 'top' | 'bottom' | 'left' | 'right'; } /** diff --git a/tsconfig.json b/tsconfig.json index b543e23..7a360e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,12 +12,8 @@ "lib": ["ES2022", "DOM"], "baseUrl": ".", "paths": { - "@monorepo/core": ["packages/core/src"], - "@monorepo/core/*": ["packages/core/src/*"], - "@monorepo/utils": ["packages/utils/src"], - "@monorepo/utils/*": ["packages/utils/src/*"], - "@monorepo/feature-a": ["packages/feature-a/src"], - "@monorepo/feature-a/*": ["packages/feature-a/src/*"] + "@prosdevlab/experience-sdk": ["packages/core/src"], + "@prosdevlab/experience-sdk/*": ["packages/core/src/*"] } }, "exclude": ["node_modules"], From 582e4ef0bbbb7f01212b329c14b20a4200889c68 Mon Sep 17 00:00:00 2001 From: prosdevlab Date: Fri, 26 Dec 2025 13:26:31 -0800 Subject: [PATCH 2/4] docs: update banner plugin README and examples with CSS customization - Add CSS customization section to plugins README - Update banner examples page with className and style props - Add customization examples for Tailwind and inline styles - Remove emoji from banner example - Link to full customization documentation --- docs/pages/demo/banner.mdx | 40 +++++++++++++++++++++++++++++++++++++- packages/plugins/README.md | 29 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/docs/pages/demo/banner.mdx b/docs/pages/demo/banner.mdx index b3ed2fa..47b26dc 100644 --- a/docs/pages/demo/banner.mdx +++ b/docs/pages/demo/banner.mdx @@ -17,7 +17,7 @@ experiences.register('welcome-banner', { url: { contains: '/' } }, content: { - title: '👋 Welcome!', + title: 'Welcome!', message: 'Thanks for visiting our site', buttons: [ { @@ -115,6 +115,8 @@ const decisions = experiences.evaluateAll(); | `buttons` | `array` | Array of button configurations | | `dismissable` | `boolean` | Can user dismiss? (default: `true`) | | `position` | `'top' \| 'bottom'` | Banner position (default: `'top'`) | +| `className` | `string` | Custom CSS class for the banner | +| `style` | `Record` | Inline styles for the banner | ### Button Options @@ -125,6 +127,8 @@ buttons: [{ url?: string; // Navigate on click variant?: 'primary' | 'secondary' | 'link'; // Visual style (default: 'primary') metadata?: Record; // Custom metadata + className?: string; // Custom CSS class + style?: Record; // Inline styles }] ``` @@ -159,6 +163,40 @@ targeting: { } ``` +## Customization + +Customize banners with your own CSS using `className` or `style` props: + +```typescript +// With Tailwind classes +experiences.register('promo', { + type: 'banner', + content: { + message: 'Flash Sale: 50% Off!', + className: 'bg-gradient-to-r from-blue-600 to-purple-600 text-white', + buttons: [{ + text: 'Shop Now', + className: 'bg-white text-blue-600 hover:bg-gray-100' + }] + } +}); + +// With inline styles +experiences.register('custom', { + type: 'banner', + content: { + message: 'Custom styled banner', + style: { + background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)', + color: 'white', + padding: '24px' + } + } +}); +``` + +The banner plugin uses stable `.xp-*` CSS classes that you can target in your stylesheets. See the [Plugins documentation](/reference/plugins#customization) for complete customization guide. + ## Events Listen to banner interactions: diff --git a/packages/plugins/README.md b/packages/plugins/README.md index 47d4cb3..eb0f88f 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -16,6 +16,8 @@ Renders banner experiences in the DOM with automatic positioning, theming, and r - Automatic theme detection (light/dark mode) - Top/bottom positioning - Dismissable with close button +- **CSS customization** via `className` and `style` props +- Stable `.xp-*` CSS classes for styling ```typescript import { createInstance, bannerPlugin } from '@prosdevlab/experience-sdk-plugins'; @@ -38,6 +40,33 @@ sdk.banner.show({ }); ``` +**Customization:** + +The banner plugin uses `.xp-*` CSS classes and supports custom styling: + +```typescript +// With Tailwind +content: { + message: 'Flash Sale!', + className: 'bg-gradient-to-r from-blue-600 to-purple-600 text-white', + buttons: [{ + text: 'Shop Now', + className: 'bg-white text-blue-600 hover:bg-gray-100' + }] +} + +// With inline styles +content: { + message: 'Flash Sale!', + style: { + background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)', + color: 'white' + } +} +``` + +See the [Plugins documentation](https://prosdevlab.github.io/experience-sdk/reference/plugins#customization) for more customization examples. + ### Frequency Plugin Manages impression tracking and frequency capping with persistent storage. From 96e71d25e6eeb7d81365f62acbb6838fef7d1c36 Mon Sep 17 00:00:00 2001 From: prosdevlab Date: Fri, 26 Dec 2025 13:47:24 -0800 Subject: [PATCH 3/4] feat: add HTML sanitizer for XSS prevention - Add lightweight HTML sanitizer utility with whitelist-based approach - Sanitize title and message fields in banner plugin - Support safe HTML tags (strong, em, a, br, span, b, i, p) - Block dangerous tags (script, iframe, object, embed, etc.) - Block event handlers and javascript:/data: URLs - Add comprehensive XSS prevention tests (52 tests) - Add banner plugin integration tests for HTML sanitization Prevents XSS attacks while allowing safe HTML formatting in banner content. --- packages/plugins/src/banner/banner.test.ts | 210 ++++++++++ packages/plugins/src/banner/banner.ts | 7 +- packages/plugins/src/utils/sanitize.test.ts | 412 ++++++++++++++++++++ packages/plugins/src/utils/sanitize.ts | 196 ++++++++++ 4 files changed, 823 insertions(+), 2 deletions(-) create mode 100644 packages/plugins/src/utils/sanitize.test.ts create mode 100644 packages/plugins/src/utils/sanitize.ts diff --git a/packages/plugins/src/banner/banner.test.ts b/packages/plugins/src/banner/banner.test.ts index 6c09e7e..387f01a 100644 --- a/packages/plugins/src/banner/banner.test.ts +++ b/packages/plugins/src/banner/banner.test.ts @@ -875,4 +875,214 @@ describe('Banner Plugin', () => { expect(button.style.color).toBe('rgb(255, 255, 255)'); }); }); + + describe('HTML Sanitization', () => { + it('should sanitize HTML in title to prevent XSS', () => { + const experience: Experience = { + id: 'xss-test', + type: 'banner', + targeting: {}, + content: { + title: 'Safe Title', + message: 'Test message', + }, + }; + + sdk.emit('experiences:evaluated', { + decision: { + show: true, + experienceId: 'xss-test', + reasons: [], + trace: [], + context: {} as any, + metadata: {}, + }, + experience, + }); + + const banner = document.querySelector('[data-experience-id="xss-test"]') as HTMLElement; + expect(banner).toBeTruthy(); + + const title = banner.querySelector('.xp-banner__title') as HTMLElement; + expect(title).toBeTruthy(); + // Script tag should be stripped + expect(title.innerHTML).not.toContain('World', + }, + }; + + sdk.emit('experiences:evaluated', { + decision: { + show: true, + experienceId: 'xss-test', + reasons: [], + trace: [], + context: {} as any, + metadata: {}, + }, + experience, + }); + + const banner = document.querySelector('[data-experience-id="xss-test"]') as HTMLElement; + expect(banner).toBeTruthy(); + + const message = banner.querySelector('.xp-banner__message') as HTMLElement; + expect(message).toBeTruthy(); + // Script tag should be stripped + expect(message.innerHTML).not.toContain('')).toBe(''); + expect(sanitizeHTML('HelloWorld')).toBe('HelloWorld'); + }); + + it('should strip script tags with attributes', () => { + expect(sanitizeHTML('')).toBe(''); + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip nested script tags', () => { + expect(sanitizeHTML('

')).toBe('

'); + expect(sanitizeHTML('')).toBe( + '' + ); + }); + + it('should strip script tags in mixed content', () => { + expect(sanitizeHTML('SafeText')).toBe( + 'SafeText' + ); + }); + }); + + describe('XSS Prevention - Event Handlers', () => { + it('should strip onclick attributes', () => { + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + expect(sanitizeHTML('Text')).toBe('Text'); + }); + + it('should strip onerror attributes', () => { + expect(sanitizeHTML('')).toBe(''); + expect(sanitizeHTML('Text')).toBe('Text'); + }); + + it('should strip onload attributes', () => { + expect(sanitizeHTML('Text')).toBe('Text'); + }); + + it('should strip onmouseover attributes', () => { + expect(sanitizeHTML('Text')).toBe( + 'Text' + ); + }); + + it('should strip all event handler attributes', () => { + const eventHandlers = [ + 'onclick', + 'onerror', + 'onload', + 'onmouseover', + 'onmouseout', + 'onfocus', + 'onblur', + 'onchange', + 'onsubmit', + 'onkeydown', + 'onkeypress', + 'onkeyup', + ]; + + for (const handler of eventHandlers) { + expect(sanitizeHTML(`Text`)).toBe( + 'Text' + ); + } + }); + }); + + describe('XSS Prevention - Dangerous Tags', () => { + it('should strip iframe tags', () => { + expect(sanitizeHTML('')).toBe(''); + expect(sanitizeHTML('TextMore')).toBe('TextMore'); + }); + + it('should strip object tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip embed tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip form tags', () => { + expect(sanitizeHTML('
')).toBe(''); + }); + + it('should strip input tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip img tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip style tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip link tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip meta tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip video tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip audio tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + + it('should strip svg tags', () => { + expect(sanitizeHTML('')).toBe(''); + }); + }); + + describe('XSS Prevention - javascript: URLs', () => { + it('should strip javascript: URLs in href', () => { + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + }); + + it('should strip javascript: URLs with encoded characters', () => { + expect(sanitizeHTML('Link')).toBe('Link'); + }); + }); + + describe('XSS Prevention - data: URLs', () => { + it('should strip data: URLs in href', () => { + expect( + sanitizeHTML('Link') + ).toBe('Link'); + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + }); + }); + + describe('XSS Prevention - Style-based Attacks', () => { + it('should allow safe style attributes', () => { + expect(sanitizeHTML('Text')).toBe( + 'Text' + ); + }); + + it('should escape HTML in style attributes', () => { + // Quotes in style attributes are escaped for safety + const result = sanitizeHTML('Text'); + expect(result).toContain(''); + // The exact escaping format may vary, but quotes should be escaped + expect(result).not.toContain("color: 'red'"); + }); + }); + + describe('XSS Prevention - HTML Entities', () => { + it('should escape HTML entities in text content', () => { + expect(sanitizeHTML('<script>')).toBe( + '<script>' + ); + expect(sanitizeHTML('<script>alert("xss")</script>')).toBe( + '<script>alert("xss")</script>' + ); + }); + + it('should escape quotes in attributes', () => { + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + }); + }); + + describe('XSS Prevention - Nested Attacks', () => { + it('should handle deeply nested dangerous tags', () => { + expect( + sanitizeHTML('

') + ).toBe('

'); + }); + + it('should handle mixed safe and dangerous content', () => { + expect( + sanitizeHTML( + 'SafeAlso Safe' + ) + ).toBe('SafeAlso Safe'); + }); + }); + + describe('XSS Prevention - Edge Cases', () => { + it('should handle malformed HTML', () => { + expect(sanitizeHTML('Unclosed tag')).toBe('Unclosed tag'); + expect(sanitizeHTML('Closing tag without opening')).toBe( + 'Closing tag without opening' + ); + }); + + it('should handle case variations', () => { + expect(sanitizeHTML('')).toBe(''); + expect(sanitizeHTML('')).toBe(''); + expect(sanitizeHTML('')).toBe(''); + }); + + it('should handle whitespace in tags', () => { + // Tags with whitespace are treated as text (safe) + // Browser normalizes tags, so "< script >" becomes text content + const result1 = sanitizeHTML('< script >alert("xss")'); + expect(result1).toContain('alert("xss")'); + expect(result1).not.toContain('')).toBe(''); + + // Iframe injection + expect(sanitizeHTML('')).toBe(''); + + // Event handler in allowed tag + expect(sanitizeHTML('Click')).toBe( + 'Click' + ); + + // Mixed attack + expect( + sanitizeHTML( + 'WelcomeLink' + ) + ).toBe('WelcomeLink'); + }); + + it('should prevent encoded XSS attacks', () => { + // HTML entity encoded + expect(sanitizeHTML('<script>alert("xss")</script>')).toBe( + '<script>alert("xss")</script>' + ); + + // URL encoded javascript: should be decoded and blocked + // Note: decodeURIComponent will decode %6A%61%76%61%73%63%72%69%70%74%3A to "javascript:" + const result = sanitizeHTML( + 'Link' + ); + expect(result).toBe('Link'); + }); + }); + + describe('URL Sanitization', () => { + it('should allow relative URLs', () => { + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + }); + + it('should allow http and https URLs', () => { + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + }); + + it('should allow mailto and tel URLs', () => { + expect(sanitizeHTML('Email')).toBe( + 'Email' + ); + expect(sanitizeHTML('Call')).toBe( + 'Call' + ); + }); + + it('should block javascript: URLs regardless of case', () => { + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + }); + + it('should block data: URLs', () => { + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + expect(sanitizeHTML('Link')).toBe( + 'Link' + ); + }); + + it('should block other dangerous protocols', () => { + expect(sanitizeHTML('Link')).toBe('Link'); + expect(sanitizeHTML('Link')).toBe('Link'); + }); + }); + + describe('Complex Scenarios', () => { + it('should handle real-world banner content', () => { + const content = ` + Flash Sale! + Get 50% off everything. + Shop Now + `; + const sanitized = sanitizeHTML(content); + expect(sanitized).toContain('Flash Sale!'); + expect(sanitized).toContain('50% off'); + expect(sanitized).toContain('Shop Now'); + expect(sanitized).not.toContain(''); + * // Returns: 'Hello' + * ``` + */ +export function sanitizeHTML(html: string): string { + if (!html || typeof html !== 'string') { + return ''; + } + + // Create a temporary DOM element to parse HTML + const temp = document.createElement('div'); + temp.innerHTML = html; + + /** + * Recursively sanitize a DOM node + */ + function sanitizeNode(node: Node): string { + // Text nodes - escape HTML entities + if (node.nodeType === Node.TEXT_NODE) { + return escapeHTML(node.textContent || ''); + } + + // Element nodes + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + const tagName = element.tagName.toLowerCase(); + + // Handle tags with whitespace (malformed HTML like "< script >") + // Browser normalizes these, but if we see a tag that's not in our list, + // it might be a dangerous tag that was normalized + if (!tagName || tagName.includes(' ')) { + return ''; + } + + // If tag is not allowed, return empty string + if (!ALLOWED_TAGS.includes(tagName as any)) { + return ''; + } + + // Get allowed attributes for this tag + const allowedAttrs = ALLOWED_ATTRIBUTES[tagName] || []; + + // Build attribute string + const attrs: string[] = []; + for (const attr of allowedAttrs) { + const value = element.getAttribute(attr); + if (value !== null) { + // Sanitize attribute values + if (attr === 'href') { + // Only allow safe URLs (http, https, mailto, tel, relative) + const sanitizedHref = sanitizeURL(value); + if (sanitizedHref) { + attrs.push(`href="${escapeAttribute(sanitizedHref)}"`); + } + } else { + // For all other attributes (title, class, style), escape HTML entities + attrs.push(`${attr}="${escapeAttribute(value)}"`); + } + } + } + + const attrString = attrs.length > 0 ? ' ' + attrs.join(' ') : ''; + + // Process child nodes + let innerHTML = ''; + for (const child of Array.from(element.childNodes)) { + innerHTML += sanitizeNode(child); + } + + // Self-closing tags + if (tagName === 'br') { + return ``; + } + + return `<${tagName}${attrString}>${innerHTML}`; + } + + return ''; + } + + // Sanitize all nodes + let sanitized = ''; + for (const child of Array.from(temp.childNodes)) { + sanitized += sanitizeNode(child); + } + + return sanitized; +} + +/** + * Escape HTML entities to prevent XSS in text content + */ +function escapeHTML(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * Escape HTML entities for use in attribute values + */ +function escapeAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Sanitize URL to prevent javascript: and data: XSS attacks + * + * @param url - URL to sanitize + * @returns Sanitized URL or empty string if unsafe + */ +function sanitizeURL(url: string): string { + if (!url || typeof url !== 'string') { + return ''; + } + + // Decode URL-encoded characters to check for encoded attacks + let decoded: string; + try { + decoded = decodeURIComponent(url); + } catch { + // If decoding fails, use original + decoded = url; + } + + const trimmed = decoded.trim().toLowerCase(); + + // Block javascript: and data: protocols (check both original and decoded) + if ( + trimmed.startsWith('javascript:') || + trimmed.startsWith('data:') || + url.toLowerCase().trim().startsWith('javascript:') || + url.toLowerCase().trim().startsWith('data:') + ) { + return ''; + } + + // Allow http, https, mailto, tel, and relative URLs + if ( + trimmed.startsWith('http://') || + trimmed.startsWith('https://') || + trimmed.startsWith('mailto:') || + trimmed.startsWith('tel:') || + trimmed.startsWith('/') || + trimmed.startsWith('#') || + trimmed.startsWith('?') + ) { + return url; // Return original (case preserved) + } + + // Allow relative paths without protocol + if (!trimmed.includes(':')) { + return url; + } + + // Block everything else + return ''; +} From e80ddaab9260a664a84c6b878335f2702cc194e6 Mon Sep 17 00:00:00 2001 From: prosdevlab Date: Fri, 26 Dec 2025 13:49:55 -0800 Subject: [PATCH 4/4] docs: update changeset with HTML sanitizer feature --- .changeset/fix-banner-content-types.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/fix-banner-content-types.md b/.changeset/fix-banner-content-types.md index ec8b8c3..61d33c0 100644 --- a/.changeset/fix-banner-content-types.md +++ b/.changeset/fix-banner-content-types.md @@ -3,7 +3,7 @@ '@prosdevlab/experience-sdk-plugins': patch --- -Fix BannerContent type definition and add CSS customization support: +Fix BannerContent type definition, add CSS customization support, and implement HTML sanitization: - Add `buttons` array property with variant and metadata support - Add `position` property (top/bottom) @@ -11,7 +11,11 @@ Fix BannerContent type definition and add CSS customization support: - Add `className` and `style` props for banner and buttons - Update banner plugin to use `.xp-*` CSS classes - Provide minimal, functional default styles +- Add HTML sanitizer for XSS prevention in title and message fields +- Support safe HTML tags (strong, em, a, br, span, b, i, p) +- Block dangerous tags and event handlers +- Sanitize URLs to prevent javascript: and data: attacks - Aligns core types with banner plugin implementation -This enables users to customize banners with Tailwind, design systems, or CSS frameworks while maintaining SDK's focus on targeting logic. +This enables users to customize banners with Tailwind, design systems, or CSS frameworks while maintaining SDK's focus on targeting logic. HTML sanitization ensures safe rendering of user-provided content.