@pushwoosh/dumb-components 1.1.222 → 1.1.224
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/EmailTemplatePreview/EmailTemplatePreview.js +3 -1
- package/EmailTemplatePreview/__tests__/clickMap.test.js +64 -3
- package/EmailTemplatePreview/clickMap.d.ts +8 -3
- package/EmailTemplatePreview/clickMap.js +60 -9
- package/EmailTemplatePreview/index.d.ts +2 -2
- package/EmailTemplatePreview/index.js +1 -1
- package/EmailTemplatePreview/types.d.ts +5 -0
- package/package.json +1 -1
|
@@ -42,6 +42,7 @@ export function EmailTemplatePreview(props) {
|
|
|
42
42
|
onReady
|
|
43
43
|
} = props;
|
|
44
44
|
const clickMapItems = clickMap?.items;
|
|
45
|
+
const clickMapMode = clickMap?.mode;
|
|
45
46
|
const clickMapActiveKey = clickMap?.activeKey;
|
|
46
47
|
const clickMapOnSelect = clickMap?.onSelect;
|
|
47
48
|
const clickMapOnPlaced = clickMap?.onPlaced;
|
|
@@ -60,6 +61,7 @@ export function EmailTemplatePreview(props) {
|
|
|
60
61
|
const handleReady = useCallback(frameDocument => {
|
|
61
62
|
const cleanups = [onReady?.(frameDocument), clickMapItems && decorateClickMap(frameDocument, {
|
|
62
63
|
items: clickMapItems,
|
|
64
|
+
mode: clickMapMode,
|
|
63
65
|
activeKey: clickMapActiveKey,
|
|
64
66
|
scale,
|
|
65
67
|
onSelect: clickMapOnSelect,
|
|
@@ -68,7 +70,7 @@ export function EmailTemplatePreview(props) {
|
|
|
68
70
|
return () => {
|
|
69
71
|
cleanups.forEach(cleanup => cleanup?.());
|
|
70
72
|
};
|
|
71
|
-
}, [onReady, clickMapItems, clickMapActiveKey, clickMapOnSelect, clickMapOnPlaced, scale]);
|
|
73
|
+
}, [onReady, clickMapItems, clickMapMode, clickMapActiveKey, clickMapOnSelect, clickMapOnPlaced, scale]);
|
|
72
74
|
return _jsx(IframeStyled, {
|
|
73
75
|
...LIGHT_ISLAND_PROPS,
|
|
74
76
|
"$scale": scale,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { CLICK_MAP_BADGE_ATTRIBUTE, decorateClickMap, matchClickMapItems } from '../clickMap';
|
|
1
|
+
import { CLICK_MAP_BADGE_ATTRIBUTE, CLICK_MAP_HEAT_ATTRIBUTE, decorateClickMap, matchClickMapItems } from '../clickMap';
|
|
2
2
|
const PROMO = 'http://spartak.com/matchtowin';
|
|
3
3
|
const TICKETS = 'https://tickets.spartak.com/zones/18956';
|
|
4
|
+
const SHOP = 'https://shop.spartak.com/';
|
|
4
5
|
const HTML = `
|
|
5
6
|
<a href="${PROMO}?utm_source=email&utm_content=img"><img src="banner.png" alt=""></a>
|
|
6
7
|
<p>Подробности на <a href="${PROMO}?utm_source=email">странице акции</a>.</p>
|
|
@@ -8,12 +9,15 @@ const HTML = `
|
|
|
8
9
|
<a href="${TICKETS}?utm_source=email">Купить билеты</a>
|
|
9
10
|
<a href="${TICKETS}?utm_source=email">Купить билеты</a>
|
|
10
11
|
<a href="[% param.url_unsub %]">Отписаться</a>
|
|
12
|
+
<a data-pw-lid="aaaa11112222" href="${SHOP}">В магазин</a>
|
|
13
|
+
<a data-pw-lid="bbbb33334444" href="${SHOP}">В магазин</a>
|
|
11
14
|
`;
|
|
12
|
-
const item = (key, link, title, clicks) => ({
|
|
15
|
+
const item = (key, link, title, clicks, linkId) => ({
|
|
13
16
|
key,
|
|
14
17
|
link,
|
|
15
18
|
title,
|
|
16
|
-
clicks
|
|
19
|
+
clicks,
|
|
20
|
+
linkId
|
|
17
21
|
});
|
|
18
22
|
function createDocument() {
|
|
19
23
|
const frameDocument = document.implementation.createHTMLDocument('preview');
|
|
@@ -64,6 +68,33 @@ describe('matchClickMapItems', () => {
|
|
|
64
68
|
expect(placement.pooled).toEqual(['promo']);
|
|
65
69
|
expect(matches).toHaveLength(3);
|
|
66
70
|
});
|
|
71
|
+
it('tells identical anchors apart by the stamped link id', () => {
|
|
72
|
+
const {
|
|
73
|
+
matches,
|
|
74
|
+
placement
|
|
75
|
+
} = matchClickMapItems(createDocument(), [item('top', SHOP, 'В магазин', 40, 'aaaa11112222'), item('bottom', SHOP, 'В магазин', 9, 'bbbb33334444')]);
|
|
76
|
+
expect(placement).toEqual({
|
|
77
|
+
placed: ['top', 'bottom'],
|
|
78
|
+
pooled: [],
|
|
79
|
+
unplaced: []
|
|
80
|
+
});
|
|
81
|
+
expect(matches.map(({
|
|
82
|
+
anchor,
|
|
83
|
+
items: matched
|
|
84
|
+
}) => [anchor.getAttribute('data-pw-lid'), matched.map(row => row.key)])).toEqual([['aaaa11112222', ['top']], ['bbbb33334444', ['bottom']]]);
|
|
85
|
+
});
|
|
86
|
+
it('falls back to link and text when the template no longer carries the recorded link id', () => {
|
|
87
|
+
const {
|
|
88
|
+
matches,
|
|
89
|
+
placement
|
|
90
|
+
} = matchClickMapItems(createDocument(), [item('old', SHOP, 'В магазин', 3, 'cccc55556666'), item('unstamped', PROMO, 'странице акции', 1)]);
|
|
91
|
+
expect(placement).toEqual({
|
|
92
|
+
placed: ['unstamped'],
|
|
93
|
+
pooled: ['old'],
|
|
94
|
+
unplaced: []
|
|
95
|
+
});
|
|
96
|
+
expect(matches).toHaveLength(3);
|
|
97
|
+
});
|
|
67
98
|
it('reports rows whose link is not in the template as unplaced', () => {
|
|
68
99
|
const {
|
|
69
100
|
matches,
|
|
@@ -104,4 +135,34 @@ describe('decorateClickMap', () => {
|
|
|
104
135
|
expect(frameDocument.querySelectorAll(`[${CLICK_MAP_BADGE_ATTRIBUTE}]`)).toHaveLength(0);
|
|
105
136
|
expect(frameDocument.querySelector('a strong')?.parentElement?.style.outline).toBe('');
|
|
106
137
|
});
|
|
138
|
+
});
|
|
139
|
+
describe('decorateClickMap in heat mode', () => {
|
|
140
|
+
it('tints every matched anchor by its share of clicks and counts only the active one', () => {
|
|
141
|
+
const frameDocument = createDocument();
|
|
142
|
+
const items = [item('button', PROMO, 'Перейти на страницу акции', 1000), item('image', PROMO, '', 250), item('gone', 'https://old.example.com/', 'x', 5)];
|
|
143
|
+
const cleanup = decorateClickMap(frameDocument, {
|
|
144
|
+
items,
|
|
145
|
+
mode: 'heat',
|
|
146
|
+
activeKey: 'image'
|
|
147
|
+
});
|
|
148
|
+
const heats = [...frameDocument.querySelectorAll(`[${CLICK_MAP_HEAT_ATTRIBUTE}]`)];
|
|
149
|
+
const badges = [...frameDocument.querySelectorAll(`[${CLICK_MAP_BADGE_ATTRIBUTE}]`)];
|
|
150
|
+
const alpha = heat => Number(heat.style.backgroundColor.match(/[\d.]+\)$/)?.[0].slice(0, -1));
|
|
151
|
+
expect(heats.map(heat => heat.getAttribute(CLICK_MAP_HEAT_ATTRIBUTE))).toEqual(['button', 'image']);
|
|
152
|
+
expect(alpha(heats[0])).toBeGreaterThan(alpha(heats[1]));
|
|
153
|
+
expect(badges.map(badge => [badge.getAttribute(CLICK_MAP_BADGE_ATTRIBUTE), badge.textContent])).toEqual([['image', '250']]);
|
|
154
|
+
cleanup();
|
|
155
|
+
expect(frameDocument.querySelectorAll(`[${CLICK_MAP_HEAT_ATTRIBUTE}], [${CLICK_MAP_BADGE_ATTRIBUTE}]`)).toHaveLength(0);
|
|
156
|
+
});
|
|
157
|
+
it('selects a row by clicking its heat overlay', () => {
|
|
158
|
+
const frameDocument = createDocument();
|
|
159
|
+
const onSelect = jest.fn();
|
|
160
|
+
decorateClickMap(frameDocument, {
|
|
161
|
+
items: [item('text', PROMO, 'странице акции', 1)],
|
|
162
|
+
mode: 'heat',
|
|
163
|
+
onSelect
|
|
164
|
+
});
|
|
165
|
+
frameDocument.querySelector(`[${CLICK_MAP_HEAT_ATTRIBUTE}]`)?.click();
|
|
166
|
+
expect(onSelect).toHaveBeenCalledWith('text');
|
|
167
|
+
});
|
|
107
168
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EmailClickMapItem, EmailClickMapPlacement } from './types';
|
|
1
|
+
import type { EmailClickMapItem, EmailClickMapMode, EmailClickMapPlacement } from './types';
|
|
2
2
|
/** An anchor of the rendered template together with the rows recorded on it. */
|
|
3
3
|
export type EmailClickMapMatch = {
|
|
4
4
|
anchor: HTMLAnchorElement;
|
|
@@ -12,6 +12,7 @@ export type EmailClickMapMatchResult = {
|
|
|
12
12
|
/** Options of {@link decorateClickMap}. */
|
|
13
13
|
export type EmailClickMapDecorateOptions = {
|
|
14
14
|
items: EmailClickMapItem[];
|
|
15
|
+
mode?: EmailClickMapMode;
|
|
15
16
|
activeKey?: string | null;
|
|
16
17
|
scale?: number;
|
|
17
18
|
onSelect?: (key: string) => void;
|
|
@@ -19,7 +20,11 @@ export type EmailClickMapDecorateOptions = {
|
|
|
19
20
|
};
|
|
20
21
|
/** Attribute every badge carries, so tests and consumers can find them. */
|
|
21
22
|
export declare const CLICK_MAP_BADGE_ATTRIBUTE = "data-pw-click-map-badge";
|
|
22
|
-
/**
|
|
23
|
+
/** Stable anchor id stamped into the template html on save; the click carries it as `linkId`. */
|
|
24
|
+
export declare const CLICK_MAP_LINK_ID_ATTRIBUTE = "data-pw-lid";
|
|
25
|
+
/** Attribute every heat overlay carries, so tests and consumers can find them. */
|
|
26
|
+
export declare const CLICK_MAP_HEAT_ATTRIBUTE = "data-pw-click-map-heat";
|
|
27
|
+
/** Lands click-tracking rows on template anchors by stamped `linkId`, else by (link, text), else every anchor of the link. */
|
|
23
28
|
export declare function matchClickMapItems(document: Document, items: EmailClickMapItem[]): EmailClickMapMatchResult;
|
|
24
|
-
/** Draws click
|
|
29
|
+
/** Draws the click map over matched anchors (counts in `badges` mode, tints in `heat`), outlines `activeKey`; returns a cleanup. */
|
|
25
30
|
export declare function decorateClickMap(document: Document, options: EmailClickMapDecorateOptions): () => void;
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** Attribute every badge carries, so tests and consumers can find them. */
|
|
2
2
|
export const CLICK_MAP_BADGE_ATTRIBUTE = 'data-pw-click-map-badge';
|
|
3
|
+
/** Stable anchor id stamped into the template html on save; the click carries it as `linkId`. */
|
|
4
|
+
export const CLICK_MAP_LINK_ID_ATTRIBUTE = 'data-pw-lid';
|
|
5
|
+
/** Attribute every heat overlay carries, so tests and consumers can find them. */
|
|
6
|
+
export const CLICK_MAP_HEAT_ATTRIBUTE = 'data-pw-click-map-heat';
|
|
7
|
+
const HEAT_MIN_ALPHA = 0.18;
|
|
8
|
+
const HEAT_MAX_ALPHA = 0.7;
|
|
3
9
|
const ACTIVE_COLOR = '#00b2ff';
|
|
4
10
|
const IDLE_COLOR = 'rgba(36, 48, 66, 0.82)';
|
|
5
11
|
const compactNumber = new Intl.NumberFormat('en', {
|
|
@@ -10,14 +16,19 @@ const stripQuery = url => url.split('?')[0].trim();
|
|
|
10
16
|
const normalizeText = text => text.replace(/\s+/g, ' ').trim();
|
|
11
17
|
const anchorText = anchor => normalizeText(anchor.textContent ?? '');
|
|
12
18
|
const anchorLink = anchor => stripQuery(anchor.getAttribute('href') ?? '');
|
|
13
|
-
/** Lands click-tracking rows on template anchors by (link, text),
|
|
19
|
+
/** Lands click-tracking rows on template anchors by stamped `linkId`, else by (link, text), else every anchor of the link. */
|
|
14
20
|
export function matchClickMapItems(document, items) {
|
|
15
21
|
const anchorsByLink = new Map();
|
|
22
|
+
const anchorsByLinkId = new Map();
|
|
16
23
|
document.querySelectorAll('a[href]').forEach(anchor => {
|
|
17
24
|
const link = anchorLink(anchor);
|
|
25
|
+
const linkId = anchor.getAttribute(CLICK_MAP_LINK_ID_ATTRIBUTE);
|
|
18
26
|
if (link) {
|
|
19
27
|
anchorsByLink.set(link, [...(anchorsByLink.get(link) ?? []), anchor]);
|
|
20
28
|
}
|
|
29
|
+
if (linkId) {
|
|
30
|
+
anchorsByLinkId.set(linkId, [...(anchorsByLinkId.get(linkId) ?? []), anchor]);
|
|
31
|
+
}
|
|
21
32
|
});
|
|
22
33
|
const itemsByAnchor = new Map();
|
|
23
34
|
const placement = {
|
|
@@ -25,11 +36,15 @@ export function matchClickMapItems(document, items) {
|
|
|
25
36
|
pooled: [],
|
|
26
37
|
unplaced: []
|
|
27
38
|
};
|
|
28
|
-
|
|
39
|
+
const targetsByLinkAndText = item => {
|
|
29
40
|
const candidates = anchorsByLink.get(stripQuery(item.link)) ?? [];
|
|
30
41
|
const title = normalizeText(item.title);
|
|
31
42
|
const exact = candidates.filter(anchor => anchorText(anchor) === title);
|
|
32
|
-
|
|
43
|
+
return exact.length > 0 ? exact : candidates;
|
|
44
|
+
};
|
|
45
|
+
items.forEach(item => {
|
|
46
|
+
const stamped = item.linkId ? anchorsByLinkId.get(item.linkId) : undefined;
|
|
47
|
+
const targets = stamped ?? targetsByLinkAndText(item);
|
|
33
48
|
if (targets.length === 0) {
|
|
34
49
|
placement.unplaced.push(item.key);
|
|
35
50
|
return;
|
|
@@ -85,29 +100,64 @@ function createBadge(document, match, options) {
|
|
|
85
100
|
});
|
|
86
101
|
return badge;
|
|
87
102
|
}
|
|
88
|
-
|
|
103
|
+
const matchClicks = match => match.items.reduce((sum, item) => sum + item.clicks, 0);
|
|
104
|
+
const isActiveMatch = (match, options) => options.activeKey != null && match.items.some(item => item.key === options.activeKey);
|
|
105
|
+
function createHeat(document, match, maxClicks, options) {
|
|
106
|
+
const share = maxClicks > 0 ? matchClicks(match) / maxClicks : 0;
|
|
107
|
+
const heat = document.createElement('span');
|
|
108
|
+
heat.setAttribute(CLICK_MAP_HEAT_ATTRIBUTE, match.items.map(item => item.key).join(' '));
|
|
109
|
+
heat.title = match.items.map(item => `${item.title || item.link}: ${item.clicks}`).join('\n');
|
|
110
|
+
Object.assign(heat.style, {
|
|
111
|
+
position: 'absolute',
|
|
112
|
+
zIndex: '2147483646',
|
|
113
|
+
borderRadius: `${4 * (options.scale ?? 1)}px`,
|
|
114
|
+
backgroundColor: `rgba(0, 178, 255, ${(HEAT_MIN_ALPHA + (HEAT_MAX_ALPHA - HEAT_MIN_ALPHA) * share).toFixed(2)})`,
|
|
115
|
+
cursor: options.onSelect ? 'pointer' : 'default'
|
|
116
|
+
});
|
|
117
|
+
heat.addEventListener('click', event => {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
event.stopPropagation();
|
|
120
|
+
options.onSelect?.(match.items[0].key);
|
|
121
|
+
});
|
|
122
|
+
return heat;
|
|
123
|
+
}
|
|
124
|
+
/** Draws the click map over matched anchors (counts in `badges` mode, tints in `heat`), outlines `activeKey`; returns a cleanup. */
|
|
89
125
|
export function decorateClickMap(document, options) {
|
|
90
126
|
const view = document.defaultView;
|
|
127
|
+
const heatMode = options.mode === 'heat';
|
|
91
128
|
const {
|
|
92
129
|
matches,
|
|
93
130
|
placement
|
|
94
131
|
} = matchClickMapItems(document, options.items);
|
|
95
|
-
const
|
|
96
|
-
const
|
|
132
|
+
const maxClicks = Math.max(0, ...matches.map(matchClicks));
|
|
133
|
+
const heats = heatMode ? matches.map(match => createHeat(document, match, maxClicks, options)) : [];
|
|
134
|
+
const badged = heatMode ? matches.filter(match => isActiveMatch(match, options)) : matches;
|
|
135
|
+
const badges = badged.map(match => createBadge(document, match, options));
|
|
136
|
+
const outlined = matches.filter(match => isActiveMatch(match, options)).map(match => [match.anchor, match.anchor.style.outline]);
|
|
97
137
|
const isInlineText = anchor => view?.getComputedStyle(anchor).display === 'inline' && !anchor.querySelector('img');
|
|
98
138
|
const layout = () => {
|
|
99
139
|
const maxRight = document.documentElement.clientWidth;
|
|
100
|
-
|
|
140
|
+
const scrollX = view?.scrollX ?? 0;
|
|
141
|
+
const scrollY = view?.scrollY ?? 0;
|
|
142
|
+
heats.forEach((heat, index) => {
|
|
143
|
+
const rect = anchorRect(matches[index].anchor);
|
|
144
|
+
heat.style.top = `${rect.top + scrollY}px`;
|
|
145
|
+
heat.style.left = `${rect.left + scrollX}px`;
|
|
146
|
+
heat.style.width = `${rect.width}px`;
|
|
147
|
+
heat.style.height = `${rect.height}px`;
|
|
148
|
+
});
|
|
149
|
+
badged.forEach((match, index) => {
|
|
101
150
|
const badge = badges[index];
|
|
102
151
|
const rect = anchorRect(match.anchor);
|
|
103
152
|
const right = Math.min(rect.right, maxRight);
|
|
104
153
|
const inline = isInlineText(match.anchor);
|
|
105
154
|
const left = inline ? Math.min(right, maxRight - badge.offsetWidth) : right - badge.offsetWidth;
|
|
106
155
|
const top = inline ? rect.top + rect.height / 2 : rect.top;
|
|
107
|
-
badge.style.top = `${top +
|
|
108
|
-
badge.style.left = `${left +
|
|
156
|
+
badge.style.top = `${top + scrollY}px`;
|
|
157
|
+
badge.style.left = `${left + scrollX}px`;
|
|
109
158
|
});
|
|
110
159
|
};
|
|
160
|
+
heats.forEach(heat => document.body.appendChild(heat));
|
|
111
161
|
badges.forEach(badge => document.body.appendChild(badge));
|
|
112
162
|
outlined.forEach(([anchor]) => {
|
|
113
163
|
anchor.style.outline = `${2 * (options.scale ?? 1)}px solid ${ACTIVE_COLOR}`;
|
|
@@ -120,6 +170,7 @@ export function decorateClickMap(document, options) {
|
|
|
120
170
|
return () => {
|
|
121
171
|
observer?.disconnect();
|
|
122
172
|
view?.removeEventListener('resize', layout);
|
|
173
|
+
heats.forEach(heat => heat.remove());
|
|
123
174
|
badges.forEach(badge => badge.remove());
|
|
124
175
|
outlined.forEach(([anchor, outline]) => {
|
|
125
176
|
anchor.style.outline = outline;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { EmailTemplatePreview } from './EmailTemplatePreview';
|
|
2
2
|
export { NoEmailTemplatePreview } from './components/NoEmailTemplatePreview';
|
|
3
3
|
export { replaceLocalization, replacePlaceholders } from './helpers';
|
|
4
|
-
export { matchClickMapItems, decorateClickMap, CLICK_MAP_BADGE_ATTRIBUTE, type EmailClickMapMatch, type EmailClickMapMatchResult, type EmailClickMapDecorateOptions, } from './clickMap';
|
|
5
|
-
export type { EmailClickMapItem, EmailClickMapPlacement, EmailClickMapProps, PreviewIframeProps, } from './types';
|
|
4
|
+
export { matchClickMapItems, decorateClickMap, CLICK_MAP_BADGE_ATTRIBUTE, CLICK_MAP_HEAT_ATTRIBUTE, CLICK_MAP_LINK_ID_ATTRIBUTE, type EmailClickMapMatch, type EmailClickMapMatchResult, type EmailClickMapDecorateOptions, } from './clickMap';
|
|
5
|
+
export type { EmailClickMapItem, EmailClickMapMode, EmailClickMapPlacement, EmailClickMapProps, PreviewIframeProps, } from './types';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { EmailTemplatePreview } from './EmailTemplatePreview';
|
|
2
2
|
export { NoEmailTemplatePreview } from './components/NoEmailTemplatePreview';
|
|
3
3
|
export { replaceLocalization, replacePlaceholders } from './helpers';
|
|
4
|
-
export { matchClickMapItems, decorateClickMap, CLICK_MAP_BADGE_ATTRIBUTE } from './clickMap';
|
|
4
|
+
export { matchClickMapItems, decorateClickMap, CLICK_MAP_BADGE_ATTRIBUTE, CLICK_MAP_HEAT_ATTRIBUTE, CLICK_MAP_LINK_ID_ATTRIBUTE } from './clickMap';
|
|
@@ -6,6 +6,8 @@ export type EmailClickMapItem = {
|
|
|
6
6
|
link: string;
|
|
7
7
|
/** Anchor text recorded with the click; empty for image links. */
|
|
8
8
|
title: string;
|
|
9
|
+
/** Stable anchor id (`data-pw-lid`) recorded with the click; matched before link and text when set. */
|
|
10
|
+
linkId?: string;
|
|
9
11
|
clicks: number;
|
|
10
12
|
};
|
|
11
13
|
/** How the rows of {@link EmailClickMapProps.items} landed on the rendered template. */
|
|
@@ -17,9 +19,12 @@ export type EmailClickMapPlacement = {
|
|
|
17
19
|
/** Keys with no matching anchor in the current template. */
|
|
18
20
|
unplaced: string[];
|
|
19
21
|
};
|
|
22
|
+
/** `badges` draws a count on every anchor; `heat` tints anchors by their share and shows the count on the active one only. */
|
|
23
|
+
export type EmailClickMapMode = 'badges' | 'heat';
|
|
20
24
|
/** Click counts to overlay on the rendered email, see {@link EmailTemplatePreview}. */
|
|
21
25
|
export type EmailClickMapProps = {
|
|
22
26
|
items: EmailClickMapItem[];
|
|
27
|
+
mode?: EmailClickMapMode;
|
|
23
28
|
/** Key whose badge and anchor are highlighted. */
|
|
24
29
|
activeKey?: string | null;
|
|
25
30
|
/** Called with the key of the badge the user clicked. */
|