diff --git a/.changeset/phase-2-presentation-layer.md b/.changeset/phase-2-presentation-layer.md new file mode 100644 index 0000000..22519e5 --- /dev/null +++ b/.changeset/phase-2-presentation-layer.md @@ -0,0 +1,107 @@ +--- +"@prosdevlab/experience-sdk": minor +"@prosdevlab/experience-sdk-plugins": minor +--- + +**Phase 2: Presentation Layer - Modal & Inline Plugins** + +This release introduces two powerful new rendering plugins with built-in form support, completing the presentation layer for the Experience SDK. + +## ๐ŸŽ‰ New Features + +### Modal Plugin +- **Rich Content Modals**: Display announcements, promotions, and interactive content +- **Built-in Forms**: Email capture, surveys, and feedback forms with validation +- **Size Variants**: Small, medium, large, and extra-large sizes +- **Hero Images**: Full-width images for visual impact +- **Responsive Design**: Mobile fullscreen mode for optimal UX +- **Keyboard Navigation**: Focus trap, Escape key, Tab navigation +- **Animations**: Smooth fade-in/fade-out transitions +- **Form States**: Success, error, and loading states +- **API Methods**: `show()`, `remove()`, `isShowing()`, `showFormState()`, `resetForm()`, `getFormData()` + +### Inline Plugin +- **DOM Insertion**: Embed content anywhere in your page +- **5 Insertion Methods**: `replace`, `append`, `prepend`, `before`, `after` +- **CSS Selector Targeting**: Use any valid selector to target elements +- **Dismissal with Persistence**: Users can dismiss and it persists in localStorage +- **No Layout Disruption**: Seamlessly integrates with existing page structure +- **API Methods**: `show()`, `remove()`, `isShowing()` + +### Forms (Built into Modal) +- **Field Types**: text, email, url, tel, number, textarea, select, checkbox, radio +- **Validation**: Required, email, URL, pattern, custom validation, min/max length +- **Real-time Feedback**: Validates on blur, shows errors inline +- **Submission Handling**: Emits `experiences:modal:form:submit` event +- **Success/Error States**: Built-in UI for post-submission states +- **Pure Functions**: Validation and rendering logic easily extractable + +## ๐ŸŽจ Theming & Customization + +### CSS Variables +All plugins now support CSS variable theming: +- **Modal**: `--xp-modal-*` variables for backdrop, dialog, title, content, buttons +- **Forms**: `--xp-form-*` variables for inputs, labels, errors, submit button +- **Banner**: `--xp-banner-*` variables (refactored from inline styles) +- **Inline**: `--xp-inline-*` variables for custom styling + +See the [Theming Guide](https://prosdevlab.github.io/experience-sdk/guides/theming) for full reference. + +## ๐Ÿ”ง API Improvements + +### Runtime +- **Auto-registration**: Modal and inline plugins are automatically registered +- **Plugin API Access**: Expose plugin APIs via singleton (`experiences.modal.show()`) +- **Trigger Event Handling**: Explicit listeners for each trigger type (exit intent, scroll depth, time delay) + +### Display Conditions +Seamless integration with existing display condition plugins: +- **Exit Intent + Modal**: Capture emails before users leave +- **Scroll Depth + Inline**: Progressive feature discovery +- **Time Delay + Modal**: Time-sensitive promotions +- **Page Visits + Banner**: Returning user messages + +## ๐Ÿ“ฆ Bundle Size +- **Core SDK**: 13.4 KB gzipped (under 15 KB target โœ…) +- **All Plugins**: ~26 KB gzipped total (smaller than competitors like Pathfora at ~47 KB) +- **Excellent Compression**: CSS-in-JS with CSS variables maintains small footprint + +## ๐Ÿงช Testing +- **432 tests passing** (unit, integration, browser tests) +- **Modal Plugin**: 56 tests for core functionality, forms, keyboard nav, accessibility +- **Inline Plugin**: 24 tests for DOM insertion, dismissal, persistence +- **Form Validation**: 35 tests for all field types and edge cases +- **Integration Tests**: 10 tests for plugin interactions +- **Exit Intent**: 21 tests with timing and sensitivity validation + +## ๐Ÿ“š Documentation +- **Modal Plugin Reference**: Complete API docs with examples +- **Inline Plugin Reference**: Full insertion method documentation +- **Theming Guide**: CSS variable reference with examples +- **Use Case Examples**: 4 complete implementation guides in playground + +## ๐Ÿš€ Playground Enhancements +- **Layout Gallery Hub**: Visual directory for banner, modal, and inline layouts +- **Navigation System**: Breadcrumbs and sub-navigation tabs +- **Use Case Examples**: + - Exit Intent Email Capture (exit intent + modal forms) + - Feature Discovery Journey (scroll depth + inline + modal) + - Time-Delayed Promotions (time delay + hero image modal) + - Promotions & Announcements (banner examples) +- **Interactive Demos**: Live examples with SDK integration + +## โš ๏ธ Breaking Changes +None. This is a **minor** release with backward compatibility. + +## ๐Ÿ”œ Next Steps (Phase 3+) +- Browser tests for form focus management +- Composable form plugin (separate from modal) +- Additional layout plugins (tooltip, slideout, sticky bar) +- Multi-instance support with `instanceId` tracking + +--- + +**Migration Guide**: No migration needed. Simply upgrade and start using the new plugins! + +**Full Changelog**: See [Phase 2 Spec](https://github.com/prosdevlab/experience-sdk/blob/main/specs/phase-2-presentation-layer/spec.md) + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f640021..3564846 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,21 @@ jobs: - name: Install dependencies run: pnpm install + - name: Get Playwright version + id: playwright-version + run: echo "version=$(node -p "require('./package.json').devDependencies.playwright")" >> $GITHUB_OUTPUT + + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + - name: Lint run: pnpm lint @@ -38,5 +53,8 @@ jobs: - name: Type check run: pnpm typecheck - - name: Test - run: pnpm test \ No newline at end of file + - name: Test (Unit) + run: pnpm test + + - name: Test (Browser) + run: pnpm test:browser \ No newline at end of file diff --git a/README.md b/README.md index 298c28b..77597b4 100644 --- a/README.md +++ b/README.md @@ -4,61 +4,96 @@ **A lightweight, explainable client-side experience runtime built on [@lytics/sdk-kit](https://github.com/lytics/sdk-kit)** -Experience SDK decides if/when/why experiences (banners, modals, tooltips) should render. Every decision comes with structured reasons, making debugging and testing effortless. +Experience SDK enables marketers and developers to create personalized experiences (modals, banners, inline content) with powerful targeting and explainability. Every decision comes with structured reasons, making debugging and testing effortless. ## Features - ๐Ÿ” **Explainability-First** - Every decision includes structured reasons - ๐Ÿงฉ **Plugin-Based** - Built on @lytics/sdk-kit's powerful plugin system -- ๐Ÿ“ฆ **Script Tag Ready** - Works without build tools +- ๐ŸŽจ **Presentation Plugins** - Modal, banner, and inline content rendering +- ๐Ÿ“ **Built-in Forms** - Email capture, surveys, feedback with validation +- ๐ŸŽฏ **Smart Triggers** - Exit intent, scroll depth, time delay, page visits +- ๐Ÿ“ฆ **Script Tag Ready** - Works without build tools (marketers love it!) +- ๐Ÿ’… **CSS Variables** - Easy theming with CSS custom properties - ๐ŸŽฏ **Type-Safe** - Full TypeScript support -- ๐Ÿชถ **Lightweight** - < 15KB gzipped +- ๐Ÿชถ **Lightweight** - ~26KB gzipped with all plugins (13.4KB core) - ๐Ÿ”ง **Developer-Friendly** - Built for inspection and debugging ## Quick Start -### Script Tag +### Script Tag (For Marketers) ```html - + ``` -### npm +### npm (For Developers) ```bash -npm install @prosdevlab/experience-sdk +npm install @prosdevlab/experience-sdk @prosdevlab/experience-sdk-plugins ``` ```typescript -import experiences from '@prosdevlab/experience-sdk'; +import { createInstance } from '@prosdevlab/experience-sdk'; +import { modalPlugin, inlinePlugin, bannerPlugin } from '@prosdevlab/experience-sdk-plugins'; + +const sdk = createInstance({ debug: true }); + +// Use plugins +sdk.use(modalPlugin); +sdk.use(inlinePlugin); +sdk.use(bannerPlugin); + +// Register experiences +sdk.register('feature-tip', { + type: 'inline', + content: { + selector: '#feature-section', + position: 'after', + message: '
๐Ÿ’ก New: Check out our analytics dashboard!
' + }, + display: { + trigger: 'scrollDepth', + triggerData: { threshold: 50 } + } +}); -experiences.init({ debug: true }); -experiences.register('welcome', { ... }); -const decision = experiences.evaluate(); +// Listen to events +sdk.on('experiences:shown', (event) => { + analytics.track('Experience Shown', { id: event.experienceId }); +}); ``` ### Event-Driven Architecture @@ -90,18 +125,38 @@ See the [Events Reference](https://your-docs-url/api/events) for comprehensive d ## Documentation -See [notes/IMPLEMENTATION_PLAN.md](notes/IMPLEMENTATION_PLAN.md) for detailed implementation guide. +- **[Plugin Reference](https://prosdevlab.github.io/experience-sdk/reference/plugins)** - Modal, Banner, Inline plugins +- **[Theming Guide](https://prosdevlab.github.io/experience-sdk/guides/theming)** - CSS variables customization +- **[Playground](https://experience-sdk-playground.vercel.app)** - Live demos and use cases ## Project Status -๐Ÿšง **v0.1.0 in development** - Foundation phase - -- [ ] Core runtime with explainability -- [ ] Storage plugin (frequency capping) -- [ ] Debug plugin (event emission) -- [ ] Banner plugin (delivery) -- [ ] Demo site -- [ ] UMD bundle +โœ… **v0.2.0** - Presentation Layer Complete + +**Core Runtime:** +- โœ… Explainability-first evaluation engine +- โœ… Plugin system (sdk-kit) +- โœ… Event-driven architecture +- โœ… Hybrid API (singleton + instance) + +**Display Condition Plugins:** +- โœ… Exit Intent - Detect users about to leave +- โœ… Scroll Depth - Trigger at scroll thresholds +- โœ… Time Delay - Time-based triggers +- โœ… Page Visits - Session/total visit tracking +- โœ… Frequency Capping - Impression limits + +**Presentation Plugins:** +- โœ… Modal - Announcements, promotions, forms +- โœ… Banner - Top/bottom dismissible messages +- โœ… Inline - Embed content in page DOM + +**Features:** +- โœ… Built-in form support (validation, submission) +- โœ… CSS variable theming +- โœ… TypeScript support +- โœ… 432 tests passing +- โœ… ~26KB gzipped (all plugins) ## Development @@ -149,13 +204,13 @@ Built on [@lytics/sdk-kit](https://github.com/lytics/sdk-kit), Experience SDK sh ## Roadmap -- **Phase 0 (v0.1.0)**: Foundation - Runtime + 3 plugins + demo -- **Phase 1 (v0.2.0)**: Chrome Extension - DevTools integration -- **Phase 2 (v0.3.0)**: Advanced plugins - More experience types -- **Phase 3 (v0.4.0)**: Developer tools - Playground & testing -- **Phase 4 (v1.0.0)**: Production-ready +- โœ… **Phase 0 (v0.1.0)**: Foundation - Core runtime, display condition plugins, banner plugin +- โœ… **Phase 1 (v0.2.0)**: Presentation Layer - Modal & inline plugins with forms +- ๐Ÿšง **Phase 2 (v0.3.0)**: Developer Experience - Chrome DevTools extension +- ๐Ÿšง **Phase 3 (v0.4.0)**: Advanced Features - Tooltip plugin, multi-instance support +- ๐Ÿšง **Phase 4 (v1.0.0)**: Production Ready - Performance optimizations, advanced targeting -See [notes/vision-and-roadmap.md](notes/vision-and-roadmap.md) for full roadmap. +See the [full roadmap](notes/vision-and-roadmap.md) for details. ## License diff --git a/biome.json b/biome.json index 9cb3c0e..5ca1402 100644 --- a/biome.json +++ b/biome.json @@ -31,10 +31,16 @@ "includes": [ "packages/core/src/types.ts", "packages/core/src/runtime.ts", + "packages/core/src/singleton.ts", "packages/plugins/src/types.ts", "packages/plugins/src/exit-intent/exit-intent.ts", "packages/plugins/src/scroll-depth/scroll-depth.ts", "packages/plugins/src/page-visits/page-visits.ts", + "packages/plugins/src/time-delay/time-delay.ts", + "packages/plugins/src/modal/modal.ts", + "packages/plugins/src/modal/types.ts", + "packages/plugins/src/inline/inline.ts", + "packages/plugins/src/inline/types.ts", "packages/plugins/src/utils/sanitize.ts", "specs/**/contracts/types.ts", "docs/**/*.tsx" diff --git a/docs/pages/_meta.json b/docs/pages/_meta.json index 5e2ff93..82c2c37 100644 --- a/docs/pages/_meta.json +++ b/docs/pages/_meta.json @@ -1,6 +1,7 @@ { "index": "Introduction", "getting-started": "Getting Started", + "guides": "Guides", "demo": "Examples", "reference": "API Reference" } diff --git a/docs/pages/guides/_meta.json b/docs/pages/guides/_meta.json new file mode 100644 index 0000000..b34db19 --- /dev/null +++ b/docs/pages/guides/_meta.json @@ -0,0 +1,4 @@ +{ + "theming": "CSS Theming" +} + diff --git a/docs/pages/guides/theming.mdx b/docs/pages/guides/theming.mdx new file mode 100644 index 0000000..ec1f832 --- /dev/null +++ b/docs/pages/guides/theming.mdx @@ -0,0 +1,528 @@ +# CSS Theming + +The Experience SDK uses CSS custom properties (variables) for comprehensive theming. All layout plugins (Banner, Modal, Inline) support full visual customization without overriding styles. + +## Quick Start + +Override CSS variables in your stylesheet: + +```css +:root { + /* Brand colors */ + --xp-modal-button-primary-bg: #8b5cf6; /* Purple */ + --xp-banner-bg: #1f2937; /* Dark gray */ + --xp-inline-button-primary-bg: #0ea5e9; /* Sky blue */ +} +``` + +All changes apply automatically to all experiences. + +## Banner Variables + +### Container & Layout + +```css +:root { + --xp-banner-z-index: 10000; + --xp-banner-padding: 16px 20px; + --xp-banner-gap: 16px; /* Space between elements */ +} +``` + +### Colors & Backgrounds + +```css +:root { + /* Background */ + --xp-banner-bg: #ffffff; + --xp-banner-border: 1px solid #e5e7eb; + --xp-banner-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + /* Text */ + --xp-banner-title-color: #111827; + --xp-banner-message-color: #374151; + + /* Close button */ + --xp-banner-close-color: #6b7280; + --xp-banner-close-hover-color: #111827; +} +``` + +### Typography + +```css +:root { + --xp-banner-title-font-size: 16px; + --xp-banner-title-font-weight: 600; + --xp-banner-title-margin-bottom: 4px; + + --xp-banner-message-font-size: 14px; + --xp-banner-message-line-height: 1.5; +} +``` + +### Buttons + +```css +:root { + /* Primary button */ + --xp-banner-button-primary-bg: #2563eb; + --xp-banner-button-primary-color: #ffffff; + --xp-banner-button-primary-hover-bg: #1d4ed8; + + /* Secondary button */ + --xp-banner-button-secondary-bg: #f3f4f6; + --xp-banner-button-secondary-color: #374151; + --xp-banner-button-secondary-hover-bg: #e5e7eb; + --xp-banner-button-secondary-border: 1px solid #e5e7eb; + + /* Link button */ + --xp-banner-button-link-color: #2563eb; + --xp-banner-button-link-hover-bg: #f3f4f6; + + /* Size */ + --xp-banner-button-padding: 8px 16px; + --xp-banner-button-font-size: 14px; + --xp-banner-button-font-weight: 500; + --xp-banner-button-border-radius: 6px; + --xp-banner-buttons-gap: 8px; +} +``` + +## Modal Variables + +### Container & Backdrop + +```css +:root { + --xp-modal-z-index: 10001; + --xp-modal-backdrop-bg: rgba(0, 0, 0, 0.5); +} +``` + +### Dialog + +```css +:root { + --xp-modal-dialog-bg: #ffffff; + --xp-modal-dialog-border-radius: 8px; + --xp-modal-dialog-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + --xp-modal-dialog-padding: 24px; +} +``` + +### Content + +```css +:root { + /* Title */ + --xp-modal-title-font-size: 20px; + --xp-modal-title-font-weight: 600; + --xp-modal-title-color: #111; + --xp-modal-title-margin-bottom: 16px; + + /* Message */ + --xp-modal-message-font-size: 14px; + --xp-modal-message-line-height: 1.5; + --xp-modal-message-color: #444; + --xp-modal-message-margin-bottom: 20px; + + /* Close button */ + --xp-modal-close-color: #666; + --xp-modal-close-hover-color: #111; +} +``` + +### Buttons + +```css +:root { + /* Primary button */ + --xp-modal-button-primary-bg: #2563eb; + --xp-modal-button-primary-color: #ffffff; + --xp-modal-button-primary-hover-bg: #1d4ed8; + + /* Secondary button */ + --xp-modal-button-secondary-bg: #f3f4f6; + --xp-modal-button-secondary-color: #374151; + --xp-modal-button-secondary-hover-bg: #e5e7eb; + --xp-modal-button-secondary-border-color: #e5e7eb; + + /* Link button */ + --xp-modal-button-link-color: #2563eb; + --xp-modal-button-link-hover-bg: #f3f4f6; + + /* Size */ + --xp-modal-button-padding: 10px 20px; + --xp-modal-button-font-size: 14px; + --xp-modal-button-font-weight: 500; + --xp-modal-button-border-radius: 6px; + --xp-modal-buttons-gap: 8px; +} +``` + +### Forms + +```css +:root { + /* Form container */ + --xp-form-gap: 16px; + --xp-form-field-gap: 8px; + + /* Label */ + --xp-form-label-font-size: 14px; + --xp-form-label-font-weight: 500; + --xp-form-label-color: #1f2937; + --xp-form-required-color: #ef4444; + + /* Input fields */ + --xp-input-bg: #ffffff; + --xp-input-border-color: #d1d5db; + --xp-input-border-radius: 6px; + --xp-input-padding: 10px 12px; + --xp-input-font-size: 14px; + --xp-input-color: #1f2937; + --xp-input-placeholder-color: #6b7280; + + /* Focus state */ + --xp-input-focus-border-color: #2563eb; + --xp-input-focus-ring-color: rgba(59, 130, 246, 0.25); + + /* Error state */ + --xp-input-error-border-color: #ef4444; + --xp-input-error-focus-border-color: #dc2626; + --xp-input-error-focus-ring-color: rgba(239, 68, 68, 0.25); + --xp-error-message-color: #ef4444; + --xp-error-message-font-size: 12px; + + /* Submit button */ + --xp-submit-button-bg-primary: #2563eb; + --xp-submit-button-color-primary: #ffffff; + --xp-submit-button-hover-bg-primary: #1d4ed8; + --xp-submit-button-loading-bg: #e5e7eb; + --xp-submit-button-loading-color: #6b7280; + + /* Success/error states */ + --xp-form-state-success-bg: #ecfdf5; + --xp-form-state-success-border: 1px solid #a7f3d0; + --xp-form-state-success-color: #065f46; + + --xp-form-state-error-bg: #fef2f2; + --xp-form-state-error-border: 1px solid #fca5a5; + --xp-form-state-error-color: #991b1b; +} +``` + +## Inline Variables + +### Close Button + +```css +:root { + --xp-inline-close-color: #666; + --xp-inline-close-hover-color: #111; + --xp-inline-close-size: 32px; + --xp-inline-close-bg: transparent; + --xp-inline-close-hover-bg: rgba(0, 0, 0, 0.1); + --xp-inline-close-border-radius: 4px; +} +``` + +### Buttons + +```css +:root { + /* Primary button */ + --xp-inline-button-primary-bg: #2563eb; + --xp-inline-button-primary-color: #ffffff; + --xp-inline-button-primary-hover-bg: #1d4ed8; + + /* Secondary button */ + --xp-inline-button-secondary-bg: #f3f4f6; + --xp-inline-button-secondary-color: #374151; + --xp-inline-button-secondary-hover-bg: #e5e7eb; + --xp-inline-button-secondary-border-color: #e5e7eb; + + /* Link button */ + --xp-inline-button-link-color: #2563eb; + --xp-inline-button-link-hover-bg: #f3f4f6; + + /* Size */ + --xp-inline-button-padding: 8px 16px; + --xp-inline-button-font-size: 14px; + --xp-inline-button-font-weight: 500; + --xp-inline-button-border-radius: 4px; + --xp-inline-buttons-gap: 8px; +} +``` + +## Dark Mode + +All plugins include automatic dark mode support using `prefers-color-scheme`: + +```css +@media (prefers-color-scheme: dark) { + :root { + /* Banner */ + --xp-banner-bg: #1f2937; + --xp-banner-title-color: #f9fafb; + --xp-banner-message-color: #d1d5db; + + /* Modal */ + --xp-modal-backdrop-bg: rgba(0, 0, 0, 0.7); + --xp-modal-dialog-bg: #1f2937; + --xp-modal-title-color: #f9fafb; + --xp-modal-message-color: #d1d5db; + + /* Forms */ + --xp-input-bg: #1f2937; + --xp-input-border-color: #374151; + --xp-input-color: #f9fafb; + + /* Buttons */ + --xp-modal-button-primary-bg: #3b82f6; + --xp-modal-button-secondary-bg: #374151; + --xp-modal-button-secondary-color: #f9fafb; + } +} +``` + +## Complete Examples + +### Brand Colors + +```css +:root { + /* Apply your brand colors across all experiences */ + --xp-banner-button-primary-bg: #8b5cf6; /* Purple */ + --xp-modal-button-primary-bg: #8b5cf6; + --xp-inline-button-primary-bg: #8b5cf6; + + --xp-banner-button-primary-hover-bg: #7c3aed; + --xp-modal-button-primary-hover-bg: #7c3aed; + --xp-inline-button-primary-hover-bg: #7c3aed; +} +``` + +### E-Commerce Theme + +```css +:root { + /* High-contrast, conversion-focused */ + --xp-banner-bg: #000000; + --xp-banner-title-color: #ffffff; + --xp-banner-message-color: #d1d5db; + + --xp-banner-button-primary-bg: #ef4444; /* Red CTA */ + --xp-banner-button-primary-color: #ffffff; + --xp-banner-button-primary-hover-bg: #dc2626; + + --xp-banner-button-secondary-bg: transparent; + --xp-banner-button-secondary-color: #ffffff; + --xp-banner-button-secondary-border: 1px solid #ffffff; + --xp-banner-button-secondary-hover-bg: rgba(255, 255, 255, 0.1); +} +``` + +### SaaS Theme + +```css +:root { + /* Clean, professional */ + --xp-modal-dialog-border-radius: 12px; + --xp-modal-dialog-padding: 32px; + --xp-modal-dialog-box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + + --xp-modal-button-primary-bg: #0ea5e9; /* Sky blue */ + --xp-modal-button-primary-hover-bg: #0284c7; + --xp-modal-button-border-radius: 8px; + + --xp-input-border-radius: 8px; + --xp-input-padding: 12px 16px; + --xp-input-focus-border-color: #0ea5e9; + --xp-input-focus-ring-color: rgba(14, 165, 233, 0.25); +} +``` + +### Minimalist Theme + +```css +:root { + /* Subtle, understated */ + --xp-banner-bg: #fafafa; + --xp-banner-border: none; + --xp-banner-shadow: none; + --xp-banner-padding: 12px 16px; + + --xp-banner-title-font-size: 14px; + --xp-banner-message-font-size: 13px; + + --xp-banner-button-primary-bg: #171717; + --xp-banner-button-primary-color: #ffffff; + --xp-banner-button-secondary-bg: transparent; + --xp-banner-button-secondary-color: #171717; + --xp-banner-button-border-radius: 4px; +} +``` + +## Per-Experience Customization + +For experience-specific styling, use `className` or `style` props: + +### Using className + +```typescript +experiences.register('sale', { + type: 'modal', + content: { + title: 'Flash Sale!', + message: '24 hours only.', + className: 'sale-modal', // Custom class + buttons: [...] + } +}); +``` + +```css +/* Your CSS */ +.sale-modal { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.sale-modal .xp-modal__title { + font-size: 32px; + text-transform: uppercase; +} +``` + +### Using Inline Styles + +```typescript +{ + content: { + message: 'Limited offer', + style: { + background: '#fef3c7', + border: '2px solid #f59e0b', + borderRadius: '12px' + } + } +} +``` + +## CSS Class Reference + +### Banner + +- `.xp-banner` - Container +- `.xp-banner__close` - Close button +- `.xp-banner__title` - Title text +- `.xp-banner__message` - Message text +- `.xp-banner__buttons` - Button container +- `.xp-banner__button` - Individual button +- `.xp-banner__button--primary` - Primary variant +- `.xp-banner__button--secondary` - Secondary variant +- `.xp-banner__button--link` - Link variant + +### Modal + +- `.xp-modal` - Container +- `.xp-modal__backdrop` - Backdrop overlay +- `.xp-modal__dialog` - Dialog box +- `.xp-modal__close` - Close button +- `.xp-modal__hero-image` - Hero image (if present) +- `.xp-modal__content` - Content wrapper +- `.xp-modal__title` - Title text +- `.xp-modal__message` - Message text +- `.xp-modal__buttons` - Button container +- `.xp-modal__button` - Individual button +- `.xp-modal__form` - Form container +- `.xp-form__field` - Form field wrapper +- `.xp-form__label` - Field label +- `.xp-form__input` - Input field +- `.xp-form__error` - Error message +- `.xp-form__submit` - Submit button + +### Inline + +- `.xp-inline` - Container +- `.xp-inline__close` - Close button +- `.xp-inline__buttons` - Button container +- `.xp-inline__button` - Individual button + +## Integration with Frameworks + +### Tailwind CSS + +```typescript +{ + content: { + message: 'Flash Sale!', + className: 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-2xl', + buttons: [{ + text: 'Shop Now', + className: 'bg-white text-purple-600 hover:bg-gray-100 font-bold' + }] + } +} +``` + +### CSS Modules + +```typescript +import styles from './experiences.module.css'; + +{ + content: { + message: 'Special offer', + className: styles.promo, + buttons: [{ + text: 'Learn More', + className: styles.button + }] + } +} +``` + +### Styled Components + +```typescript +import { createGlobalStyle } from 'styled-components'; + +const GlobalStyles = createGlobalStyle` + :root { + --xp-modal-button-primary-bg: ${props => props.theme.colors.primary}; + --xp-banner-bg: ${props => props.theme.colors.surface}; + } +`; +``` + +## Best Practices + +1. **Define once, apply everywhere** - Set global CSS variables for consistent branding +2. **Use semantic naming** - CSS variables describe purpose, not appearance +3. **Support dark mode** - Include `prefers-color-scheme` overrides +4. **Test responsively** - Some variables affect mobile differently +5. **Progressive enhancement** - Styles gracefully degrade if variables unsupported +6. **Avoid `!important`** - CSS variables have high specificity already + +## Browser Support + +CSS custom properties are supported in: +- Chrome 49+ +- Firefox 31+ +- Safari 9.1+ +- Edge 15+ +- iOS Safari 9.3+ +- Chrome Android 49+ + +For older browsers, fallback values are automatically applied. + +## Next Steps + +- [Banner Plugin](/reference/plugins/banner) - Banner-specific examples +- [Modal Plugin](/reference/plugins/modal) - Modal-specific examples +- [Inline Plugin](/reference/plugins/inline) - Inline-specific examples +- [Events](/reference/events) - Listen to experience events + diff --git a/docs/pages/reference/plugins/_meta.json b/docs/pages/reference/plugins/_meta.json index 394ecff..c1b8a92 100644 --- a/docs/pages/reference/plugins/_meta.json +++ b/docs/pages/reference/plugins/_meta.json @@ -1,6 +1,8 @@ { "index": "Overview", "banner": "Banner", + "modal": "Modal", + "inline": "Inline", "frequency": "Frequency", "debug": "Debug", "exit-intent": "Exit Intent", diff --git a/docs/pages/reference/plugins/index.mdx b/docs/pages/reference/plugins/index.mdx index 31dfdbc..eac162e 100644 --- a/docs/pages/reference/plugins/index.mdx +++ b/docs/pages/reference/plugins/index.mdx @@ -4,14 +4,18 @@ The Experience SDK uses a plugin architecture powered by [@lytics/sdk-kit](https ## Official Plugins -### Rendering Plugins +### Layout Plugins -- **[Banner](/reference/plugins/banner)** - Renders banner experiences in the DOM with automatic positioning, theming, and responsive layout +Layout plugins render experiences in the DOM with different presentation styles. + +- **[Banner](/reference/plugins/banner)** - Top/bottom notification bars with push-down support +- **[Modal](/reference/plugins/modal)** - Overlay dialogs with forms, hero images, and animations +- **[Inline](/reference/plugins/inline)** - In-content experiences using CSS selectors ### Utility Plugins -- **[Frequency](/reference/plugins/frequency)** - Manages impression tracking and frequency capping using persistent storage -- **[Debug](/reference/plugins/debug)** - Provides console logging and window event emission for debugging +- **[Frequency](/reference/plugins/frequency)** - Impression tracking and frequency capping +- **[Debug](/reference/plugins/debug)** - Console logging and window event emission ### Display Condition Plugins diff --git a/docs/pages/reference/plugins/inline.mdx b/docs/pages/reference/plugins/inline.mdx new file mode 100644 index 0000000..523478c --- /dev/null +++ b/docs/pages/reference/plugins/inline.mdx @@ -0,0 +1,489 @@ +# Inline Plugin + +Embed experiences directly within page content using CSS selectors. Perfect for in-content promotions, contextual CTAs, and dynamic content replacement. + +## Features + +- **5 insertion methods** - `replace`, `append`, `prepend`, `before`, `after` +- **Dismissal with persistence** - Remember dismissed experiences +- **Multi-instance support** - Multiple inline experiences per page +- **HTML sanitization** - XSS protection built-in +- **Custom styling** - `className` and `style` props +- **CSS Variables** - Full theming support + +## Basic Usage + +```typescript +import { experiences } from '@prosdevlab/experience-sdk'; + +experiences.register('promo-banner', { + type: 'inline', + content: { + selector: '#content-sidebar', + message: '

