@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.
@@ -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&amp;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
- /** Lands click-tracking rows on template anchors by (link, text), falling back to every anchor of the link. */
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-count badges over matched anchors, outlines `activeKey`; returns a cleanup. */
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), falling back to every anchor of the link. */
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
- items.forEach(item => {
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
- const targets = exact.length > 0 ? exact : candidates;
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
- /** Draws click-count badges over matched anchors, outlines `activeKey`; returns a cleanup. */
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 badges = matches.map(match => createBadge(document, match, options));
96
- const outlined = matches.filter(match => options.activeKey != null && match.items.some(item => item.key === options.activeKey)).map(match => [match.anchor, match.anchor.style.outline]);
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
- matches.forEach((match, index) => {
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 + (view?.scrollY ?? 0)}px`;
108
- badge.style.left = `${left + (view?.scrollX ?? 0)}px`;
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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/dumb-components",
3
- "version": "1.1.222",
3
+ "version": "1.1.224",
4
4
  "description": "React components to build Pushwoosh products",
5
5
  "main": "index.js",
6
6
  "module": "index.js",