cuoral-ionic 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/modal.js CHANGED
@@ -3,10 +3,11 @@
3
3
  * Handles full-screen modal display with floating button
4
4
  */
5
5
  export class CuoralModal {
6
- constructor(widgetUrl, showFloatingButton = true) {
6
+ constructor(widgetUrl, showFloatingButton = true, primaryColor = '#007AFF') {
7
7
  this.isOpen = false;
8
8
  this.widgetUrl = widgetUrl;
9
9
  this.showFloatingButton = showFloatingButton;
10
+ this.primaryColor = primaryColor;
10
11
  }
11
12
  /**
12
13
  * Update the widget URL (e.g., to include new session_id)
@@ -74,12 +75,12 @@ export class CuoralModal {
74
75
  width: '56px',
75
76
  height: '56px',
76
77
  borderRadius: '50%',
77
- backgroundColor: '#007AFF',
78
+ backgroundColor: this.primaryColor,
78
79
  display: 'flex',
79
80
  alignItems: 'center',
80
81
  justifyContent: 'center',
81
82
  cursor: 'pointer',
82
- boxShadow: '0 4px 12px rgba(0, 122, 255, 0.4)',
83
+ boxShadow: `0 4px 12px ${this.primaryColor}66`, // 40% opacity
83
84
  zIndex: '999999',
84
85
  transition: 'transform 0.2s, box-shadow 0.2s'
85
86
  });
@@ -87,13 +88,13 @@ export class CuoralModal {
87
88
  this.floatingButton.addEventListener('mouseenter', () => {
88
89
  if (this.floatingButton) {
89
90
  this.floatingButton.style.transform = 'scale(1.1)';
90
- this.floatingButton.style.boxShadow = '0 6px 16px rgba(0, 122, 255, 0.5)';
91
+ this.floatingButton.style.boxShadow = `0 6px 16px ${this.primaryColor}80`; // 50% opacity
91
92
  }
92
93
  });
93
94
  this.floatingButton.addEventListener('mouseleave', () => {
94
95
  if (this.floatingButton) {
95
96
  this.floatingButton.style.transform = 'scale(1)';
96
- this.floatingButton.style.boxShadow = '0 4px 12px rgba(0, 122, 255, 0.4)';
97
+ this.floatingButton.style.boxShadow = `0 4px 12px ${this.primaryColor}66`; // 40% opacity
97
98
  }
98
99
  });
99
100
  // Click to open modal
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cuoral-ionic",
3
- "version": "0.0.8",
4
- "description": "Cuoral Ionic Framework Library - Proactive customer success platform with support ticketing, customer intelligence, screen recording, and engagement tools",
3
+ "version": "0.1.0",
4
+ "description": "Cuoral Ionic Framework Library - silent churn prevention platform for Ionic apps. Provides screen recording, user interaction capture, and support widget integration for enhanced customer support experiences.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.esm.js",
@@ -12,6 +12,7 @@
12
12
  "assets",
13
13
  "ios",
14
14
  "android",
15
+ "CuoralIonic.podspec",
15
16
  "README.md"
16
17
  ],
17
18
  "scripts": {
package/src/cuoral.ts CHANGED
@@ -14,10 +14,14 @@ export interface CuoralOptions {
14
14
  widgetBaseUrl?: string; // Allow custom widget URL
15
15
  showFloatingButton?: boolean; // Show floating chat button (default: true)
16
16
  useModal?: boolean; // Use modal display mode (default: true)
17
+ primaryColor?: string; // Custom primary color (fetched from backend if not provided)
17
18
  }
18
19
 
19
20
  interface SessionConfiguration {
20
21
  customer_intelligence?: boolean;
22
+ color?: string; // Organization's primary brand color
23
+ config_name?: string;
24
+ support_name?: string;
21
25
  [key: string]: any;
22
26
  }
23
27
 
@@ -77,10 +81,7 @@ export class Cuoral {
77
81
 
78
82
  const widgetUrl = `${baseUrl}?${params.toString()}`;
79
83
 
80
- // Initialize modal if enabled
81
- if (this.options.useModal) {
82
- this.modal = new CuoralModal(widgetUrl, this.options.showFloatingButton);
83
- }
84
+ // Note: Modal will be created in initialize() after fetching primary color from backend
84
85
 
85
86
  // Initialize bridge and recorder
86
87
  this.bridge = new CuoralBridge({
@@ -106,6 +107,7 @@ export class Cuoral {
106
107
  */
107
108
  public async initialize(): Promise<void> {
108
109
  // Fetch session configuration and initialize intelligence if enabled by backend
110
+ // This also fetches the organization's primary color
109
111
  await this.initializeIntelligence();
110
112
 
111
113
  console.log('[Cuoral] Initialize - Session ID:', localStorage.getItem('__x_loadID'));
@@ -118,7 +120,7 @@ export class Cuoral {
118
120
  // Recreate modal if it was destroyed (e.g., after clearSession)
119
121
  if (this.options.useModal && !this.modal) {
120
122
  const widgetUrl = this.getWidgetUrl();
121
- this.modal = new CuoralModal(widgetUrl, this.options.showFloatingButton);
123
+ this.modal = new CuoralModal(widgetUrl, this.options.showFloatingButton, this.options.primaryColor);
122
124
  }
123
125
 
124
126
  // Update modal URL with session ID
@@ -148,6 +150,15 @@ export class Cuoral {
148
150
  // Fetch session configuration
149
151
  const config = await this.fetchSessionConfiguration(sessionId);
150
152
 
153
+ // Extract organization's primary color from config if available
154
+ if (!this.options.primaryColor && config?.color) {
155
+ this.options.primaryColor = config.color;
156
+ console.log('[Cuoral] Using organization primary color:', this.options.primaryColor);
157
+ } else if (!this.options.primaryColor) {
158
+ this.options.primaryColor = '#007AFF';
159
+ console.log('[Cuoral] Using default primary color');
160
+ }
161
+
151
162
  // If session was invalid/expired, try to initiate a new one
152
163
  if (!config && !localStorage.getItem('__x_loadID')) {
153
164
  sessionId = await this.initiateSession();
@@ -107,11 +107,13 @@ export class CuoralIntelligence {
107
107
  // Session replay state
108
108
  private rrwebStopFn: any = null;
109
109
  private rrwebEvents: any[] = [];
110
+ private rrwebEmit: ((event: any) => void) | null = null; // Store emit function for custom events
110
111
  private customEvents: CustomEventData[] = [];
111
112
  private sessionReplayTimer: any = null;
112
113
  private clickTimestamps: Map<string, number[]> = new Map();
113
114
  private rageClickThreshold = 5; // 5 rapid clicks
114
115
  private rageClickWindowMs = 2000; // Within 2 seconds
116
+ private shadowDOMNodeMap: Map<Element, number> = new Map(); // Map elements to unique IDs
115
117
 
116
118
  constructor(sessionId: string) {
117
119
  this.sessionId = sessionId;
@@ -677,6 +679,7 @@ export class CuoralIntelligence {
677
679
 
678
680
  /**
679
681
  * Setup session replay with rrweb
682
+ * Captures everything for maximum replay fidelity
680
683
  */
681
684
  private setupSessionReplay(): void {
682
685
  if (!this.sessionId) {
@@ -684,22 +687,67 @@ export class CuoralIntelligence {
684
687
  return;
685
688
  }
686
689
 
687
- // Start rrweb recording
690
+ // Start rrweb recording with aggressive capture settings
688
691
  this.rrwebStopFn = rrweb.record({
689
692
  emit: (event) => {
690
693
  this.rrwebEvents.push(event);
694
+ this.rrwebEmit = this.rrwebEmit || ((e: any) => this.rrwebEvents.push(e)); // Store emit reference
691
695
  },
692
- checkoutEveryNms: 60000, // Full snapshot every minute
696
+
697
+ // Take full snapshots more frequently to catch dynamic content
698
+ checkoutEveryNms: 30000, // Every 30 seconds (was 60s)
699
+
700
+ // Capture everything - minimize sampling throttling
693
701
  sampling: {
694
- scroll: 150, // Throttle scroll events
695
- media: 800,
696
- input: 'last', // Only record final input value (privacy)
702
+ scroll: 50, // Capture scroll very frequently (was 150)
703
+ media: 200, // Capture media interactions frequently (was 800)
704
+ input: 'last', // Only record final input value (privacy for passwords)
705
+ mousemove: true, // Capture all mouse movements
706
+ mouseInteraction: true, // Capture all mouse interactions
707
+ },
708
+
709
+ // Privacy: Only mask sensitive inputs (passwords, credit cards)
710
+ // Everything else is captured for maximum replay fidelity
711
+ maskAllInputs: false, // Don't mask all inputs
712
+ maskInputOptions: {
713
+ password: true, // Mask password fields
714
+ email: false, // Capture emails
715
+ text: false, // Capture text inputs
716
+ textarea: false, // Capture textareas
717
+ select: false, // Capture select dropdowns
718
+ // Mask credit card patterns
719
+ },
720
+
721
+ // Capture all content types
722
+ recordCanvas: true, // Capture canvas elements (charts, games, etc.)
723
+
724
+ // Inline everything for perfect replay
725
+ inlineStylesheet: true, // Inline all external stylesheets
726
+ inlineImages: true, // Inline images as base64 (larger but more reliable)
727
+ collectFonts: true, // Capture custom fonts
728
+
729
+ // Block/ignore classes - customers can add these to sensitive elements
730
+ blockClass: 'cuoral-block', // Add to elements that should be blocked (replaced with placeholder)
731
+ ignoreClass: 'cuoral-ignore', // Add to elements that should be ignored (not captured)
732
+
733
+ // Capture mutations aggressively
734
+ slimDOMOptions: {
735
+ // Don't slim anything - capture full fidelity
736
+ script: false, // Keep scripts
737
+ comment: false, // Keep comments
738
+ headFavicon: false, // Keep favicons
739
+ headWhitespace: false, // Keep whitespace
740
+ headMetaSocial: false, // Keep meta tags
741
+ headMetaRobots: false, // Keep robots meta
742
+ headMetaHttpEquiv: false, // Keep http-equiv
743
+ headMetaAuthorship: false, // Keep authorship
744
+ headMetaDescKeywords: false, // Keep description/keywords
697
745
  },
698
- maskAllInputs: true, // Mask sensitive inputs (privacy)
699
- blockClass: 'cuoral-block',
700
- ignoreClass: 'cuoral-ignore',
701
746
  });
702
747
 
748
+ // Setup Shadow DOM observer to capture Ionic components
749
+ this.setupShadowDOMObserver();
750
+
703
751
  // Setup custom event tracking
704
752
  this.setupClickTracking();
705
753
  this.setupScrollTracking();
@@ -873,6 +921,181 @@ export class CuoralIntelligence {
873
921
  });
874
922
  }
875
923
 
924
+ /**
925
+ * Setup Shadow DOM observer to capture Ionic component content
926
+ * Emits custom rrweb events with Shadow DOM snapshots for viewer reconstruction
927
+ */
928
+ private setupShadowDOMObserver(): void {
929
+ let shadowNodeIdCounter = 1;
930
+ let captureTimeout: any = null;
931
+
932
+ const captureShadowDOM = (immediate = false) => {
933
+ // Debounce rapid calls (e.g., during scroll)
934
+ if (!immediate && captureTimeout) {
935
+ clearTimeout(captureTimeout);
936
+ }
937
+
938
+ const doCapture = () => {
939
+ if (!this.rrwebEmit) return;
940
+
941
+ const shadowDOMData: any[] = [];
942
+ const elementsWithShadowDOM = document.querySelectorAll('*');
943
+
944
+ elementsWithShadowDOM.forEach((element) => {
945
+ if (element.shadowRoot) {
946
+ try {
947
+ // Assign unique ID to element if it doesn't have one
948
+ if (!this.shadowDOMNodeMap.has(element)) {
949
+ this.shadowDOMNodeMap.set(element, shadowNodeIdCounter++);
950
+ }
951
+
952
+ const nodeId = this.shadowDOMNodeMap.get(element);
953
+ const tagName = element.tagName.toLowerCase();
954
+
955
+ // Capture Shadow DOM HTML
956
+ const shadowHTML = element.shadowRoot.innerHTML;
957
+
958
+ // Capture ALL styles from Shadow DOM
959
+ const styleSheets: string[] = [];
960
+
961
+ // 1. Capture inline <style> tags
962
+ const shadowStyles = element.shadowRoot.querySelectorAll('style');
963
+ shadowStyles.forEach((styleEl) => {
964
+ if (styleEl.textContent) {
965
+ styleSheets.push(styleEl.textContent);
966
+ }
967
+ });
968
+
969
+ // 2. Capture adoptedStyleSheets (modern CSS API)
970
+ if ((element.shadowRoot as any).adoptedStyleSheets) {
971
+ try {
972
+ (element.shadowRoot as any).adoptedStyleSheets.forEach((sheet: CSSStyleSheet) => {
973
+ try {
974
+ const rules = Array.from(sheet.cssRules).map(rule => rule.cssText).join('\n');
975
+ if (rules) styleSheets.push(rules);
976
+ } catch (e) {
977
+ // CORS blocked stylesheet
978
+ }
979
+ });
980
+ } catch (e) {
981
+ // Browser doesn't support adoptedStyleSheets
982
+ }
983
+ }
984
+
985
+ // 3. Capture external <link> stylesheets in Shadow DOM
986
+ const shadowLinks = element.shadowRoot.querySelectorAll('link[rel="stylesheet"]');
987
+ shadowLinks.forEach((linkEl) => {
988
+ const href = (linkEl as HTMLLinkElement).href;
989
+ if (href) {
990
+ // Store link href so dashboard can also load it
991
+ styleSheets.push(`/* @import url("${href}"); */`);
992
+ }
993
+ });
994
+
995
+ // 4. Capture CSS custom properties from host element
996
+ // Ionic components use CSS variables extensively (--ion-color-*, etc.)
997
+ const computedStyle = window.getComputedStyle(element);
998
+ const cssVariables: string[] = [];
999
+
1000
+ // Get all CSS custom properties from host
1001
+ for (let i = 0; i < computedStyle.length; i++) {
1002
+ const propertyName = computedStyle[i];
1003
+ if (propertyName.startsWith('--')) {
1004
+ const propertyValue = computedStyle.getPropertyValue(propertyName).trim();
1005
+ if (propertyValue) {
1006
+ cssVariables.push(`${propertyName}: ${propertyValue};`);
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ // Add CSS variables as a :host rule
1012
+ if (cssVariables.length > 0) {
1013
+ styleSheets.push(`:host { ${cssVariables.join(' ')} }`);
1014
+ }
1015
+
1016
+ // Get element's CSS selector for reconstruction
1017
+ const selector = this.getElementSelector(element as HTMLElement);
1018
+
1019
+ // Capture element attributes (Ionic uses color="primary", mode="ios", etc.)
1020
+ const attributes: Record<string, string> = {};
1021
+ for (let i = 0; i < element.attributes.length; i++) {
1022
+ const attr = element.attributes[i];
1023
+ // Skip internal attributes
1024
+ if (!attr.name.startsWith('data-cuoral') && !attr.name.startsWith('ng-')) {
1025
+ attributes[attr.name] = attr.value;
1026
+ }
1027
+ }
1028
+
1029
+ // Create Shadow DOM snapshot
1030
+ shadowDOMData.push({
1031
+ nodeId,
1032
+ tagName,
1033
+ selector,
1034
+ attributes, // NEW: Include element attributes
1035
+ shadowHTML,
1036
+ styleSheets,
1037
+ timestamp: Date.now(),
1038
+ });
1039
+
1040
+ // Also set data attribute as fallback
1041
+ element.setAttribute('data-cuoral-shadow-id', String(nodeId));
1042
+
1043
+ } catch (error) {
1044
+ // Shadow DOM might be closed
1045
+ console.warn('[Cuoral Intelligence] Failed to capture Shadow DOM:', error);
1046
+ }
1047
+ }
1048
+ });
1049
+
1050
+ // Emit custom event with Shadow DOM data
1051
+ if (shadowDOMData.length > 0 && this.rrwebEmit) {
1052
+ this.rrwebEmit({
1053
+ type: 5, // CustomEvent type in rrweb
1054
+ data: {
1055
+ tag: 'cuoral-shadow-dom',
1056
+ payload: {
1057
+ shadows: shadowDOMData,
1058
+ url: window.location.href,
1059
+ },
1060
+ },
1061
+ timestamp: Date.now(),
1062
+ });
1063
+
1064
+ console.log(`[Cuoral Intelligence] Captured ${shadowDOMData.length} Shadow DOM elements`);
1065
+ }
1066
+ };
1067
+
1068
+ if (immediate) {
1069
+ doCapture();
1070
+ } else {
1071
+ // Debounce: wait 500ms after last call
1072
+ captureTimeout = setTimeout(doCapture, 500);
1073
+ }
1074
+ };
1075
+
1076
+ // Run immediately on init
1077
+ captureShadowDOM(true);
1078
+
1079
+ // Observe DOM mutations to catch dynamically added Shadow DOM elements
1080
+ const observer = new MutationObserver(() => {
1081
+ captureShadowDOM(false); // Debounced
1082
+ });
1083
+
1084
+ observer.observe(document.body, {
1085
+ childList: true,
1086
+ subtree: true,
1087
+ });
1088
+
1089
+ // Capture on scroll events (debounced)
1090
+ window.addEventListener('scroll', () => captureShadowDOM(false), { passive: true });
1091
+
1092
+ // Also listen for Ionic scroll events
1093
+ document.addEventListener('ionScroll', () => captureShadowDOM(false), { passive: true });
1094
+
1095
+ // Periodic capture as fallback (every 5 seconds now, since we have scroll capture)
1096
+ setInterval(() => captureShadowDOM(true), 5000);
1097
+ }
1098
+
876
1099
  /**
877
1100
  * Track custom business events (flows, features, etc.)
878
1101
  */
package/src/modal.ts CHANGED
@@ -9,11 +9,13 @@ export class CuoralModal {
9
9
  private isOpen = false;
10
10
  private widgetUrl: string;
11
11
  private showFloatingButton: boolean;
12
+ private primaryColor: string;
12
13
  private lastLoadedUrl?: string;
13
14
 
14
- constructor(widgetUrl: string, showFloatingButton = true) {
15
+ constructor(widgetUrl: string, showFloatingButton = true, primaryColor = '#007AFF') {
15
16
  this.widgetUrl = widgetUrl;
16
17
  this.showFloatingButton = showFloatingButton;
18
+ this.primaryColor = primaryColor;
17
19
  }
18
20
 
19
21
  /**
@@ -94,12 +96,12 @@ export class CuoralModal {
94
96
  width: '56px',
95
97
  height: '56px',
96
98
  borderRadius: '50%',
97
- backgroundColor: '#007AFF',
99
+ backgroundColor: this.primaryColor,
98
100
  display: 'flex',
99
101
  alignItems: 'center',
100
102
  justifyContent: 'center',
101
103
  cursor: 'pointer',
102
- boxShadow: '0 4px 12px rgba(0, 122, 255, 0.4)',
104
+ boxShadow: `0 4px 12px ${this.primaryColor}66`, // 40% opacity
103
105
  zIndex: '999999',
104
106
  transition: 'transform 0.2s, box-shadow 0.2s'
105
107
  });
@@ -108,14 +110,14 @@ export class CuoralModal {
108
110
  this.floatingButton.addEventListener('mouseenter', () => {
109
111
  if (this.floatingButton) {
110
112
  this.floatingButton.style.transform = 'scale(1.1)';
111
- this.floatingButton.style.boxShadow = '0 6px 16px rgba(0, 122, 255, 0.5)';
113
+ this.floatingButton.style.boxShadow = `0 6px 16px ${this.primaryColor}80`; // 50% opacity
112
114
  }
113
115
  });
114
116
 
115
117
  this.floatingButton.addEventListener('mouseleave', () => {
116
118
  if (this.floatingButton) {
117
119
  this.floatingButton.style.transform = 'scale(1)';
118
- this.floatingButton.style.boxShadow = '0 4px 12px rgba(0, 122, 255, 0.4)';
120
+ this.floatingButton.style.boxShadow = `0 4px 12px ${this.primaryColor}66`; // 40% opacity
119
121
  }
120
122
  });
121
123