Special Offer! Get 20% off with code SAVE20.

', + buttons: [ + { text: 'Shop Now', variant: 'primary', url: '/shop' } + ] + } +}); +``` + +## Insertion Methods + +### Replace (Default) + +Replaces the target element's content: + +```typescript +{ + selector: '#announcement', + position: 'replace', // or omit (default) + message: '

New content here

' +} +``` + +**Before:** +```html +
+

Old content

+
+``` + +**After:** +```html +
+
+

New content here

+
+
+``` + +### Append + +Adds content to the end of the target element: + +```typescript +{ + selector: '.article-content', + position: 'append', + message: '

Related: Check out our complete guide.

' +} +``` + +### Prepend + +Adds content to the beginning of the target element: + +```typescript +{ + selector: '.product-list', + position: 'prepend', + message: '

๐Ÿ”ฅ Trending: Most popular items this week.

' +} +``` + +### Before + +Inserts content as a sibling before the target element: + +```typescript +{ + selector: '.checkout-button', + position: 'before', + message: '

โœ“ Free shipping on orders over $50

' +} +``` + +### After + +Inserts content as a sibling after the target element: + +```typescript +{ + selector: '.blog-post', + position: 'after', + message: '

Enjoyed this article? Subscribe for more.

' +} +``` + +## Dismissal + +Allow users to close inline experiences: + +```typescript +{ + selector: '#sidebar', + message: '

