@mparticle/web-sdk 3.28.0 → 3.29.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/README.md +2 -2
- package/dist/mparticle.common.js +1 -1
- package/dist/mparticle.esm.js +1 -1
- package/dist/mparticle.js +203 -31
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +2 -0
- package/dist/types/src/buttonTracking/checkoutIntent.d.ts +2 -0
- package/package.json +1 -1
- package/src/buttonTracking/buttonDescription.ts +249 -35
- package/src/buttonTracking/buttonTracker.ts +18 -0
- package/src/buttonTracking/checkoutIntent.ts +36 -0
|
@@ -203,12 +203,19 @@ function textOf(element: Element | null): string | undefined {
|
|
|
203
203
|
: '';
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
// The control itself is already known to be shown.
|
|
206
|
+
// The control itself is already known to be shown. An inline icon names
|
|
207
|
+
// itself with aria-label rather than alt.
|
|
207
208
|
function shownImageAlt(control: Element): string | undefined {
|
|
208
|
-
const images = control.querySelectorAll(
|
|
209
|
+
const images = control.querySelectorAll(
|
|
210
|
+
'img[alt], svg[aria-label], [role="img"][aria-label]'
|
|
211
|
+
);
|
|
209
212
|
for (let i = 0; i < images.length && i < MAX_READS; i++) {
|
|
210
|
-
|
|
211
|
-
|
|
213
|
+
const alt = nonBlank(
|
|
214
|
+
images[i].getAttribute('alt') ||
|
|
215
|
+
images[i].getAttribute('aria-label')
|
|
216
|
+
);
|
|
217
|
+
if (alt && isShown(images[i], control) && isVisible(images[i])) {
|
|
218
|
+
return alt;
|
|
212
219
|
}
|
|
213
220
|
}
|
|
214
221
|
return undefined;
|
|
@@ -388,7 +395,7 @@ function questionName(value: string | null): string | undefined {
|
|
|
388
395
|
return stableIdentifier(value && value.trim().replace(/[-_][0-9]{4,}$/, ''));
|
|
389
396
|
}
|
|
390
397
|
|
|
391
|
-
const TEST_ID_ATTRIBUTES = [
|
|
398
|
+
export const TEST_ID_ATTRIBUTES = [
|
|
392
399
|
'data-testid',
|
|
393
400
|
'data-test-id',
|
|
394
401
|
'data-test',
|
|
@@ -691,7 +698,9 @@ export function describeButtonClick(
|
|
|
691
698
|
// aria-label, aria-labelledby or legend, masked like a label and cut at its
|
|
692
699
|
// first colon, since a legend can repeat the pick ("Color: Navy"). Never the
|
|
693
700
|
// option's own text, which is the shopper's answer.
|
|
694
|
-
const NAMED_GROUP =
|
|
701
|
+
const NAMED_GROUP =
|
|
702
|
+
'[role="radiogroup"], [role="listbox"], [role="group"], fieldset, ' +
|
|
703
|
+
'[role="region"][aria-label], [role="region"][aria-labelledby]';
|
|
695
704
|
// A control such as the button that opens a list box shows the option picked.
|
|
696
705
|
const NOT_A_QUESTION =
|
|
697
706
|
CHOICE +
|
|
@@ -740,6 +749,144 @@ function sameQuestion(option: Element, choice: Element): boolean {
|
|
|
740
749
|
);
|
|
741
750
|
}
|
|
742
751
|
|
|
752
|
+
function isTransparent(element: Element, until: Element): boolean {
|
|
753
|
+
return someComposedAncestor(
|
|
754
|
+
element,
|
|
755
|
+
node => window.getComputedStyle(node).opacity === '0',
|
|
756
|
+
until
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Content that is never painted where it sits: SVG that is only referenced or
|
|
761
|
+
// read, and a canvas's fallback, which some browsers still give boxes.
|
|
762
|
+
const UNDRAWN =
|
|
763
|
+
'svg title, svg desc, svg metadata, svg defs, svg symbol, svg clipPath, ' +
|
|
764
|
+
'svg mask, svg pattern, svg marker, svg linearGradient, ' +
|
|
765
|
+
'svg radialGradient, svg filter, canvas';
|
|
766
|
+
|
|
767
|
+
// Whether text sits in any closed details element outside its summary.
|
|
768
|
+
function isInClosedDetails(text: Node, parent: Element): boolean {
|
|
769
|
+
let details = parent.closest('details:not([open])');
|
|
770
|
+
while (details) {
|
|
771
|
+
const summary = Array.from(details.children).filter(
|
|
772
|
+
child => child.tagName === 'SUMMARY'
|
|
773
|
+
)[0];
|
|
774
|
+
if (!summary || !summary.contains(text)) {
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
details = details.parentElement
|
|
778
|
+
? details.parentElement.closest('details:not([open])')
|
|
779
|
+
: null;
|
|
780
|
+
}
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Whether the browser shows an element through every ancestor it renders
|
|
785
|
+
// under, shadow trees included, which a walk of light ancestors can't see
|
|
786
|
+
// into when a shadow root is closed. Where it can't say (checkVisibility
|
|
787
|
+
// arrived in Chrome 105, Firefox 125 and Safari 17.4), the element counts as
|
|
788
|
+
// not shown, so option rows name no group in older browsers.
|
|
789
|
+
function isComposedVisible(element: Element): boolean {
|
|
790
|
+
const check = (element as Element & {
|
|
791
|
+
checkVisibility?: (options: { [key: string]: boolean }) => boolean;
|
|
792
|
+
}).checkVisibility;
|
|
793
|
+
return (
|
|
794
|
+
isFunction(check) &&
|
|
795
|
+
// Older Chrome versions read the first two names, newer browsers the rest.
|
|
796
|
+
check.call(element, {
|
|
797
|
+
checkOpacity: true,
|
|
798
|
+
checkVisibilityCSS: true,
|
|
799
|
+
opacityProperty: true,
|
|
800
|
+
visibilityProperty: true,
|
|
801
|
+
})
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Whether the browser lays a text node out in a box of its own. Text it never
|
|
806
|
+
// draws, such as an SVG definition, canvas fallback, closed details or an
|
|
807
|
+
// unslotted child of a shadow host, has none. Where boxes can't be read, the
|
|
808
|
+
// text counts as not drawn.
|
|
809
|
+
function isDrawn(text: Node): boolean {
|
|
810
|
+
const range = isFunction(document.createRange)
|
|
811
|
+
? document.createRange()
|
|
812
|
+
: null;
|
|
813
|
+
if (!range || !isFunction(range.getClientRects)) {
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
range.selectNodeContents(text);
|
|
817
|
+
return Array.from(range.getClientRects()).some(
|
|
818
|
+
box => box.width > 0 && box.height > 0
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Whether a row shows text of its own: a drawn, shown, non-transparent text
|
|
823
|
+
// node, outside any form field, editable area or opted-out part, beside
|
|
824
|
+
// nothing but the row's own input. Not a row with a shadow root, slot or
|
|
825
|
+
// custom element, whose rendered text a walk of its light children can't see.
|
|
826
|
+
function showsText(row: Element, own: Element): boolean {
|
|
827
|
+
if (
|
|
828
|
+
isUnsafeTextSource(row, own) ||
|
|
829
|
+
hasComposedAncestor(own, '[data-mp-no-track]') ||
|
|
830
|
+
row.shadowRoot ||
|
|
831
|
+
row.tagName.indexOf('-') !== -1 ||
|
|
832
|
+
row.querySelector('slot')
|
|
833
|
+
) {
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
// A row too large to check in full counts as showing nothing.
|
|
837
|
+
const descendants = row.querySelectorAll('*');
|
|
838
|
+
if (descendants.length > MAX_READS) {
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
for (let i = 0; i < descendants.length; i++) {
|
|
842
|
+
if (
|
|
843
|
+
descendants[i].shadowRoot ||
|
|
844
|
+
descendants[i].tagName.indexOf('-') !== -1
|
|
845
|
+
) {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
|
|
850
|
+
for (let i = 0; i < MAX_READS && walker.nextNode(); i++) {
|
|
851
|
+
const parent = walker.currentNode.parentElement;
|
|
852
|
+
if (
|
|
853
|
+
parent &&
|
|
854
|
+
nonBlank(walker.currentNode.nodeValue) &&
|
|
855
|
+
// Undrawn content, and the body of a closed details element,
|
|
856
|
+
// which browsers still give layout boxes.
|
|
857
|
+
!parent.closest(UNDRAWN) &&
|
|
858
|
+
!isInClosedDetails(walker.currentNode, parent) &&
|
|
859
|
+
isShown(parent, row) &&
|
|
860
|
+
isVisible(parent) &&
|
|
861
|
+
!isTransparent(parent, row) &&
|
|
862
|
+
isComposedVisible(parent) &&
|
|
863
|
+
isDrawn(walker.currentNode)
|
|
864
|
+
) {
|
|
865
|
+
return true;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// A native input inside a shown control of its own, such as a menu option row
|
|
872
|
+
// with a styled radio, which shows text of its own and doesn't also hold the
|
|
873
|
+
// option picked.
|
|
874
|
+
function showsRow(option: Element, choice: Element, group: Element): boolean {
|
|
875
|
+
const row =
|
|
876
|
+
option instanceof HTMLInputElement && option.parentElement
|
|
877
|
+
? option.parentElement.closest(CONTROL_SELECTOR)
|
|
878
|
+
: null;
|
|
879
|
+
return (
|
|
880
|
+
!!row &&
|
|
881
|
+
group.contains(row) &&
|
|
882
|
+
!row.contains(choice) &&
|
|
883
|
+
isShown(row, group) &&
|
|
884
|
+
isVisible(row) &&
|
|
885
|
+
!isTransparent(row, group) &&
|
|
886
|
+
showsText(row, option)
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
743
890
|
// Whether a group offers another answer to the question picked: a shown option
|
|
744
891
|
// to the same question that isn't part of the one picked. A card around one
|
|
745
892
|
// address radio and a "make default" checkbox, or around a role radio and the
|
|
@@ -755,12 +902,9 @@ function holdsOptions(group: Element, choice: Element): boolean {
|
|
|
755
902
|
isShown(option, group) &&
|
|
756
903
|
isVisible(option) &&
|
|
757
904
|
// A fully transparent option, as a native input behind a styled
|
|
758
|
-
// one often is, shows the shopper no second answer
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
node => window.getComputedStyle(node).opacity === '0',
|
|
762
|
-
group
|
|
763
|
-
)
|
|
905
|
+
// one often is, shows the shopper no second answer, unless a
|
|
906
|
+
// shown row around it does.
|
|
907
|
+
(!isTransparent(option, group) || showsRow(option, choice, group))
|
|
764
908
|
) {
|
|
765
909
|
return true;
|
|
766
910
|
}
|
|
@@ -788,34 +932,39 @@ function optionLabels(option: Element, root: Document | ShadowRoot): Element[] {
|
|
|
788
932
|
return labels;
|
|
789
933
|
}
|
|
790
934
|
|
|
791
|
-
// The group asking the question a pick answers. A role radio's
|
|
792
|
-
// radiogroup and an option's its list box; a role toggle, such as
|
|
793
|
-
// stands alone. A native box's is the nearest group offering other
|
|
794
|
-
// since a group around one option, such as a saved address card, can
|
|
795
|
-
// named by that option.
|
|
796
|
-
|
|
935
|
+
// The name of the group asking the question a pick answers. A role radio's
|
|
936
|
+
// group is its radiogroup and an option's its list box; a role toggle, such as
|
|
937
|
+
// a switch, stands alone. A native box's is the nearest group offering other
|
|
938
|
+
// answers, since a group around one option, such as a saved address card, can
|
|
939
|
+
// be named by that option. A region with no usable name is passed over, so a
|
|
940
|
+
// group around it can still name the pick. Each group's name is read once.
|
|
941
|
+
function groupName(choice: Element): string | undefined {
|
|
797
942
|
if (!(choice instanceof HTMLInputElement)) {
|
|
798
943
|
const container = choice.matches('[role~="option"]')
|
|
799
944
|
? choice.closest('[role="listbox"]')
|
|
800
945
|
: choice.matches('[role~="radio"]')
|
|
801
946
|
? choice.closest('[role="radiogroup"]')
|
|
802
947
|
: null;
|
|
803
|
-
return container && holdsOptions(container, choice)
|
|
948
|
+
return container && holdsOptions(container, choice)
|
|
949
|
+
? nameOf(container)
|
|
950
|
+
: undefined;
|
|
804
951
|
}
|
|
805
952
|
let group = choice.closest(NAMED_GROUP);
|
|
806
|
-
while (group
|
|
953
|
+
while (group) {
|
|
954
|
+
if (holdsOptions(group, choice)) {
|
|
955
|
+
const name = nameOf(group);
|
|
956
|
+
if (name || !group.matches('[role="region"]')) {
|
|
957
|
+
return name;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
807
960
|
group = group.parentElement
|
|
808
961
|
? group.parentElement.closest(NAMED_GROUP)
|
|
809
962
|
: null;
|
|
810
963
|
}
|
|
811
|
-
return
|
|
964
|
+
return undefined;
|
|
812
965
|
}
|
|
813
966
|
|
|
814
|
-
function
|
|
815
|
-
const group = questionGroup(choice);
|
|
816
|
-
if (!group) {
|
|
817
|
-
return undefined;
|
|
818
|
-
}
|
|
967
|
+
function nameOf(group: Element): string | undefined {
|
|
819
968
|
let raw = nonBlank(group.getAttribute('aria-label'));
|
|
820
969
|
if (!raw) {
|
|
821
970
|
const ids = group.getAttribute('aria-labelledby');
|
|
@@ -828,11 +977,24 @@ function groupName(choice: Element): string | undefined {
|
|
|
828
977
|
const root = (isFunction(group.getRootNode)
|
|
829
978
|
? group.getRootNode()
|
|
830
979
|
: document) as Document | ShadowRoot;
|
|
831
|
-
const
|
|
980
|
+
const target = ids
|
|
832
981
|
? root.getElementById
|
|
833
982
|
? root.getElementById(ids.trim().split(/\s+/)[0])
|
|
834
983
|
: null
|
|
835
984
|
: legend || null;
|
|
985
|
+
// A region opened by an accordion header outside a tab list is named
|
|
986
|
+
// by the header's first heading, never the rest of what it shows.
|
|
987
|
+
const header =
|
|
988
|
+
target &&
|
|
989
|
+
group.matches('[role="region"]') &&
|
|
990
|
+
(target.getAttribute('role') || '').trim() === 'tab' &&
|
|
991
|
+
!hasComposedAncestor(target, '[role~="tablist"]') &&
|
|
992
|
+
!target.querySelector(NOT_A_QUESTION)
|
|
993
|
+
? target
|
|
994
|
+
: null;
|
|
995
|
+
const source = header
|
|
996
|
+
? header.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]')
|
|
997
|
+
: target;
|
|
836
998
|
// A label that is, holds or sits in an option or a control, or in an
|
|
837
999
|
// option's label, would name the answer.
|
|
838
1000
|
const holder = source ? source.closest('label') : null;
|
|
@@ -848,7 +1010,10 @@ function groupName(choice: Element): string | undefined {
|
|
|
848
1010
|
raw =
|
|
849
1011
|
source &&
|
|
850
1012
|
!labelsOption &&
|
|
851
|
-
!
|
|
1013
|
+
!someComposedAncestor(
|
|
1014
|
+
source,
|
|
1015
|
+
node => node !== header && node.matches(NOT_A_QUESTION)
|
|
1016
|
+
) &&
|
|
852
1017
|
!source.querySelector(NOT_A_QUESTION) &&
|
|
853
1018
|
!(holder && (holder.control || holder.querySelector(CHOICE)))
|
|
854
1019
|
? textOf(source)
|
|
@@ -877,9 +1042,9 @@ function isPaymentQuestion(radio: Element): boolean {
|
|
|
877
1042
|
return question.some(words => PAYMENT_QUESTION.test(nameWords(words)));
|
|
878
1043
|
}
|
|
879
1044
|
|
|
880
|
-
// What names a payment option: the radio's accessible name
|
|
881
|
-
// image of each label,
|
|
882
|
-
function
|
|
1045
|
+
// What names a payment option to shoppers: the radio's accessible name and the
|
|
1046
|
+
// text and image of each label, masked like a label.
|
|
1047
|
+
function shownOptionNames(radio: Element): Array<string | undefined> {
|
|
883
1048
|
const names: Array<string | undefined> = [
|
|
884
1049
|
maskLabel(accessibleName(radio).raw),
|
|
885
1050
|
];
|
|
@@ -893,15 +1058,26 @@ function optionNames(radio: Element): string[] {
|
|
|
893
1058
|
names.push(maskLabel(shownImageAlt(label)));
|
|
894
1059
|
}
|
|
895
1060
|
});
|
|
896
|
-
names
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1061
|
+
return names;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function distinctNames(names: Array<string | undefined>): string[] {
|
|
900
1065
|
return names.filter(
|
|
901
1066
|
(name, index) => !!name && names.indexOf(name) === index
|
|
902
1067
|
);
|
|
903
1068
|
}
|
|
904
1069
|
|
|
1070
|
+
// What names a payment option: the names shoppers read, then its hand-written
|
|
1071
|
+
// ids.
|
|
1072
|
+
function optionNames(radio: Element): string[] {
|
|
1073
|
+
return distinctNames(
|
|
1074
|
+
shownOptionNames(radio).concat(
|
|
1075
|
+
stableIdentifier(radio.getAttribute('id')),
|
|
1076
|
+
firstIdentifier(radio, TEST_ID_ATTRIBUTES)
|
|
1077
|
+
)
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
905
1081
|
// The payment radio a click picks and the names of its option, for the Rokt
|
|
906
1082
|
// kit alone to read a payment method from. A radio's option is the shopper's
|
|
907
1083
|
// answer, so these names never go into the logged click.
|
|
@@ -926,6 +1102,44 @@ export function describePaymentChoice(
|
|
|
926
1102
|
return names.length ? { radio: choice, names } : undefined;
|
|
927
1103
|
}
|
|
928
1104
|
|
|
1105
|
+
const CHECKED_RADIO =
|
|
1106
|
+
'input[type="radio"]:checked, [role~="radio"][aria-checked="true"], [role~="menuitemradio"][aria-checked="true"]';
|
|
1107
|
+
|
|
1108
|
+
// The payment radio the page holds checked, such as a card option checked by
|
|
1109
|
+
// default that the shopper never clicked. A page holding more than one option
|
|
1110
|
+
// names no single method, so it describes nothing. One option drawn twice
|
|
1111
|
+
// counts once: a role radio wrapping its native input, a hidden input a
|
|
1112
|
+
// component library keeps beside its role radio, or one group rendered for two
|
|
1113
|
+
// layouts, whose copies share the names shoppers read but not their ids. An
|
|
1114
|
+
// option shoppers see no name for is told apart by its ids.
|
|
1115
|
+
export function describeCheckedPaymentChoice(): IPaymentChoice | undefined {
|
|
1116
|
+
const radios = Array.from(document.querySelectorAll(CHECKED_RADIO)).filter(
|
|
1117
|
+
radio =>
|
|
1118
|
+
isPicked(radio) &&
|
|
1119
|
+
radio.getAttribute('aria-hidden') !== 'true' &&
|
|
1120
|
+
!hasComposedAncestor(radio, '[data-mp-no-track]') &&
|
|
1121
|
+
isPaymentQuestion(radio)
|
|
1122
|
+
);
|
|
1123
|
+
const choices = radios
|
|
1124
|
+
.filter(
|
|
1125
|
+
radio =>
|
|
1126
|
+
!radios.some(other => other !== radio && radio.contains(other))
|
|
1127
|
+
)
|
|
1128
|
+
.map(radio => {
|
|
1129
|
+
const shown = distinctNames(shownOptionNames(radio));
|
|
1130
|
+
const names = optionNames(radio);
|
|
1131
|
+
return {
|
|
1132
|
+
radio,
|
|
1133
|
+
names,
|
|
1134
|
+
option: (shown.length ? shown : names).join('\n'),
|
|
1135
|
+
};
|
|
1136
|
+
});
|
|
1137
|
+
const options = new Set(choices.map(choice => choice.option));
|
|
1138
|
+
return options.size === 1 && choices[0].names.length
|
|
1139
|
+
? { radio: choices[0].radio, names: choices[0].names }
|
|
1140
|
+
: undefined;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
929
1143
|
// Read once the page has handled the click, which may have cancelled it.
|
|
930
1144
|
export function isPicked(radio: Element): boolean {
|
|
931
1145
|
return radio instanceof HTMLInputElement
|
|
@@ -3,6 +3,7 @@ import { isFunction } from '../utils';
|
|
|
3
3
|
import { buildButtonClickEvent, buildLinkClickEvent } from './buttonClickEvent';
|
|
4
4
|
import {
|
|
5
5
|
describeButtonClick,
|
|
6
|
+
describeCheckedPaymentChoice,
|
|
6
7
|
describePaymentChoice,
|
|
7
8
|
IButtonClick,
|
|
8
9
|
IPaymentChoice,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
CheckoutIntentSource,
|
|
15
16
|
classifyIntent,
|
|
16
17
|
classifyPaymentFrame,
|
|
18
|
+
namesWallet,
|
|
17
19
|
PaymentFrameFamily,
|
|
18
20
|
} from './checkoutIntent';
|
|
19
21
|
import { findControl, findRoleControl, isExcludedPath } from './controls';
|
|
@@ -390,6 +392,7 @@ export class ButtonTracker {
|
|
|
390
392
|
let source: CheckoutIntentSource | undefined;
|
|
391
393
|
let adds: true | undefined;
|
|
392
394
|
let choice: IPaymentChoice | undefined;
|
|
395
|
+
let checkedNames: string[] | undefined;
|
|
393
396
|
let family: PaymentFrameFamily | undefined;
|
|
394
397
|
let unconfirmedCommit = false;
|
|
395
398
|
if (layer === 'click') {
|
|
@@ -443,6 +446,19 @@ export class ButtonTracker {
|
|
|
443
446
|
choice = signalsKit
|
|
444
447
|
? describePaymentChoice(control, path)
|
|
445
448
|
: undefined;
|
|
449
|
+
// A confirm or checkout-step click picks no radio itself, so it
|
|
450
|
+
// carries the one the page holds checked as read here. It is not
|
|
451
|
+
// read again at delivery: the page's own handlers may clear or
|
|
452
|
+
// replace it by then. A wallet tap names its own method, and
|
|
453
|
+
// so does a confirm that names a wallet.
|
|
454
|
+
if (
|
|
455
|
+
!choice &&
|
|
456
|
+
(kind === 'commit' || kind === 'checkout_step') &&
|
|
457
|
+
!namesWallet(control, click)
|
|
458
|
+
) {
|
|
459
|
+
const checked = describeCheckedPaymentChoice();
|
|
460
|
+
checkedNames = checked ? checked.names : undefined;
|
|
461
|
+
}
|
|
446
462
|
}
|
|
447
463
|
this.lastClick = { t, excluded, kind, source };
|
|
448
464
|
} else if (layer === 'submit') {
|
|
@@ -503,6 +519,8 @@ export class ButtonTracker {
|
|
|
503
519
|
// with this pick.
|
|
504
520
|
if (choice && isPicked(choice.radio)) {
|
|
505
521
|
this.pickPayment(t, pathname, choice.names);
|
|
522
|
+
} else if (checkedNames) {
|
|
523
|
+
this.pickPayment(t, pathname, checkedNames);
|
|
506
524
|
} else if (family) {
|
|
507
525
|
this.pickPayment(t, pathname, undefined, family);
|
|
508
526
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
isWalletNamedControl,
|
|
8
8
|
vendorButtonKind,
|
|
9
9
|
} from './controls';
|
|
10
|
+
import { IButtonClick, TEST_ID_ATTRIBUTES } from './buttonDescription';
|
|
10
11
|
|
|
11
12
|
// Classifies a click, submit or frame focus as checkout intent for the Rokt kit.
|
|
12
13
|
// Reads only short control labels, never form field values, and stores nothing.
|
|
@@ -126,6 +127,9 @@ const walletBrands = new RegExp(
|
|
|
126
127
|
'^(?:' + WALLET_NAMES + ')(?: (?:checkout|check out|express(?: checkout)?|express pay|buy now|pay now|pay|now)' +
|
|
127
128
|
'| (?:logo|icon|mark|button)(?: (?:logo|icon|mark|button))*)?$'
|
|
128
129
|
);
|
|
130
|
+
// The wallets the Rokt kit reads from a click's label or identifiers, as words:
|
|
131
|
+
// "Place order with PayPal!" and "paypal-pay-button" both name PayPal.
|
|
132
|
+
const KIT_WALLET_WORDS = /(?:^| )(?:apple ?pay|google ?pay|g ?pay|android ?pay|venmo|pay ?pal|amazon ?pay|shop ?pay|klarna|after ?pay|clear ?pay|affirm|cash ?app|sezzle|samsung ?pay)(?= |$)/;
|
|
129
133
|
// The payment method a frame's focus picks, in the Rokt kit's family names.
|
|
130
134
|
export type PaymentFrameFamily =
|
|
131
135
|
| 'apple_pay'
|
|
@@ -393,6 +397,38 @@ export function classifyIntent(
|
|
|
393
397
|
return cart;
|
|
394
398
|
}
|
|
395
399
|
|
|
400
|
+
// The Rokt kit's own word split, so "PayPal!" and "paypal2" both read as
|
|
401
|
+
// "paypal" here as they do there.
|
|
402
|
+
function kitWords(value: string | null | undefined): string {
|
|
403
|
+
return (value || '')
|
|
404
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
405
|
+
.toLowerCase()
|
|
406
|
+
.replace(/[^a-z]+/g, ' ')
|
|
407
|
+
.trim();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// A confirm control may name its wallet, so "Place order with PayPal" and a
|
|
411
|
+
// "Place order" button with id paypal-pay-button are confirm clicks that still
|
|
412
|
+
// name their own method. A logged click is judged on the fields the kit reads
|
|
413
|
+
// a method from; a click that isn't logged, on the control's own label and
|
|
414
|
+
// identifiers.
|
|
415
|
+
export function namesWallet(control: Element, click?: IButtonClick): boolean {
|
|
416
|
+
const values = click
|
|
417
|
+
? [
|
|
418
|
+
click.label,
|
|
419
|
+
click.ariaLabel,
|
|
420
|
+
click.elementId,
|
|
421
|
+
click.elementName,
|
|
422
|
+
click.testId,
|
|
423
|
+
]
|
|
424
|
+
: labelSources(control).concat(
|
|
425
|
+
['id', 'name']
|
|
426
|
+
.concat(TEST_ID_ATTRIBUTES)
|
|
427
|
+
.map(attribute => control.getAttribute(attribute))
|
|
428
|
+
);
|
|
429
|
+
return values.some(value => KIT_WALLET_WORDS.test(kitWords(value)));
|
|
430
|
+
}
|
|
431
|
+
|
|
396
432
|
export function classifyIntentControl(
|
|
397
433
|
control: Element,
|
|
398
434
|
pathname: string
|