@feedvalue/core 0.1.11 → 0.1.15

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/index.cjs CHANGED
@@ -455,6 +455,123 @@ function clearFingerprint() {
455
455
  }
456
456
  }
457
457
 
458
+ // src/context-capture.ts
459
+ var DEFAULT_CONTEXT_CAPTURE_CONFIG = {
460
+ enabled: true,
461
+ maxDepth: 5,
462
+ maxHeadingLength: 100,
463
+ dataAttributeWhitelist: [
464
+ "data-section",
465
+ "data-feature",
466
+ "data-component",
467
+ "data-fv-section",
468
+ "data-fv-feature"
469
+ ]
470
+ };
471
+ function findNearestWithId(element, maxDepth) {
472
+ let current = element;
473
+ let depth = 0;
474
+ while (current && depth < maxDepth) {
475
+ if (current.id) {
476
+ return current;
477
+ }
478
+ current = current.parentElement;
479
+ depth++;
480
+ }
481
+ return null;
482
+ }
483
+ function findNearestHeading(element, section, maxDepth) {
484
+ if (section) {
485
+ const heading = section.querySelector("h1, h2, h3, h4, h5, h6");
486
+ if (heading) return heading;
487
+ }
488
+ let current = element;
489
+ let depth = 0;
490
+ while (current && depth < maxDepth) {
491
+ let sibling = current.previousElementSibling;
492
+ while (sibling) {
493
+ if (/^H[1-6]$/.test(sibling.tagName)) {
494
+ return sibling;
495
+ }
496
+ sibling = sibling.previousElementSibling;
497
+ }
498
+ current = current.parentElement;
499
+ depth++;
500
+ }
501
+ return null;
502
+ }
503
+ function captureDataAttributes(element, whitelist, maxDepth) {
504
+ const result = {};
505
+ let current = element;
506
+ let depth = 0;
507
+ while (current && depth < maxDepth) {
508
+ for (const attr of Array.from(current.attributes)) {
509
+ if (whitelist.includes(attr.name) && !result[attr.name]) {
510
+ result[attr.name] = attr.value;
511
+ }
512
+ }
513
+ current = current.parentElement;
514
+ depth++;
515
+ }
516
+ return result;
517
+ }
518
+ function generateSelector(element, maxDepth) {
519
+ const parts = [];
520
+ let current = element;
521
+ let depth = 0;
522
+ while (current && depth < maxDepth && current !== document.body) {
523
+ let selector = current.tagName.toLowerCase();
524
+ if (current.id) {
525
+ selector = `#${current.id}`;
526
+ parts.unshift(selector);
527
+ break;
528
+ }
529
+ if (current.className && typeof current.className === "string") {
530
+ const classes = current.className.split(" ").filter((c) => c.trim()).slice(0, 2);
531
+ if (classes.length) {
532
+ selector += "." + classes.join(".");
533
+ }
534
+ }
535
+ parts.unshift(selector);
536
+ current = current.parentElement;
537
+ depth++;
538
+ }
539
+ return parts.join(" > ");
540
+ }
541
+ function truncate(str, maxLength) {
542
+ if (str.length <= maxLength) return str;
543
+ return str.substring(0, maxLength - 3) + "...";
544
+ }
545
+ function captureContext(triggerElement, config = DEFAULT_CONTEXT_CAPTURE_CONFIG) {
546
+ if (!config.enabled || !triggerElement || typeof document === "undefined") {
547
+ return null;
548
+ }
549
+ const context = {};
550
+ const sectionWithId = findNearestWithId(triggerElement, config.maxDepth);
551
+ if (sectionWithId) {
552
+ context.sectionId = sectionWithId.id;
553
+ context.sectionTag = sectionWithId.tagName.toLowerCase();
554
+ }
555
+ const heading = findNearestHeading(triggerElement, sectionWithId, config.maxDepth);
556
+ if (heading) {
557
+ context.nearestHeading = truncate(heading.textContent?.trim() || "", config.maxHeadingLength);
558
+ const level = heading.tagName[1];
559
+ if (level) {
560
+ context.headingLevel = parseInt(level, 10);
561
+ }
562
+ }
563
+ const dataAttrs = captureDataAttributes(
564
+ triggerElement,
565
+ config.dataAttributeWhitelist,
566
+ config.maxDepth
567
+ );
568
+ if (Object.keys(dataAttrs).length > 0) {
569
+ context.dataAttributes = dataAttrs;
570
+ }
571
+ context.cssSelector = generateSelector(triggerElement, 3);
572
+ return Object.keys(context).length > 0 ? context : null;
573
+ }
574
+
458
575
  // src/feedvalue.ts