Limited time offer!

', + dismissable: true, // Show close button + persist: true // Remember dismissal in localStorage +} +``` + +When `persist: true`, the inline experience won't show again after dismissal (stored in `localStorage`). + +## Buttons + +Add interactive buttons to inline content: + +```typescript +{ + selector: '#product-details', + message: '

Want expert advice?

', + buttons: [ + { + text: 'Chat with Us', + variant: 'primary', + action: 'chat', + dismiss: false // Keep inline visible + }, + { + text: 'No Thanks', + variant: 'link', + dismiss: true // Close on click + } + ] +} +``` + +## Custom Styling + +### Using className + +```typescript +{ + selector: '#hero', + message: '

Flash Sale!

', + className: 'sale-banner', + buttons: [...] +} +``` + +```css +/* Your CSS */ +.sale-banner { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px; + border-radius: 8px; +} +``` + +### Using Inline Styles + +```typescript +{ + selector: '#sidebar', + message: '

New feature!

', + style: { + background: '#f0f9ff', + border: '2px solid #0ea5e9', + padding: '16px', + borderRadius: '8px' + } +} +``` + +## Events + +Listen to inline experience events: + +```typescript +import { experiences } from '@prosdevlab/experience-sdk'; + +// Inline shown +experiences.on('experiences:shown', (event) => { + if (event.type === 'inline') { + console.log('Inline shown:', event.experienceId); + console.log('Selector:', event.selector); + console.log('Position:', event.position); + } +}); + +// Button clicked +experiences.on('experiences:action', (event) => { + console.log('Button clicked:', event.action); + console.log('Button metadata:', event.metadata); +}); + +// Inline dismissed +experiences.on('experiences:dismissed', (event) => { + console.log('Inline dismissed:', event.experienceId); +}); + +// Error (selector not found) +experiences.on('experiences:inline:error', (event) => { + console.error('Selector not found:', event.selector); +}); +``` + +## API Methods + +```typescript +import { experiences } from '@prosdevlab/experience-sdk'; + +// Show an inline programmatically +experiences.inline.show(experience); + +// Remove a specific inline +experiences.inline.remove('promo-banner'); + +// Check if any inline is showing +experiences.inline.isShowing(); // true/false + +// Check if specific inline is showing +experiences.inline.isShowing('promo-banner'); // true/false +``` + +## Error Handling + +If the target selector is not found, the plugin emits an error event: + +```typescript +experiences.on('experiences:inline:error', (event) => { + console.error('Selector not found:', event.selector); + // event.experienceId, event.error ('selector-not-found') +}); +``` + +### Retry Logic + +Enable automatic retries for dynamic content: + +```typescript +import { createInstance } from '@prosdevlab/experience-sdk'; + +const experiences = createInstance({ + inline: { + retry: true, // Retry if selector not found + retryTimeout: 5000 // Retry for up to 5 seconds + } +}); +``` + +## CSS Variables + +Customize inline appearance: + +```css +:root { + /* Close button */ + --xp-inline-close-color: #666; + --xp-inline-close-hover-color: #111; + --xp-inline-close-size: 32px; + --xp-inline-close-bg: transparent; + --xp-inline-close-hover-bg: rgba(0, 0, 0, 0.1); + --xp-inline-close-border-radius: 4px; + + /* Buttons */ + --xp-inline-button-padding: 8px 16px; + --xp-inline-button-font-size: 14px; + --xp-inline-button-border-radius: 4px; + --xp-inline-button-primary-bg: #2563eb; + --xp-inline-button-primary-color: #ffffff; + --xp-inline-button-secondary-bg: #f3f4f6; + --xp-inline-button-secondary-color: #374151; + --xp-inline-buttons-gap: 8px; +} + +/* Dark mode */ +@media (prefers-color-scheme: dark) { + :root { + --xp-inline-close-color: #9ca3af; + --xp-inline-close-hover-color: #f9fafb; + --xp-inline-button-primary-bg: #3b82f6; + --xp-inline-button-secondary-bg: #374151; + --xp-inline-button-secondary-color: #f9fafb; + } +} +``` + +## HTML Sanitization + +All HTML content is automatically sanitized to prevent XSS attacks. + +**Allowed tags:** +- `strong`, `em`, `b`, `i` - Text formatting +- `a` - Links (with `href` validation) +- `p`, `span`, `br` - Structure + +**Allowed attributes:** +- `href` (links only, safe URLs) +- `class`, `style` + +**Dangerous tags removed:** +- `script`, `iframe`, `object`, `embed` +- `style` tags (use `style` prop instead) +- Event handlers (`onclick`, `onerror`, etc.) + +```typescript +// Input +{ + message: '

Safe content

' +} + +// Output (script removed) +

Safe content

+``` + +## Examples + +### In-Content CTA + +```typescript +experiences.register('article-cta', { + type: 'inline', + content: { + selector: '.article-content', + position: 'after', + message: '

Want to learn more? Download our free guide.

', + buttons: [ + { text: 'Download Guide', variant: 'primary', url: '/guide.pdf' } + ], + className: 'article-cta', + dismissable: true, + persist: true + }, + targeting: { + url: { contains: '/blog/' } + } +}); +``` + +### Product Recommendation + +```typescript +experiences.register('related-product', { + type: 'inline', + content: { + selector: '.product-sidebar', + position: 'prepend', + message: ` +

You might also like:

+

Premium Wireless Headphones - 20% off

+ `, + buttons: [ + { text: 'View Deal', variant: 'primary', url: '/products/headphones' }, + { text: 'Dismiss', variant: 'link', dismiss: true } + ], + style: { + background: '#fef3c7', + padding: '16px', + borderRadius: '8px', + marginBottom: '16px' + } + } +}); +``` + +### Survey Prompt + +```typescript +experiences.register('feedback-survey', { + type: 'inline', + content: { + selector: '#main-content', + position: 'after', + message: '

How was your experience today?

', + buttons: [ + { text: '๐Ÿ˜Š Great', variant: 'primary', action: 'positive' }, + { text: '๐Ÿ˜ Okay', variant: 'secondary', action: 'neutral' }, + { text: '๐Ÿ˜ž Poor', variant: 'secondary', action: 'negative' } + ], + dismissable: true, + persist: true + }, + targeting: { + custom: (context) => context.pageVisits.sessionCount === 3 + } +}); +``` + +### Exit Intent Offer + +```typescript +experiences.register('exit-offer', { + type: 'inline', + content: { + selector: 'header', + position: 'after', + message: '

๐ŸŽ Wait! Get 15% off before you go.

', + buttons: [ + { text: 'Claim Discount', variant: 'primary', url: '/checkout?code=EXIT15' }, + { text: 'No Thanks', variant: 'link', dismiss: true } + ], + style: { + background: '#dc2626', + color: 'white', + padding: '12px 20px', + textAlign: 'center' + } + }, + targeting: { + custom: (context) => context.triggers.exitIntent + } +}); +``` + +### Dynamic Content Replacement + +```typescript +experiences.register('featured-promo', { + type: 'inline', + content: { + selector: '#hero-banner', + position: 'replace', + message: ` +
+

๐ŸŽ‰ Black Friday Sale

+

Up to 70% off everything. Today only!

+
+ `, + buttons: [ + { text: 'Shop Now', variant: 'primary', url: '/sale' } + ] + }, + targeting: { + url: { equals: '/' } + } +}); +``` + +## Use Cases + +- **Content upgrades** - Offer downloads within blog posts +- **Related products** - Cross-sell in product pages +- **Survey prompts** - Contextual feedback requests +- **Announcements** - Temporary notifications in key areas +- **CTAs** - Strategic calls-to-action in content +- **Promotions** - Time-sensitive offers +- **Onboarding tips** - Contextual help for new users + +## Best Practices + +1. **Use semantic selectors** - IDs and classes are more reliable than tag names +2. **Test responsiveness** - Ensure inline content works on mobile +3. **Limit frequency** - Use the frequency plugin to avoid overwhelming users +4. **Provide value** - Make inline content relevant to the page context +5. **Allow dismissal** - Let users close promotional content +6. **Monitor errors** - Listen to `experiences:inline:error` events + +## Accessibility + +- **Semantic HTML** - Use proper tags in your message content +- **Close button** - Labeled with `aria-label="Close"` +- **Keyboard support** - Close button is keyboard accessible +- **Screen readers** - Content is announced naturally + +## Browser Support + +- Chrome, Firefox, Safari, Edge (last 2 versions) +- Mobile: iOS Safari, Chrome Android +- Requires ES2024+ support (or transpilation) + +## Next Steps + +- [Modal Plugin](/reference/plugins/modal) - Overlay dialogs +- [Banner Plugin](/reference/plugins/banner) - Top/bottom bars +- [Display Conditions](/reference/plugins/exit-intent) - Trigger rules +- [Events Reference](/reference/events) - Full event documentation + diff --git a/docs/pages/reference/plugins/modal.mdx b/docs/pages/reference/plugins/modal.mdx new file mode 100644 index 0000000..2042585 --- /dev/null +++ b/docs/pages/reference/plugins/modal.mdx @@ -0,0 +1,453 @@ +# Modal Plugin + +Display experiences in overlay dialogs with backdrop, focus management, and rich content. + +## Features + +- **Multiple sizes** - `sm`, `md`, `lg`, `fullscreen`, or `auto` +- **Mobile-optimized** - Automatic fullscreen on mobile for `lg` size +- **Hero images** - Add visual impact with top images +- **Animations** - `fade`, `slide-up`, or none +- **Positioning** - `center` or `bottom` placement +- **Form support** - Built-in email capture with validation +- **Accessibility** - Focus trap, ARIA attributes, keyboard support (Escape) +- **Dismissal** - Configurable close button and backdrop dismiss +- **CSS Variables** - Full theming support + +## Basic Usage + +```typescript +import { experiences } from '@prosdevlab/experience-sdk'; + +experiences.register('welcome-modal', { + type: 'modal', + content: { + title: 'Welcome!', + message: 'Get 20% off your first purchase.', + buttons: [ + { text: 'Shop Now', variant: 'primary', url: '/shop' }, + { text: 'Maybe Later', variant: 'secondary', dismiss: true } + ] + } +}); +``` + +## Content Options + +### Basic Modal + +```typescript +{ + type: 'modal', + content: { + // Text content + title: 'Flash Sale!', + message: 'Limited time offer - 50% off everything.', + + // Buttons + buttons: [ + { + text: 'Shop Now', + variant: 'primary', + url: '/shop', + dismiss: true // Close modal on click + }, + { + text: 'Dismiss', + variant: 'link', + dismiss: true + } + ], + + // Size and behavior + size: 'md', // sm | md | lg | fullscreen | auto + position: 'center', // center | bottom + animation: 'fade', // fade | slide-up | none + dismissable: true, // Show close button + backdropDismiss: true, // Click backdrop to close + + // Hero image (optional) + image: { + src: '/images/hero.jpg', + alt: 'Flash Sale', + maxHeight: 300 + }, + + // Custom styling + className: 'custom-modal', + style: { borderRadius: '16px' } + } +} +``` + +### Modal with Form + +Capture emails, phone numbers, or custom data with built-in validation. + +```typescript +{ + type: 'modal', + content: { + title: 'Stay Updated', + message: 'Subscribe to our newsletter for exclusive offers.', + + form: { + fields: [ + { + name: 'email', + label: 'Email Address', + type: 'email', + placeholder: 'you@example.com', + required: true, + errorMessage: 'Please enter a valid email' + } + ], + + submitButton: { + text: 'Subscribe', + variant: 'primary', + loadingText: 'Subscribing...' + }, + + // Optional: Custom validation + validate: (data) => { + if (data.email.includes('test')) { + return { + valid: false, + errors: { email: 'Test emails not allowed' } + }; + } + return { valid: true }; + }, + + // Success state + successState: { + title: 'โœ“ Subscribed!', + message: 'Check your inbox for a confirmation email.', + buttons: [ + { text: 'Continue', variant: 'primary', dismiss: true } + ] + }, + + // Error state + errorState: { + title: 'โœ— Something went wrong', + message: 'Please try again later.', + buttons: [ + { text: 'Try Again', variant: 'primary', action: 'reset' }, + { text: 'Cancel', variant: 'secondary', dismiss: true } + ] + } + } + } +} +``` + +## Form Field Types + +```typescript +{ + fields: [ + { name: 'email', type: 'email', required: true }, + { name: 'name', type: 'text', placeholder: 'Your name' }, + { name: 'phone', type: 'tel', pattern: '^[0-9]{10}$' }, + { name: 'website', type: 'url' }, + { name: 'age', type: 'number' }, + { + name: 'comments', + type: 'textarea', + placeholder: 'Tell us more...' + } + ] +} +``` + +## Size Variants + +```typescript +// Small modal (max-width: 400px) +{ size: 'sm' } + +// Medium modal (max-width: 600px) - Default +{ size: 'md' } + +// Large modal (max-width: 800px) +{ size: 'lg' } + +// Full viewport (100vw x 100vh) +{ size: 'fullscreen' } + +// Auto-size based on content +{ size: 'auto' } +``` + +**Mobile behavior:** `lg` size automatically becomes fullscreen on mobile (`< 768px`). + +## Animations + +```typescript +// Fade in (default) +{ animation: 'fade' } + +// Slide up from bottom +{ animation: 'slide-up' } + +// No animation +{ animation: 'none' } +``` + +## Hero Images + +Add visual impact to modals: + +```typescript +{ + image: { + src: '/promo-banner.jpg', + alt: 'Summer Sale', + maxHeight: 250 // Max height in pixels + } +} +``` + +## Events + +Listen to modal lifecycle events: + +```typescript +import { experiences } from '@prosdevlab/experience-sdk'; + +// Modal shown +experiences.on('experiences:shown', (event) => { + if (event.type === 'modal') { + console.log('Modal shown:', event.experienceId); + } +}); + +// Button clicked +experiences.on('experiences:action', (event) => { + console.log('Button clicked:', event.action, event.text); +}); + +// Modal dismissed +experiences.on('experiences:dismissed', (event) => { + console.log('Modal dismissed:', event.experienceId); +}); + +// Form events +experiences.on('experiences:modal:form:change', (event) => { + console.log('Form field changed:', event.field, event.value); +}); + +experiences.on('experiences:modal:form:validation', (event) => { + if (!event.valid) { + console.log('Validation errors:', event.errors); + } +}); + +experiences.on('experiences:modal:form:submit', (event) => { + console.log('Form submitted:', event.data); + + // Submit to your backend + fetch('/api/subscribe', { + method: 'POST', + body: JSON.stringify(event.data) + }) + .then(() => { + // Show success state + experiences.modal.showFormState(event.experienceId, 'success'); + }) + .catch(() => { + // Show error state + experiences.modal.showFormState(event.experienceId, 'error'); + }); +}); +``` + +## API Methods + +```typescript +import { experiences } from '@prosdevlab/experience-sdk'; + +// Show a modal programmatically +experiences.modal.show(experience); + +// Hide a specific modal +experiences.modal.hide('welcome-modal'); + +// Hide all modals +experiences.modal.hideAll(); + +// Check if any modal is showing +experiences.modal.isShowing(); // true/false + +// Check if specific modal is showing +experiences.modal.isShowing('welcome-modal'); // true/false + +// Form methods +experiences.modal.showFormState('newsletter', 'success'); +experiences.modal.resetForm('newsletter'); +const formData = experiences.modal.getFormData('newsletter'); +``` + +## CSS Variables + +Customize modal appearance: + +```css +:root { + /* Modal container */ + --xp-modal-z-index: 10001; + + /* Backdrop */ + --xp-modal-backdrop-bg: rgba(0, 0, 0, 0.5); + + /* Dialog */ + --xp-modal-dialog-bg: #ffffff; + --xp-modal-dialog-border-radius: 8px; + --xp-modal-dialog-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + --xp-modal-dialog-padding: 24px; + + /* Close button */ + --xp-modal-close-color: #666; + --xp-modal-close-hover-color: #111; + + /* Title */ + --xp-modal-title-font-size: 20px; + --xp-modal-title-font-weight: 600; + --xp-modal-title-color: #111; + + /* Message */ + --xp-modal-message-font-size: 14px; + --xp-modal-message-color: #444; + + /* Buttons */ + --xp-modal-button-primary-bg: #2563eb; + --xp-modal-button-primary-color: #ffffff; + --xp-modal-button-secondary-bg: #f3f4f6; + --xp-modal-button-secondary-color: #374151; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + :root { + --xp-modal-dialog-bg: #1f2937; + --xp-modal-title-color: #f9fafb; + --xp-modal-message-color: #d1d5db; + } +} +``` + +## Examples + +### Email Signup Modal + +```typescript +experiences.register('newsletter', { + type: 'modal', + content: { + title: 'Join Our Newsletter', + message: 'Get 10% off your first order.', + size: 'sm', + animation: 'slide-up', + + form: { + fields: [ + { + name: 'email', + type: 'email', + placeholder: 'you@example.com', + required: true + } + ], + submitButton: { + text: 'Get Discount', + variant: 'primary' + }, + successState: { + title: 'โœ“ Check Your Inbox', + message: 'Your discount code is on its way!' + } + } + }, + targeting: { + custom: (context) => context.triggers.exitIntent + } +}); +``` + +### Product Launch Modal + +```typescript +experiences.register('product-launch', { + type: 'modal', + content: { + title: 'Introducing Pro Plan', + message: 'Advanced features for power users.', + size: 'lg', + + image: { + src: '/product-hero.jpg', + alt: 'Pro Plan', + maxHeight: 300 + }, + + buttons: [ + { text: 'Learn More', variant: 'primary', url: '/pro' }, + { text: 'Remind Me Later', variant: 'link', dismiss: true } + ] + } +}); +``` + +### Cookie Consent Modal + +```typescript +experiences.register('cookie-consent', { + type: 'modal', + content: { + title: 'Cookie Settings', + message: 'We use cookies to improve your experience.', + size: 'md', + position: 'bottom', + dismissable: false, // Require user action + backdropDismiss: false, + + buttons: [ + { + text: 'Accept All', + variant: 'primary', + action: 'accept', + dismiss: true + }, + { + text: 'Customize', + variant: 'secondary', + url: '/cookies' + } + ] + } +}); +``` + +## Accessibility + +The modal plugin follows WAI-ARIA best practices: + +- **Focus management** - Traps focus within modal, restores on close +- **Keyboard support** - Escape key closes modal (if dismissable) +- **ARIA attributes** - `role="dialog"`, `aria-modal="true"`, `aria-labelledby` +- **Screen readers** - Proper labeling and announcements + +## Browser Support + +- Chrome, Firefox, Safari, Edge (last 2 versions) +- Mobile: iOS Safari, Chrome Android +- Requires ES2024+ support (or transpilation) + +## Next Steps + +- [Exit Intent Plugin](/reference/plugins/exit-intent) - Trigger modals on exit +- [Frequency Plugin](/reference/plugins/frequency) - Cap modal impressions +- [Events Reference](/reference/events) - Full event documentation + diff --git a/package.json b/package.json index 5eddd55..ed9a28a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "lint": "turbo lint", "test": "vitest run", "test:watch": "vitest", + "test:browser": "vitest --config vitest.browser.config.ts", + "test:browser:ui": "vitest --config vitest.browser.config.ts --ui", "clean": "turbo clean && rm -rf node_modules", "format": "turbo format", "typecheck": "turbo typecheck", @@ -40,9 +42,13 @@ "@commitlint/cli": "^20.2.0", "@commitlint/config-conventional": "^20.2.0", "@tsconfig/node-lts": "^24.0.0", + "@vitest/browser": "^4.0.16", + "@vitest/browser-playwright": "^4.0.16", "@vitest/coverage-v8": "^4.0.16", + "happy-dom": "^20.0.11", "husky": "^9.1.7", "jsdom": "^27.3.0", + "playwright": "^1.57.0", "tsup": "^8.5.1", "turbo": "^2.7.2", "typescript": "^5.9.3", diff --git a/packages/core/README.md b/packages/core/README.md index d9e1631..e19840f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -2,17 +2,20 @@ A lightweight, explainable client-side experience runtime built on [@lytics/sdk-kit](https://github.com/lytics/sdk-kit). -**Size:** 6.9 KB gzipped (includes core + 3 plugins) +**Size:** 13.4 KB gzipped core + 12.7 KB gzipped plugins = 26.1 KB total ## Features - **Explainability-First** - Every decision includes structured reasons - **Plugin-Based** - Built on sdk-kit's powerful plugin system -- **Multiple Buttons** - Primary, secondary, and link variants -- **Frequency Capping** - Control impression limits per session/day/week +- **Multiple Layouts** - Banners, modals, and inline experiences +- **Form Support** - Email capture with built-in validation +- **Display Conditions** - Exit intent, scroll depth, time delays, page visits +- **Frequency Capping** - Control impression limits per session/lifetime - **Responsive Layout** - Automatically adapts to mobile/desktop - **Script Tag Ready** - Works without build tools - **Type-Safe** - Full TypeScript support +- **CSS Variables** - Full theming support ## Installation @@ -41,18 +44,41 @@ const experiences = createInstance({ debug: true }); // Initialize await experiences.init(); -// Register a banner +// Register a modal with form +experiences.register('newsletter', { + type: 'modal', + targeting: { + custom: (context) => context.triggers.exitIntent + }, + content: { + title: 'Before You Go!', + message: 'Subscribe for 10% off your first order.', + size: 'sm', + form: { + fields: [ + { name: 'email', type: 'email', required: true, placeholder: 'you@example.com' } + ], + submitButton: { text: 'Get Discount', variant: 'primary' }, + successState: { + title: 'โœ“ Check Your Inbox', + message: 'Your discount code is on the way!' + } + } + } +}); + +// Or register a banner experiences.register('welcome', { type: 'banner', targeting: { url: { contains: '/' } }, content: { - title: 'Welcome!', - message: 'Thanks for visiting.', + message: 'Welcome! Get 20% off today.', buttons: [ - { text: 'Get Started', url: '/start', variant: 'primary' } - ] + { text: 'Shop Now', url: '/shop', variant: 'primary' } + ], + position: 'top' }, frequency: { max: 3, @@ -128,9 +154,20 @@ experiences.on('experiences:dismissed', ({ experienceId }) => { ## Included Plugins -This package includes three official plugins: +This package auto-registers the following official plugins: + +### Layout Plugins +- **Banner Plugin** - Top/bottom notification bars +- **Modal Plugin** - Overlay dialogs with forms +- **Inline Plugin** - In-content experiences + +### Display Conditions +- **Exit Intent** - Detect when users are leaving +- **Scroll Depth** - Track scroll engagement +- **Page Visits** - Session and lifetime counters +- **Time Delay** - Show after time elapsed -- **Banner Plugin** - DOM rendering with responsive layout +### Utility Plugins - **Frequency Plugin** - Impression tracking and capping - **Debug Plugin** - Logging and window events diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c562411..2747b05 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,19 +15,22 @@ export { evaluateExperience, evaluateUrlRule, } from './runtime'; - // Export singleton API +// Default export for IIFE builds (script tag) +// This is what gets exposed as window.experiences export { createInstance, destroy, evaluate, evaluateAll, + experiences, // Named exportexperiences as default, explain, getState, init, on, register, } from './singleton'; + // Export all types export type { BannerContent, diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts index 395a32f..852ff47 100644 --- a/packages/core/src/runtime.test.ts +++ b/packages/core/src/runtime.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ExperienceRuntime, evaluateUrlRule } from './runtime'; +import { ExperienceRuntime, evaluateExperience, evaluateUrlRule } from './runtime'; describe('ExperienceRuntime', () => { let runtime: ExperienceRuntime; @@ -447,6 +447,114 @@ describe('ExperienceRuntime', () => { }); }); + describe('display trigger evaluation', () => { + it('should match experience when scrollDepth threshold is reached', () => { + const experience = { + id: 'scroll-test', + type: 'inline' as const, + content: {}, + targeting: {}, + display: { + trigger: 'scrollDepth', + triggerData: { threshold: 50 }, + }, + }; + + const context = { + url: 'https://example.com', + timestamp: Date.now(), + triggers: { + scrollDepth: { + triggered: true, + threshold: 50, + percent: 50.5, + }, + }, + }; + + const result = evaluateExperience(experience, context); + expect(result.matched).toBe(true); + expect(result.reasons).toContain('Scroll depth threshold (50%) reached'); + }); + + it('should not match experience when scrollDepth threshold does not match', () => { + const experience = { + id: 'scroll-test', + type: 'inline' as const, + content: {}, + targeting: {}, + display: { + trigger: 'scrollDepth', + triggerData: { threshold: 50 }, + }, + }; + + const context = { + url: 'https://example.com', + timestamp: Date.now(), + triggers: { + scrollDepth: { + triggered: true, + threshold: 25, // Different threshold + percent: 25.5, + }, + }, + }; + + const result = evaluateExperience(experience, context); + expect(result.matched).toBe(false); + expect(result.reasons).toContain('Scroll depth threshold mismatch (expected 50%, got 25%)'); + }); + + it('should not match experience when trigger has not fired', () => { + const experience = { + id: 'scroll-test', + type: 'inline' as const, + content: {}, + targeting: {}, + display: { + trigger: 'exitIntent', + }, + }; + + const context = { + url: 'https://example.com', + timestamp: Date.now(), + triggers: {}, + }; + + const result = evaluateExperience(experience, context); + expect(result.matched).toBe(false); + expect(result.reasons).toContain('Waiting for exitIntent trigger'); + }); + + it('should match non-scrollDepth triggers when triggered', () => { + const experience = { + id: 'exit-test', + type: 'modal' as const, + content: {}, + targeting: {}, + display: { + trigger: 'exitIntent', + }, + }; + + const context = { + url: 'https://example.com', + timestamp: Date.now(), + triggers: { + exitIntent: { + triggered: true, + }, + }, + }; + + const result = evaluateExperience(experience, context); + expect(result.matched).toBe(true); + expect(result.reasons).toContain('exitIntent trigger fired'); + }); + }); + describe('frequency targeting', () => { it('should track frequency rule in trace', async () => { await runtime.init(); @@ -622,7 +730,7 @@ describe('ExperienceRuntime', () => { expect(decisions2.find((d) => d.experienceId === 'capped')?.show).toBe(false); }); - it('should emit experiences:evaluated event with array', () => { + it('should emit experiences:evaluated event for each matched experience', () => { const handler = vi.fn(); runtime.on('experiences:evaluated', handler); @@ -640,18 +748,20 @@ describe('ExperienceRuntime', () => { runtime.evaluateAll(); - expect(handler).toHaveBeenCalledOnce(); - expect(handler).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - decision: expect.objectContaining({ experienceId: 'banner1' }), - experience: expect.objectContaining({ id: 'banner1' }), - }), - expect.objectContaining({ - decision: expect.objectContaining({ experienceId: 'banner2' }), - experience: expect.objectContaining({ id: 'banner2' }), - }), - ]) + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + decision: expect.objectContaining({ experienceId: 'banner1' }), + experience: expect.objectContaining({ id: 'banner1' }), + }) + ); + expect(handler).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + decision: expect.objectContaining({ experienceId: 'banner2' }), + experience: expect.objectContaining({ id: 'banner2' }), + }) ); }); @@ -674,9 +784,9 @@ describe('ExperienceRuntime', () => { runtime.evaluateAll({ url: 'https://example.com/' }); expect(handler).toHaveBeenCalledOnce(); - const emittedDecisions = handler.mock.calls[0][0]; - expect(emittedDecisions).toHaveLength(1); - expect(emittedDecisions[0].decision.experienceId).toBe('match'); + const emittedData = handler.mock.calls[0][0]; + expect(emittedData.decision.experienceId).toBe('match'); + expect(emittedData.experience.id).toBe('match'); }); it('should return empty array when no experiences registered', () => { diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 998057d..06d0b51 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -5,6 +5,8 @@ import { debugPlugin, exitIntentPlugin, frequencyPlugin, + inlinePlugin, + modalPlugin, pageVisitsPlugin, scrollDepthPlugin, timeDelayPlugin, @@ -31,7 +33,7 @@ import type { * - Explainability-first (every decision has reasons) */ export class ExperienceRuntime { - private sdk: SDK; + public sdk: SDK; // Public for plugin API access via Proxy (readonly would prevent reinit) private experiences: Map = new Map(); private decisions: Decision[] = []; private initialized = false; @@ -53,6 +55,8 @@ export class ExperienceRuntime { this.sdk.use(scrollDepthPlugin); this.sdk.use(pageVisitsPlugin); this.sdk.use(timeDelayPlugin); + this.sdk.use(modalPlugin); + this.sdk.use(inlinePlugin); this.sdk.use(bannerPlugin); // Listen for trigger events from display condition plugins @@ -63,23 +67,44 @@ export class ExperienceRuntime { * Setup listeners for trigger:* events * This enables event-driven display conditions */ + /** + * Setup listeners for trigger:* events + * This enables event-driven display conditions + * + * Note: sdk-kit's emitter passes only the event payload to wildcard listeners, + * not the event name. Display condition plugins must include trigger metadata + * in their payload (e.g., { trigger: 'exitIntent', ...data }) + */ private setupTriggerListeners(): void { - // Listen for all trigger:* events using wildcard - // When a trigger fires, update context and re-evaluate - this.sdk.on('trigger:*', (eventName: string, data: any) => { - const triggerName = eventName.replace('trigger:', ''); - - // Update trigger context - this.triggerContext.triggers = this.triggerContext.triggers || {}; - this.triggerContext.triggers[triggerName] = { - triggered: true, - timestamp: Date.now(), - ...data, // Merge trigger-specific data - }; - - // Re-evaluate all experiences with updated context - this.evaluate(this.triggerContext); - }); + // Listen for specific trigger events + // We can't use 'trigger:*' wildcard because sdk-kit doesn't pass event name + // So we listen to each known trigger type individually + + const triggerTypes = [ + 'exitIntent', + 'scrollDepth', + 'timeDelay', + 'pageVisits', + 'modal', + 'inline', + ]; + + for (const triggerType of triggerTypes) { + this.sdk.on(`trigger:${triggerType}`, (data: any) => { + // Update trigger context + this.triggerContext.triggers = this.triggerContext.triggers || {}; + this.triggerContext.triggers[triggerType] = { + triggered: true, + timestamp: Date.now(), + ...data, // Merge trigger-specific data + }; + + // Re-evaluate ALL experiences with updated trigger context + // Using evaluateAll() to show multiple matching experiences + // buildContext() will automatically add the current URL + this.evaluateAll(this.triggerContext); + }); + } } /** @@ -313,20 +338,20 @@ export class ExperienceRuntime { this.decisions.push(decision); } - // Emit single event with all decisions (array) - // Plugins can filter to their relevant experiences + // Emit one event per matched experience + // This allows plugins to react individually to each experience const matchedDecisions = decisions.filter((d) => d.show); - const matchedExperiences = matchedDecisions - .map((d) => d.experienceId && this.experiences.get(d.experienceId)) - .filter((exp): exp is Experience => exp !== undefined); - - this.sdk.emit( - 'experiences:evaluated', - matchedDecisions.map((decision, index) => ({ - decision, - experience: matchedExperiences[index], - })) - ); + for (const decision of matchedDecisions) { + const experience = decision.experienceId + ? this.experiences.get(decision.experienceId) + : undefined; + if (experience) { + this.sdk.emit('experiences:evaluated', { + decision, + experience, + }); + } + } return decisions; } @@ -419,7 +444,7 @@ export function evaluateExperience( let matched = true; // Evaluate URL rule - if (experience.targeting.url) { + if (experience.targeting?.url) { const urlStart = Date.now(); const urlMatch = evaluateUrlRule(experience.targeting.url, context.url); @@ -440,6 +465,36 @@ export function evaluateExperience( } } + // Evaluate display trigger conditions + if (experience.display?.trigger && context.triggers) { + const triggerType = experience.display.trigger; + const triggerData = context.triggers[triggerType]; + + // Check if this trigger has fired + if (!triggerData?.triggered) { + reasons.push(`Waiting for ${triggerType} trigger`); + matched = false; + } else { + // For scrollDepth, check if threshold matches + if (triggerType === 'scrollDepth' && experience.display.triggerData?.threshold) { + const expectedThreshold = experience.display.triggerData.threshold; + const actualThreshold = triggerData.threshold; + + if (actualThreshold === expectedThreshold) { + reasons.push(`Scroll depth threshold (${expectedThreshold}%) reached`); + } else { + reasons.push( + `Scroll depth threshold mismatch (expected ${expectedThreshold}%, got ${actualThreshold}%)` + ); + matched = false; + } + } else { + // Other triggers just need to be triggered + reasons.push(`${triggerType} trigger fired`); + } + } + } + return { matched, reasons, trace }; } diff --git a/packages/core/src/singleton.ts b/packages/core/src/singleton.ts index f004802..8161373 100644 --- a/packages/core/src/singleton.ts +++ b/packages/core/src/singleton.ts @@ -177,8 +177,10 @@ export async function destroy(): Promise { * import experiences from '@prosdevlab/experience-sdk'; * await experiences.init(); * ``` + * + * For IIFE builds (script tag), this becomes window.experiences via default export */ -export const experiences = { +const experiencesObject = { createInstance, init, register, @@ -190,11 +192,15 @@ export const experiences = { destroy, }; -/** - * Global singleton instance for IIFE builds - * - * When loaded via script tag, this object is available as `window.experiences` - */ -if (typeof window !== 'undefined') { - (window as unknown as Record).experiences = experiences; -} +const experiencesProxy = new Proxy(experiencesObject, { + get(target, prop) { + // Check wrapper functions first + if (prop in target && target[prop as keyof typeof target]) { + return target[prop as keyof typeof target]; + } + // Fall back to SDK instance for plugin APIs (modal, inline, banner, etc.) + return (defaultInstance.sdk as any)[prop]; + }, +}); + +export { experiencesProxy as experiences }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 44fb292..9ce87a4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -15,7 +15,7 @@ export interface Experience { /** Unique identifier for the experience */ id: string; /** Type of experience to render */ - type: 'banner' | 'modal' | 'tooltip'; + type: 'banner' | 'modal' | 'tooltip' | 'inline'; /** Rules that determine when/where to show this experience */ targeting: TargetingRules; /** Content to display (type-specific) */ @@ -24,6 +24,8 @@ export interface Experience { frequency?: FrequencyConfig; /** Priority for ordering (higher = more important, default: 0) */ priority?: number; + /** Display conditions (triggers, timing) */ + display?: DisplayConditions; } /** @@ -78,6 +80,20 @@ export interface FrequencyConfig { per: 'session' | 'day' | 'week'; } +/** + * Display Conditions + * + * Conditions that determine when an experience should be displayed. + */ +export interface DisplayConditions { + /** Trigger type (e.g., scrollDepth, exitIntent, timeDelay) */ + trigger?: string; + /** Trigger-specific configuration data */ + triggerData?: any; + /** Frequency capping for this experience */ + frequency?: FrequencyConfig; +} + // Import plugin-specific content types from plugins package // (Core depends on plugins, so plugins owns these definitions) import type { @@ -176,6 +192,15 @@ export interface TriggerState { /** Number of visibility changes */ visibilityChanges?: number; }; + /** Modal trigger state (when modal is shown) */ + modal?: { + triggered: boolean; + timestamp?: number; + /** Experience ID of the shown modal */ + experienceId?: string; + /** Whether the modal is currently showing */ + shown?: boolean; + }; /** Extensible for future triggers */ [key: string]: any; } diff --git a/packages/plugins/README.md b/packages/plugins/README.md index eb0f88f..8b3e1c5 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -2,100 +2,90 @@ Official plugins for [Experience SDK](https://github.com/prosdevlab/experience-sdk). -**Size:** 13.4 KB (ESM) +**Size:** 12.7 KB gzipped (all plugins) ## Included Plugins -### Banner Plugin +### Layout Plugins -Renders banner experiences in the DOM with automatic positioning, theming, and responsive layout. +**Banner Plugin** - Top/bottom notification bars with push-down support **Features:** - Multiple buttons with variants (primary, secondary, link) - Responsive layout (desktop inline, mobile stack) -- 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 +- Top/bottom positioning with optional push-down +- HTML sanitization for XSS protection +- CSS variables for full theming -```typescript -import { createInstance, bannerPlugin } from '@prosdevlab/experience-sdk-plugins'; - -const sdk = createInstance(); -sdk.use(bannerPlugin); +**Modal Plugin** - Overlay dialogs with forms and rich content -sdk.banner.show({ - id: 'welcome', - type: 'banner', - content: { - title: 'Welcome!', - message: 'Thanks for visiting.', - buttons: [ - { text: 'Get Started', url: '/start', variant: 'primary' } - ], - position: 'top', - dismissable: true - } -}); -``` +**Features:** +- Multiple sizes (sm, md, lg, fullscreen, auto) +- Mobile fullscreen for large modals +- Hero images with max height +- Animations (fade, slide-up, none) +- Built-in form support with validation +- Focus trap and keyboard support (Escape) +- CSS variables for full theming -**Customization:** +**Inline Plugin** - In-content experiences using CSS selectors -The banner plugin uses `.xp-*` CSS classes and supports custom styling: +**Features:** +- 5 insertion methods (replace, append, prepend, before, after) +- Dismissal with persistence +- Multi-instance support +- HTML sanitization +- Custom styling (className, style props) +- CSS variables for theming -```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' - } -} -``` +### Display Condition Plugins -See the [Plugins documentation](https://prosdevlab.github.io/experience-sdk/reference/plugins#customization) for more customization examples. +**Exit Intent Plugin** - Detect when users are leaving -### Frequency Plugin +**Features:** +- Mouse movement tracking +- Configurable sensitivity +- Min time on page +- Delay before triggering +- Session persistence -Manages impression tracking and frequency capping with persistent storage. +**Scroll Depth Plugin** - Track scroll engagement **Features:** -- Session/day/week impression tracking -- Automatic storage management -- Manual impression recording -- Reset capabilities +- Multiple thresholds (25%, 50%, 75%, 100%) +- Max scroll tracking +- Velocity and direction tracking +- Time-to-threshold metrics +- Throttling and resize handling -```typescript -import { createInstance, frequencyPlugin } from '@prosdevlab/experience-sdk-plugins'; +**Page Visits Plugin** - Session and lifetime counters -const sdk = createInstance(); -sdk.use(frequencyPlugin); +**Features:** +- Session-scoped visits (sessionStorage) +- Lifetime visits (localStorage) +- First-visit detection +- DNT (Do Not Track) support +- Expiration and cross-tab safety -// Check impression count -const count = sdk.frequency.getImpressionCount('welcome', 'session'); +**Time Delay Plugin** - Show after time elapsed -// Record impression -sdk.frequency.recordImpression('welcome'); +**Features:** +- Configurable show delay +- Auto-hide after duration +- Pause when page hidden (Page Visibility API) +- Timer management -// Reset counts -sdk.frequency.reset('welcome'); -``` +### Utility Plugins -### Debug Plugin +**Frequency Plugin** - Impression tracking and capping -Provides console logging and window event emission for debugging and Chrome extension integration. +**Features:** +- Session/day/week impression tracking +- Automatic storage management +- Manual impression recording +- Reset capabilities + +**Debug Plugin** - Logging and window events **Features:** - Console logging with prefix @@ -103,18 +93,87 @@ Provides console logging and window event emission for debugging and Chrome exte - Automatic decision logging - Configurable logging levels +## Quick Examples + +### Banner + ```typescript -import { createInstance, debugPlugin } from '@prosdevlab/experience-sdk-plugins'; +import { createInstance, bannerPlugin } from '@prosdevlab/experience-sdk-plugins'; -const sdk = createInstance({ debug: true }); -sdk.use(debugPlugin); +const sdk = createInstance(); +sdk.use(bannerPlugin); -// Manual logging -sdk.debug.log('Custom message', { foo: 'bar' }); +sdk.banner.show({ + id: 'welcome', + type: 'banner', + content: { + message: 'Welcome! Get 20% off today.', + buttons: [ + { text: 'Shop Now', url: '/shop', variant: 'primary' } + ], + position: 'top' + } +}); +``` -// Listen to debug events -window.addEventListener('experiences:debug', (event) => { - console.log(event.detail); +### Modal with Form + +```typescript +sdk.modal.show({ + id: 'newsletter', + type: 'modal', + content: { + title: 'Stay Updated', + message: 'Subscribe for exclusive offers.', + size: 'sm', + form: { + fields: [ + { name: 'email', type: 'email', required: true } + ], + submitButton: { text: 'Subscribe', variant: 'primary' }, + successState: { + title: 'โœ“ Subscribed!', + message: 'Check your inbox.' + } + } + } +}); +``` + +### Inline Experience + +```typescript +sdk.inline.show({ + id: 'promo', + type: 'inline', + content: { + selector: '#sidebar', + position: 'prepend', + message: '

Special Offer! Get 20% off with SAVE20.

', + buttons: [ + { text: 'Shop Now', url: '/shop', variant: 'primary' } + ], + dismissable: true, + persist: true + } +}); +``` + +### Exit Intent + +```typescript +// Trigger modal on exit intent +sdk.on('trigger:exitIntent', () => { + sdk.modal.show({ + id: 'exit-offer', + content: { + title: 'Wait! Before You Go...', + message: 'Get 15% off your first order.', + buttons: [ + { text: 'Claim Discount', variant: 'primary', url: '/checkout?code=EXIT15' } + ] + } + }); }); ``` @@ -145,8 +204,11 @@ experiences.register('banner', { ... }); ## Documentation - [Full Documentation](https://prosdevlab.github.io/experience-sdk) -- [Plugins Guide](https://prosdevlab.github.io/experience-sdk/reference/plugins) -- [Banner Examples](https://prosdevlab.github.io/experience-sdk/demo/banner) +- [Banner Plugin](https://prosdevlab.github.io/experience-sdk/reference/plugins/banner) +- [Modal Plugin](https://prosdevlab.github.io/experience-sdk/reference/plugins/modal) +- [Inline Plugin](https://prosdevlab.github.io/experience-sdk/reference/plugins/inline) +- [Display Conditions](https://prosdevlab.github.io/experience-sdk/reference/plugins/exit-intent) +- [All Plugins](https://prosdevlab.github.io/experience-sdk/reference/plugins) ## License diff --git a/packages/plugins/src/banner/banner.ts b/packages/plugins/src/banner/banner.ts index 0786870..4b10821 100644 --- a/packages/plugins/src/banner/banner.ts +++ b/packages/plugins/src/banner/banner.ts @@ -86,15 +86,15 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => { left: 0; right: 0; width: 100%; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 14px; - line-height: 1.5; + font-family: var(--xp-banner-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); + font-size: var(--xp-banner-font-size, 14px); + line-height: var(--xp-banner-line-height, 1.5); box-sizing: border-box; - z-index: 10000; - background: #ffffff; - color: #111827; - border-bottom: 1px solid #e5e7eb; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05); + z-index: var(--xp-banner-z-index, 10000); + background: var(--xp-banner-bg, #ffffff); + color: var(--xp-banner-color, #111827); + border-bottom: var(--xp-banner-border-width, 1px) solid var(--xp-banner-border-color, #e5e7eb); + box-shadow: var(--xp-banner-shadow, 0 1px 3px 0 rgba(0, 0, 0, 0.05)); } .xp-banner--top { @@ -104,17 +104,17 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => { .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); + border-top: var(--xp-banner-border-width, 1px) solid var(--xp-banner-border-color, #e5e7eb); + box-shadow: var(--xp-banner-shadow-bottom, 0 -1px 3px 0 rgba(0, 0, 0, 0.05)); } .xp-banner__container { display: flex; align-items: center; - gap: 16px; - max-width: 1280px; + gap: var(--xp-banner-gap, 16px); + max-width: var(--xp-banner-max-width, 1280px); margin: 0 auto; - padding: 14px 24px; + padding: var(--xp-banner-padding, 14px 24px); } .xp-banner__content { @@ -122,36 +122,37 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => { min-width: 0; display: flex; flex-direction: column; - gap: 4px; + gap: var(--xp-banner-content-gap, 4px); } .xp-banner__title { - font-weight: 600; + font-weight: var(--xp-banner-title-weight, 600); margin: 0; - font-size: 15px; - line-height: 1.4; + font-size: var(--xp-banner-title-size, 15px); + line-height: var(--xp-banner-title-line-height, 1.4); + color: var(--xp-banner-title-color, inherit); } .xp-banner__message { margin: 0; - font-size: 14px; - line-height: 1.5; - color: #6b7280; + font-size: var(--xp-banner-message-size, 14px); + line-height: var(--xp-banner-message-line-height, 1.5); + color: var(--xp-banner-message-color, #6b7280); } .xp-banner__buttons { display: flex; align-items: center; - gap: 8px; + gap: var(--xp-banner-buttons-gap, 8px); flex-shrink: 0; } .xp-banner__button { - padding: 8px 16px; + padding: var(--xp-banner-button-padding, 8px 16px); border: none; - border-radius: 6px; - font-size: 14px; - font-weight: 500; + border-radius: var(--xp-banner-button-radius, 6px); + font-size: var(--xp-banner-button-font-size, 14px); + font-weight: var(--xp-banner-button-font-weight, 500); cursor: pointer; transition: all 0.2s; text-decoration: none; @@ -162,64 +163,64 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => { } .xp-banner__button--primary { - background: #2563eb; - color: #ffffff; + background: var(--xp-banner-button-primary-bg, #2563eb); + color: var(--xp-banner-button-primary-color, #ffffff); } .xp-banner__button--primary:hover { - background: #1d4ed8; + background: var(--xp-banner-button-primary-bg-hover, #1d4ed8); } .xp-banner__button--secondary { - background: #f3f4f6; - color: #374151; - border: 1px solid #e5e7eb; + background: var(--xp-banner-button-secondary-bg, #f3f4f6); + color: var(--xp-banner-button-secondary-color, #374151); + border: var(--xp-banner-border-width, 1px) solid var(--xp-banner-button-secondary-border, #e5e7eb); } .xp-banner__button--secondary:hover { - background: #e5e7eb; + background: var(--xp-banner-button-secondary-bg-hover, #e5e7eb); } .xp-banner__button--link { background: transparent; - color: #2563eb; - padding: 6px 12px; - font-weight: 400; + color: var(--xp-banner-button-link-color, #2563eb); + padding: var(--xp-banner-button-link-padding, 6px 12px); + font-weight: var(--xp-banner-button-link-font-weight, 400); } .xp-banner__button--link:hover { - background: #f3f4f6; + background: var(--xp-banner-button-link-bg-hover, #f3f4f6); text-decoration: underline; } .xp-banner__close { background: transparent; border: none; - color: #9ca3af; - font-size: 20px; + color: var(--xp-banner-close-color, #9ca3af); + font-size: var(--xp-banner-close-size, 20px); line-height: 1; cursor: pointer; - padding: 4px; + padding: var(--xp-banner-close-padding, 4px); margin: 0; transition: color 0.2s; flex-shrink: 0; - width: 28px; - height: 28px; + width: var(--xp-banner-close-width, 28px); + height: var(--xp-banner-close-height, 28px); display: flex; align-items: center; justify-content: center; - border-radius: 4px; + border-radius: var(--xp-banner-close-radius, 4px); } .xp-banner__close:hover { - color: #111827; - background: #f3f4f6; + color: var(--xp-banner-close-color-hover, #111827); + background: var(--xp-banner-close-bg-hover, #f3f4f6); } @media (max-width: 640px) { .xp-banner__container { flex-wrap: wrap; - padding: 14px 16px; + padding: var(--xp-banner-padding-mobile, 14px 16px); position: relative; } @@ -244,55 +245,55 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => { } } - /* Dark mode support */ + /* Dark mode support - override CSS variables */ @media (prefers-color-scheme: dark) { .xp-banner { - background: #111827; - color: #f9fafb; - border-bottom-color: #1f2937; + background: var(--xp-banner-bg-dark, #111827); + color: var(--xp-banner-color-dark, #f9fafb); + border-bottom-color: var(--xp-banner-border-color-dark, #1f2937); } .xp-banner--bottom { - border-top-color: #1f2937; + border-top-color: var(--xp-banner-border-color-dark, #1f2937); } .xp-banner__message { - color: #9ca3af; + color: var(--xp-banner-message-color-dark, #9ca3af); } .xp-banner__button--primary { - background: #3b82f6; + background: var(--xp-banner-button-primary-bg-dark, #3b82f6); } .xp-banner__button--primary:hover { - background: #2563eb; + background: var(--xp-banner-button-primary-bg-hover-dark, #2563eb); } .xp-banner__button--secondary { - background: #1f2937; - color: #f9fafb; - border-color: #374151; + background: var(--xp-banner-button-secondary-bg-dark, #1f2937); + color: var(--xp-banner-button-secondary-color-dark, #f9fafb); + border-color: var(--xp-banner-button-secondary-border-dark, #374151); } .xp-banner__button--secondary:hover { - background: #374151; + background: var(--xp-banner-button-secondary-bg-hover-dark, #374151); } .xp-banner__button--link { - color: #60a5fa; + color: var(--xp-banner-button-link-color-dark, #60a5fa); } .xp-banner__button--link:hover { - background: #1f2937; + background: var(--xp-banner-button-link-bg-hover-dark, #1f2937); } .xp-banner__close { - color: #6b7280; + color: var(--xp-banner-close-color-dark, #6b7280); } .xp-banner__close:hover { - color: #f9fafb; - background: #1f2937; + color: var(--xp-banner-close-color-hover-dark, #f9fafb); + background: var(--xp-banner-close-bg-hover-dark, #1f2937); } } `; diff --git a/packages/plugins/src/exit-intent/exit-intent.ts b/packages/plugins/src/exit-intent/exit-intent.ts index 3b38b45..ddfc32d 100644 --- a/packages/plugins/src/exit-intent/exit-intent.ts +++ b/packages/plugins/src/exit-intent/exit-intent.ts @@ -365,8 +365,7 @@ export const exitIntentPlugin: PluginFunction = (plugin, instance, config) => { initialize(); // Cleanup on instance destroy - const destroyHandler = () => { + instance.on('sdk:destroy', () => { cleanup(); - }; - instance.on('destroy', destroyHandler); + }); }; diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index bbfe79c..57a281b 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -10,6 +10,8 @@ export * from './banner'; export * from './debug'; export * from './exit-intent'; export * from './frequency'; +export * from './inline'; +export * from './modal'; export * from './page-visits'; export * from './scroll-depth'; export * from './time-delay'; diff --git a/packages/plugins/src/inline/index.ts b/packages/plugins/src/inline/index.ts new file mode 100644 index 0000000..f465f02 --- /dev/null +++ b/packages/plugins/src/inline/index.ts @@ -0,0 +1,3 @@ +export { inlinePlugin } from './inline'; +export { insertContent, removeContent } from './insertion'; +export type { InlineContent, InlinePlugin, InlinePluginConfig, InsertionPosition } from './types'; diff --git a/packages/plugins/src/inline/inline.test.ts b/packages/plugins/src/inline/inline.test.ts new file mode 100644 index 0000000..28eeb10 --- /dev/null +++ b/packages/plugins/src/inline/inline.test.ts @@ -0,0 +1,620 @@ +/** + * @vitest-environment happy-dom + */ +import { SDK } from '@lytics/sdk-kit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { inlinePlugin } from './inline'; + +// Helper to initialize SDK with inline plugin +function initPlugin(config = {}) { + const sdk = new SDK({ + name: 'test-sdk', + ...config, + }); + + sdk.use(inlinePlugin); + + // Ensure body exists + if (!document.body) { + document.body = document.createElement('body'); + } + + return sdk; +} + +describe('Inline Plugin', () => { + let sdk: SDK & { inline?: any }; + + beforeEach(async () => { + vi.useFakeTimers(); + sdk = initPlugin(); + await sdk.init(); + }); + + afterEach(async () => { + // Clean up any inline content + document.querySelectorAll('.xp-inline').forEach((el) => { + el.remove(); + }); + + // Clean up any target elements + document.body.innerHTML = ''; + + if (sdk) { + await sdk.destroy(); + } + + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('should register the inline plugin', () => { + expect(sdk.inline).toBeDefined(); + expect(sdk.inline.show).toBeInstanceOf(Function); + expect(sdk.inline.remove).toBeInstanceOf(Function); + expect(sdk.inline.isShowing).toBeInstanceOf(Function); + }); + + it('should set default configuration', () => { + // Check that defaults were set by verifying plugin registered + expect(sdk.inline).toBeDefined(); + }); + + describe('Insertion Methods', () => { + it('should insert content with "replace" position', () => { + // Create target element + const target = document.createElement('div'); + target.id = 'test-target'; + target.innerHTML = '

Original content

'; + document.body.appendChild(target); + + const experience = { + id: 'replace-test', + type: 'inline', + content: { + selector: '#test-target', + position: 'replace', + message: '

New content

', + }, + }; + + sdk.inline.show(experience); + + const inline = document.querySelector('.xp-inline'); + expect(inline).toBeTruthy(); + expect(inline?.innerHTML).toBe('

New content

'); + expect(target.querySelector('p')?.textContent).toBe('New content'); // Content replaced + }); + + it('should insert content with "append" position', () => { + const target = document.createElement('div'); + target.id = 'test-target'; + target.innerHTML = '

Original

'; + document.body.appendChild(target); + + const experience = { + id: 'append-test', + type: 'inline', + content: { + selector: '#test-target', + position: 'append', + message: 'Appended', + }, + }; + + sdk.inline.show(experience); + + const inline = document.querySelector('.xp-inline'); + expect(inline).toBeTruthy(); + expect(target.children.length).toBe(2); // Original + appended + expect(target.children[0].tagName).toBe('P'); // Original first + expect(target.children[1].className).toBe('xp-inline'); // Appended second + }); + + it('should insert content with "prepend" position', () => { + const target = document.createElement('div'); + target.id = 'test-target'; + target.innerHTML = '

Original

'; + document.body.appendChild(target); + + const experience = { + id: 'prepend-test', + type: 'inline', + content: { + selector: '#test-target', + position: 'prepend', + message: 'Prepended', + }, + }; + + sdk.inline.show(experience); + + const inline = document.querySelector('.xp-inline'); + expect(inline).toBeTruthy(); + expect(target.children.length).toBe(2); // Prepended + original + expect(target.children[0].className).toBe('xp-inline'); // Prepended first + expect(target.children[1].tagName).toBe('P'); // Original second + }); + + it('should insert content with "before" position', () => { + const container = document.createElement('div'); + const target = document.createElement('p'); + target.id = 'test-target'; + target.textContent = 'Target'; + container.appendChild(target); + document.body.appendChild(container); + + const experience = { + id: 'before-test', + type: 'inline', + content: { + selector: '#test-target', + position: 'before', + message: 'Before', + }, + }; + + sdk.inline.show(experience); + + const inline = document.querySelector('.xp-inline'); + expect(inline).toBeTruthy(); + expect(container.children.length).toBe(2); // Inline + target + expect(container.children[0].className).toBe('xp-inline'); // Inline first + expect(container.children[1].id).toBe('test-target'); // Target second + }); + + it('should insert content with "after" position', () => { + const container = document.createElement('div'); + const target = document.createElement('p'); + target.id = 'test-target'; + target.textContent = 'Target'; + container.appendChild(target); + document.body.appendChild(container); + + const experience = { + id: 'after-test', + type: 'inline', + content: { + selector: '#test-target', + position: 'after', + message: 'After', + }, + }; + + sdk.inline.show(experience); + + const inline = document.querySelector('.xp-inline'); + expect(inline).toBeTruthy(); + expect(container.children.length).toBe(2); // Target + inline + expect(container.children[0].id).toBe('test-target'); // Target first + expect(container.children[1].className).toBe('xp-inline'); // Inline second + }); + + it('should default to "replace" when position not specified', () => { + const target = document.createElement('div'); + target.id = 'test-target'; + target.innerHTML = '

Original

'; + document.body.appendChild(target); + + const experience = { + id: 'default-position', + type: 'inline', + content: { + selector: '#test-target', + message: '

Replaced

', + }, + }; + + sdk.inline.show(experience); + + const inline = document.querySelector('.xp-inline'); + expect(inline).toBeTruthy(); + expect(target.querySelector('p')).toBeFalsy(); // Original replaced + }); + }); + + describe('Error Handling', () => { + it('should emit error event when selector not found', async () => { + const errorHandler = vi.fn(); + sdk.on('experiences:inline:error', errorHandler); + + const experience = { + id: 'not-found', + type: 'inline', + content: { + selector: '#does-not-exist', + message: '

Content

', + }, + }; + + sdk.inline.show(experience); + + await vi.waitFor(() => { + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + experienceId: 'not-found', + error: 'selector-not-found', + selector: '#does-not-exist', + }) + ); + }); + }); + + it('should not throw error when removing non-existent inline', () => { + expect(() => { + sdk.inline.remove('does-not-exist'); + }).not.toThrow(); + }); + }); + + describe('Dismissal', () => { + it('should render close button when dismissable is true', () => { + const target = document.createElement('div'); + target.id = 'test-target'; + document.body.appendChild(target); + + const experience = { + id: 'dismissable-test', + type: 'inline', + content: { + selector: '#test-target', + message: '

Content

', + dismissable: true, + }, + }; + + sdk.inline.show(experience); + + const closeBtn = document.querySelector('.xp-inline__close'); + expect(closeBtn).toBeTruthy(); + expect(closeBtn?.getAttribute('aria-label')).toBe('Close'); + }); + + it('should not render close button when dismissable is false', () => { + const target = document.createElement('div'); + target.id = 'test-target'; + document.body.appendChild(target); + + const experience = { + id: 'not-dismissable', + type: 'inline', + content: { + selector: '#test-target', + message: '

Content

', + dismissable: false, + }, + }; + + sdk.inline.show(experience); + + const closeBtn = document.querySelector('.xp-inline__close'); + expect(closeBtn).toBeFalsy(); + }); + + it('should remove inline when close button is clicked', async () => { + const target = document.createElement('div'); + target.id = 'test-target'; + document.body.appendChild(target); + + const dismissHandler = vi.fn(); + sdk.on('experiences:dismissed', dismissHandler); + + const experience = { + id: 'dismiss-test', + type: 'inline', + content: { + selector: '#test-target', + message: '

Content

', + dismissable: true, + }, + }; + + sdk.inline.show(experience); + + const closeBtn = document.querySelector('.xp-inline__close') as HTMLElement; + closeBtn.click(); + + await vi.waitFor(() => { + expect(dismissHandler).toHaveBeenCalledWith( + expect.objectContaining({ + experienceId: 'dismiss-test', + }) + ); + }); + + expect(document.querySelector('.xp-inline')).toBeFalsy(); + }); + + it('should persist dismissal in localStorage when persist is true', async () => { + const target = document.createElement('div'); + target.id = 'test-target'; + document.body.appendChild(target); + + const experience = { + id: 'persist-test', + type: 'inline', + content: { + selector: '#test-target', + message: '

Content

', + dismissable: true, + persist: true, + }, + }; + + sdk.inline.show(experience); + + const closeBtn = document.querySelector('.xp-inline__close') as HTMLElement; + closeBtn.click(); + + // Try to show again + const dismissedHandler = vi.fn(); + sdk.on('experiences:inline:dismissed', dismissedHandler); + + sdk.inline.show(experience); + + await vi.waitFor(() => { + expect(dismissedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + experienceId: 'persist-test', + reason: 'previously-dismissed', + }) + ); + }); + + expect(document.querySelector('.xp-inline')).toBeFalsy(); + }); + }); + + describe('Custom Styling', () => { + it('should apply custom className', () => { + const target = document.createElement('div'); + target.id = 'test-target'; + document.body.appendChild(target); + + const experience = { + id: 'class-test', + type: 'inline', + content: { + selector: '#test-target', + message: '

Content

', + className: 'custom-class', + }, + }; + + sdk.inline.show(experience); + + const inline = document.querySelector('.xp-inline'); + expect(inline?.classList.contains('custom-class')).toBe(true); + }); + + it('should apply custom inline styles', () => { + const target = document.createElement('div'); + target.id = 'test-target'; + document.body.appendChild(target); + + const experience = { + id: 'style-test', + type: 'inline', + content: { + selector: '#test-target', + message: '

Content

', + style: { + padding: '20px', + backgroundColor: 'red', + }, + }, + }; + + sdk.inline.show(experience); + + const inline = document.querySelector('.xp-inline') as HTMLElement; + expect(inline?.style.padding).toBe('20px'); + expect(inline?.style.backgroundColor).toBe('red'); + }); + }); + + describe('Multi-instance', () => { + it('should support multiple inline experiences', () => { + const target1 = document.createElement('div'); + target1.id = 'target-1'; + document.body.appendChild(target1); + + const target2 = document.createElement('div'); + target2.id = 'target-2'; + document.body.appendChild(target2); + + sdk.inline.show({ + id: 'inline-1', + type: 'inline', + content: { + selector: '#target-1', + message: '

Content 1

', + }, + }); + + sdk.inline.show({ + id: 'inline-2', + type: 'inline', + content: { + selector: '#target-2', + message: '

Content 2

', + }, + }); + + const inlines = document.querySelectorAll('.xp-inline'); + expect(inlines.length).toBe(2); + }); + + it('should prevent duplicate inline experiences', () => { + const target = document.createElement('div'); + target.id = 'test-target'; + document.body.appendChild(target); + + const experience = { + id: 'duplicate-test', + type: 'inline' as const, + content: { + selector: '#test-target', + message: '

Content

', + }, + }; + + // Show the same experience twice + sdk.inline.show(experience); + sdk.inline.show(experience); + + // Should only insert once + const inlines = document.querySelectorAll('[data-xp-id="duplicate-test"]'); + expect(inlines.length).toBe(1); + expect(sdk.inline.isShowing('duplicate-test')).toBe(true); + }); + + it('should check if specific inline is showing', () => { + const target = document.createElement('div'); + target.id = 'test-target'; + document.body.appendChild(target); + + sdk.inline.show({ + id: 'check-test', + type: 'inline', + content: { + selector: '#test-target', + message: '

Content

', + }, + }); + + expect(sdk.inline.isShowing('check-test')).toBe(true); + expect(sdk.inline.isShowing('does-not-exist')).toBe(false); + }); + + it('should check if any inline is showing', () => { + expect(sdk.inline.isShowing()).toBe(false); + + const target = document.createElement('div'); + target.id = 'test-target'; + document.body.appendChild(target); + + sdk.inline.show({ + id: 'any-test', + type: 'inline', + content: { + selector: '#test-target', + message: '

Content

', + }, + }); + + expect(sdk.inline.isShowing()).toBe(true); + }); + }); + + describe('Events', () => { + it('should emit shown event when inline is displayed', async () => { + const target = document.createElement('div'); + target.id = 'test-target'; + document.body.appendChild(target); + + const shownHandler = vi.fn(); + sdk.on('experiences:shown', shownHandler); + + sdk.inline.show({ + id: 'shown-test', + type: 'inline', + content: { + selector: '#test-target', + message: '

Content

', + }, + }); + + await vi.waitFor(() => { + expect(shownHandler).toHaveBeenCalledWith( + expect.objectContaining({ + experienceId: 'shown-test', + type: 'inline', + selector: '#test-target', + position: 'replace', + }) + ); + }); + }); + }); + + describe('Cleanup', () => { + it('should remove inline on explicit remove call', () => { + const target = document.createElement('div'); + target.id = 'test-target'; + document.body.appendChild(target); + + sdk.inline.show({ + id: 'remove-test', + type: 'inline', + content: { + selector: '#test-target', + message: '

Content

', + }, + }); + + expect(document.querySelector('.xp-inline')).toBeTruthy(); + + sdk.inline.remove('remove-test'); + + expect(document.querySelector('.xp-inline')).toBeFalsy(); + }); + + it('should remove all inlines on destroy', async () => { + const target1 = document.createElement('div'); + target1.id = 'target-1'; + document.body.appendChild(target1); + + const target2 = document.createElement('div'); + target2.id = 'target-2'; + document.body.appendChild(target2); + + sdk.inline.show({ + id: 'destroy-1', + type: 'inline', + content: { + selector: '#target-1', + message: '

Content 1

', + }, + }); + + sdk.inline.show({ + id: 'destroy-2', + type: 'inline', + content: { + selector: '#target-2', + message: '

Content 2

', + }, + }); + + expect(document.querySelectorAll('.xp-inline').length).toBe(2); + + await sdk.destroy(); + + expect(document.querySelectorAll('.xp-inline').length).toBe(0); + }); + }); + + describe('HTML Sanitization', () => { + it('should sanitize HTML content', () => { + const target = document.createElement('div'); + target.id = 'test-target'; + document.body.appendChild(target); + + const experience = { + id: 'sanitize-test', + type: 'inline', + content: { + selector: '#test-target', + message: '

Safe content

', + }, + }; + + sdk.inline.show(experience); + + const inline = document.querySelector('.xp-inline'); + expect(inline?.innerHTML).not.toContain('', + }, + }; + + sdk.modal.show(experience); + + const message = document.querySelector('.xp-modal__message'); + expect(message?.innerHTML).toContain('Bold text'); + expect(message?.innerHTML).not.toContain('