459
576
  var SUCCESS_AUTO_CLOSE_DELAY_MS = 3e3;
460
577
  var VALID_SENTIMENTS = ["angry", "disappointed", "satisfied", "excited"];
@@ -477,6 +594,7 @@ var _FeedValue = class _FeedValue {
477
594
  __publicField(this, "apiClient");
478
595
  __publicField(this, "emitter");
479
596
  __publicField(this, "headless");
597
+ __publicField(this, "contextCaptureConfig");
480
598
  __publicField(this, "config");
481
599
  __publicField(this, "widgetConfig", null);
482
600
  // State
@@ -499,6 +617,8 @@ var _FeedValue = class _FeedValue {
499
617
  __publicField(this, "modal", null);
500
618
  __publicField(this, "overlay", null);
501
619
  __publicField(this, "stylesInjected", false);
620
+ // Viewport resize compensation cleanup
621
+ __publicField(this, "viewportResizeCleanup", null);
502
622
  // Auto-close timeout reference (for cleanup on destroy)
503
623
  __publicField(this, "autoCloseTimeout", null);
504
624
  // Destroyed flag - guards async continuations (fixes React StrictMode race condition)
@@ -506,6 +626,10 @@ var _FeedValue = class _FeedValue {
506
626
  this.widgetId = options.widgetId;
507
627
  this.headless = options.headless ?? false;
508
628
  this.config = { ...DEFAULT_CONFIG, ...options.config };
629
+ this.contextCaptureConfig = {
630
+ ...DEFAULT_CONTEXT_CAPTURE_CONFIG,
631
+ ...options.contextCapture
632
+ };
509
633
  this.apiClient = new ApiClient(
510
634
  options.apiBaseUrl ?? DEFAULT_API_BASE_URL,
511
635
  this.config.debug
@@ -569,12 +693,28 @@ var _FeedValue = class _FeedValue {
569
693
  thankYouMessage: configResponse.config.thankYouMessage ?? "Thank you for your feedback!",
570
694
  showBranding: configResponse.config.showBranding ?? true,
571
695
  customFields: configResponse.config.customFields,
572
- // Reaction config (for reaction widgets) - only include if defined
696
+ // Reaction config (for reaction widgets) - pass through all fields
573
697
  ...configResponse.config.template && { template: configResponse.config.template },
574
698
  ...configResponse.config.options && { options: configResponse.config.options },
575
699
  followUpLabel: configResponse.config.followUpLabel ?? "Tell us more (optional)",
576
- submitText: configResponse.config.submitText ?? "Send"
700
+ submitText: configResponse.config.submitText ?? "Send",
701
+ // Reaction widget display options (support both camelCase and snake_case from API)
702
+ ...(configResponse.config.showLabels !== void 0 || configResponse.config.show_labels !== void 0) && {
703
+ showLabels: configResponse.config.showLabels ?? configResponse.config.show_labels
704
+ },
705
+ ...(configResponse.config.buttonSize || configResponse.config.button_size) && {
706
+ buttonSize: configResponse.config.buttonSize ?? configResponse.config.button_size
707
+ },
708
+ ...(configResponse.config.followUpTrigger || configResponse.config.follow_up_trigger) && {
709
+ followUpTrigger: configResponse.config.followUpTrigger ?? configResponse.config.follow_up_trigger
710
+ }
577
711
  };
712
+ this.log("Built baseConfig:", {
713
+ buttonSize: baseConfig.buttonSize,
714
+ showLabels: baseConfig.showLabels,
715
+ followUpTrigger: baseConfig.followUpTrigger,
716
+ template: baseConfig.template
717
+ });
578
718
  this.widgetConfig = {
579
719
  widgetId: configResponse.widget_id,
580
720
  widgetKey: configResponse.widget_key,
@@ -582,12 +722,16 @@ var _FeedValue = class _FeedValue {
582
722
  type: configResponse.type ?? "feedback",
583
723
  config: baseConfig,
584
724
  styling: {
585
- primaryColor: configResponse.styling.primaryColor ?? "#3b82f6",
586
- backgroundColor: configResponse.styling.backgroundColor ?? "#ffffff",
587
- textColor: configResponse.styling.textColor ?? "#1f2937",
588
- buttonTextColor: configResponse.styling.buttonTextColor ?? "#ffffff",
589
- borderRadius: configResponse.styling.borderRadius ?? "8px",
590
- customCSS: configResponse.styling.customCSS
725
+ // Pass through all styling properties from API
726
+ ...configResponse.styling,
727
+ // Apply defaults for required fields (support both camelCase and snake_case from API)
728
+ primaryColor: configResponse.styling.primaryColor ?? configResponse.styling.primary_color ?? "#3b82f6",
729
+ backgroundColor: configResponse.styling.backgroundColor ?? configResponse.styling.background_color ?? "#ffffff",
730
+ textColor: configResponse.styling.textColor ?? configResponse.styling.text_color ?? "#1f2937",
731
+ buttonTextColor: configResponse.styling.buttonTextColor ?? configResponse.styling.button_text_color ?? "#ffffff",
732
+ borderColor: configResponse.styling.borderColor ?? configResponse.styling.border_color ?? "#e5e7eb",
733
+ borderWidth: configResponse.styling.borderWidth ?? configResponse.styling.border_width ?? "1",
734
+ borderRadius: configResponse.styling.borderRadius ?? configResponse.styling.border_radius ?? "8px"
591
735
  }
592
736
  };
593
737
  if (!this.headless && typeof window !== "undefined" && typeof document !== "undefined") {
@@ -613,6 +757,8 @@ var _FeedValue = class _FeedValue {
613
757
  clearTimeout(this.autoCloseTimeout);
614
758
  this.autoCloseTimeout = null;
615
759
  }
760
+ this.viewportResizeCleanup?.();
761
+ this.viewportResizeCleanup = null;
616
762
  this.triggerButton?.remove();
617
763
  this.modal?.remove();
618
764
  this.overlay?.remove();
@@ -821,12 +967,12 @@ var _FeedValue = class _FeedValue {
821
967
  getTemplateOptions(template) {
822
968
  const templates = {
823
969
  thumbs: [
824
- { label: "Helpful", value: "helpful", icon: "thumbs-up", showFollowUp: false },
825
- { label: "Not Helpful", value: "not_helpful", icon: "thumbs-down", showFollowUp: true }
970
+ { label: "Helpful", value: "helpful", icon: "\u{1F44D}", showFollowUp: false },
971
+ { label: "Not Helpful", value: "not_helpful", icon: "\u{1F44E}", showFollowUp: true }
826
972
  ],
827
973
  helpful: [
828
- { label: "Yes", value: "yes", icon: "check", showFollowUp: false },
829
- { label: "No", value: "no", icon: "x", showFollowUp: true }
974
+ { label: "Yes", value: "yes", icon: "\u2713", showFollowUp: false },
975
+ { label: "No", value: "no", icon: "\u2717", showFollowUp: true }
830
976
  ],
831
977
  emoji: [
832
978
  { label: "Angry", value: "angry", icon: "\u{1F620}", showFollowUp: true },
@@ -848,7 +994,7 @@ var _FeedValue = class _FeedValue {
848
994
  /**
849
995
  * Submit a reaction.
850
996
  * @param value - Selected reaction option value
851
- * @param options - Optional follow-up text
997
+ * @param options - Optional follow-up text and trigger element for context capture
852
998
  */
853
999
  async react(value, options) {
854
1000
  if (!this.state.isReady) {
@@ -866,13 +1012,25 @@ var _FeedValue = class _FeedValue {
866
1012
  const validValues = reactionOptions.map((opt) => opt.value).join(", ");
867
1013
  throw new Error(`Invalid reaction value. Must be one of: ${validValues}`);
868
1014
  }
1015
+ let capturedContext = null;
1016
+ if (options?.triggerElement) {
1017
+ capturedContext = captureContext(options.triggerElement, this.contextCaptureConfig);
1018
+ this.log("Captured context", capturedContext);
1019
+ }
869
1020
  this.emitter.emit("react", { value, hasFollowUp: selectedOption.showFollowUp });
870
1021
  this.updateState({ isSubmitting: true });
871
1022
  try {
872
1023
  const reactionData = {
873
1024
  value,
874
1025
  metadata: {
875
- page_url: typeof window !== "undefined" ? window.location.href : ""
1026
+ page_url: typeof window !== "undefined" ? window.location.href : "",
1027
+ // Spread captured context into metadata
1028
+ ...capturedContext?.sectionId && { section_id: capturedContext.sectionId },
1029
+ ...capturedContext?.sectionTag && { section_tag: capturedContext.sectionTag },
1030
+ ...capturedContext?.nearestHeading && { nearest_heading: capturedContext.nearestHeading },
1031
+ ...capturedContext?.headingLevel && { heading_level: capturedContext.headingLevel },
1032
+ ...capturedContext?.dataAttributes && { data_attributes: capturedContext.dataAttributes },
1033
+ ...capturedContext?.cssSelector && { css_selector: capturedContext.cssSelector }
876
1034
  },
877
1035
  ...options?.followUp && { followUp: options.followUp }
878
1036
  };
@@ -1024,6 +1182,7 @@ var _FeedValue = class _FeedValue {
1024
1182
  }
1025
1183
  this.renderTrigger();
1026
1184
  this.renderModal();
1185
+ this.setupViewportCompensation();
1027
1186
  }
1028
1187
  /**
1029
1188
  * Sanitize CSS to block potentially dangerous patterns
@@ -1236,7 +1395,7 @@ var _FeedValue = class _FeedValue {
1236
1395
  getPositionStyles(position) {
1237
1396
  switch (position) {
1238
1397
  case "bottom-left":
1239
- return "bottom: 20px; left: 20px;";
1398
+ return "bottom: calc(20px + env(safe-area-inset-bottom, 0px)); left: 20px;";
1240
1399
  case "top-right":
1241
1400
  return "top: 20px; right: 20px;";
1242
1401
  case "top-left":
@@ -1245,7 +1404,7 @@ var _FeedValue = class _FeedValue {
1245
1404
  return "top: 50%; left: 50%; transform: translate(-50%, -50%);";
1246
1405
  case "bottom-right":
1247
1406
  default:
1248
- return "bottom: 20px; right: 20px;";
1407
+ return "bottom: calc(20px + env(safe-area-inset-bottom, 0px)); right: 20px;";
1249
1408
  }
1250
1409
  }
1251
1410
  /**
@@ -1254,9 +1413,9 @@ var _FeedValue = class _FeedValue {
1254
1413
  getModalPositionStyles(position) {
1255
1414
  switch (position) {
1256
1415
  case "bottom-left":
1257
- return "bottom: 20px; left: 20px;";
1416
+ return "bottom: calc(20px + env(safe-area-inset-bottom, 0px)); left: 20px;";
1258
1417
  case "bottom-right":
1259
- return "bottom: 20px; right: 20px;";
1418
+ return "bottom: calc(20px + env(safe-area-inset-bottom, 0px)); right: 20px;";
1260
1419
  case "top-right":
1261
1420
  return "top: 20px; right: 20px;";
1262
1421
  case "top-left":
@@ -1266,6 +1425,37 @@ var _FeedValue = class _FeedValue {
1266
1425
  return "top: 50%; left: 50%; transform: translate(-50%, -50%);";
1267
1426
  }
1268
1427
  }
1428
+ /**
1429
+ * Smooth viewport resize compensation for mobile browser toolbar show/hide.
1430
+ *
1431
+ * When Chrome/Safari's bottom toolbar appears or disappears, the layout viewport
1432
+ * resizes and position:fixed elements jump. This detects toolbar-sized height
1433
+ * changes and applies an inverse CSS translate to cancel the jump, then animates
1434
+ * back to the natural position. Uses the `translate` CSS property (separate from
1435
+ * `transform`) to avoid conflicting with the hover transform on the trigger.
1436
+ */
1437
+ setupViewportCompensation() {
1438
+ if (typeof window === "undefined") return;
1439
+ let lastHeight = window.innerHeight;
1440
+ const handleResize = () => {
1441
+ const newHeight = window.innerHeight;
1442
+ const delta = newHeight - lastHeight;
1443
+ lastHeight = newHeight;
1444
+ if (Math.abs(delta) <= 5 || Math.abs(delta) >= 120) return;
1445
+ const elements = [this.triggerButton, this.modal].filter(Boolean);
1446
+ for (const el of elements) {
1447
+ el.style.transition = "none";
1448
+ el.style.translate = `0 ${delta}px`;
1449
+ void el.offsetHeight;
1450
+ el.style.transition = "translate 0.3s ease-out";
1451
+ el.style.translate = "0 0";
1452
+ }
1453
+ };
1454
+ window.addEventListener("resize", handleResize, { passive: true });
1455
+ this.viewportResizeCleanup = () => {
1456
+ window.removeEventListener("resize", handleResize);
1457
+ };
1458
+ }
1269
1459
  /**
1270
1460
  * Parse SVG string to DOM element safely using DOMParser
1271
1461
  */
@@ -1466,9 +1656,6 @@ var _FeedValue = class _FeedValue {
1466
1656
  }
1467
1657
  }
1468
1658
  };
1469
- /**
1470
- * SVG icons for trigger button (matching widget-bundle exactly)
1471
- */
1472
1659
  /**
1473
1660
  * SVG icons for trigger button - must match Lucide icons used in frontend-web
1474
1661
  * chat = MessageCircle, message = MessageSquare, feedback = MessagesSquare,
@@ -1494,9 +1681,11 @@ var NEGATIVE_OPTIONS_MAP = {
1494
1681
 
1495
1682
  exports.ApiClient = ApiClient;
1496
1683
  exports.DEFAULT_API_BASE_URL = DEFAULT_API_BASE_URL;
1684
+ exports.DEFAULT_CONTEXT_CAPTURE_CONFIG = DEFAULT_CONTEXT_CAPTURE_CONFIG;
1497
1685
  exports.FeedValue = FeedValue;
1498
1686
  exports.NEGATIVE_OPTIONS_MAP = NEGATIVE_OPTIONS_MAP;
1499
1687
  exports.TypedEventEmitter = TypedEventEmitter;
1688
+ exports.captureContext = captureContext;
1500
1689
  exports.clearFingerprint = clearFingerprint;
1501
1690
  exports.generateFingerprint = generateFingerprint;
1502
1691
  //# sourceMappingURL=index.cjs.map