@pushwoosh/dumb-components 1.1.223 → 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,4 +1,4 @@
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
4
  const SHOP = 'https://shop.spartak.com/';
@@ -135,4 +135,34 @@ describe('decorateClickMap', () => {
135
135
  expect(frameDocument.querySelectorAll(`[${CLICK_MAP_BADGE_ATTRIBUTE}]`)).toHaveLength(0);
136
136
  expect(frameDocument.querySelector('a strong')?.parentElement?.style.outline).toBe('');
137
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
+ });
138
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;
@@ -21,7 +22,9 @@ export type EmailClickMapDecorateOptions = {
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`. */
23
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";
24
27
  /** Lands click-tracking rows on template anchors by stamped `linkId`, else by (link, text), else every anchor of the link. */
25
28
  export declare function matchClickMapItems(document: Document, items: EmailClickMapItem[]): EmailClickMapMatchResult;
26
- /** 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. */
27
30
  export declare function decorateClickMap(document: Document, options: EmailClickMapDecorateOptions): () => void;
@@ -2,6 +2,10 @@
2
2
  export const CLICK_MAP_BADGE_ATTRIBUTE = 'data-pw-click-map-badge';
3
3
  /** Stable anchor id stamped into the template html on save; the click carries it as `linkId`. */
4
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;
5
9
  const ACTIVE_COLOR = '#00b2ff';
6
10
  const IDLE_COLOR = 'rgba(36, 48, 66, 0.82)';
7
11
  const compactNumber = new Intl.NumberFormat('en', {
@@ -96,29 +100,64 @@ function createBadge(document, match, options) {
96
100
  });
97
101
  return badge;
98
102
  }
99
- /** 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. */
100
125
  export function decorateClickMap(document, options) {
101
126
  const view = document.defaultView;
127
+ const heatMode = options.mode === 'heat';
102
128
  const {
103
129
  matches,
104
130
  placement
105
131
  } = matchClickMapItems(document, options.items);
106
- const badges = matches.map(match => createBadge(document, match, options));
107
- 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]);
108
137
  const isInlineText = anchor => view?.getComputedStyle(anchor).display === 'inline' && !anchor.querySelector('img');
109
138
  const layout = () => {
110
139
  const maxRight = document.documentElement.clientWidth;
111
- 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) => {
112
150
  const badge = badges[index];
113
151
  const rect = anchorRect(match.anchor);
114
152
  const right = Math.min(rect.right, maxRight);
115
153
  const inline = isInlineText(match.anchor);
116
154
  const left = inline ? Math.min(right, maxRight - badge.offsetWidth) : right - badge.offsetWidth;
117
155
  const top = inline ? rect.top + rect.height / 2 : rect.top;
118
- badge.style.top = `${top + (view?.scrollY ?? 0)}px`;
119
- badge.style.left = `${left + (view?.scrollX ?? 0)}px`;
156
+ badge.style.top = `${top + scrollY}px`;
157
+ badge.style.left = `${left + scrollX}px`;
120
158
  });
121
159
  };
160
+ heats.forEach(heat => document.body.appendChild(heat));
122
161
  badges.forEach(badge => document.body.appendChild(badge));
123
162
  outlined.forEach(([anchor]) => {
124
163
  anchor.style.outline = `${2 * (options.scale ?? 1)}px solid ${ACTIVE_COLOR}`;
@@ -131,6 +170,7 @@ export function decorateClickMap(document, options) {
131
170
  return () => {
132
171
  observer?.disconnect();
133
172
  view?.removeEventListener('resize', layout);
173
+ heats.forEach(heat => heat.remove());
134
174
  badges.forEach(badge => badge.remove());
135
175
  outlined.forEach(([anchor, outline]) => {
136
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';
@@ -19,9 +19,12 @@ export type EmailClickMapPlacement = {
19
19
  /** Keys with no matching anchor in the current template. */
20
20
  unplaced: string[];
21
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';
22
24
  /** Click counts to overlay on the rendered email, see {@link EmailTemplatePreview}. */
23
25
  export type EmailClickMapProps = {
24
26
  items: EmailClickMapItem[];
27
+ mode?: EmailClickMapMode;
25
28
  /** Key whose badge and anchor are highlighted. */
26
29
  activeKey?: string | null;
27
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.223",
3
+ "version": "1.1.224",
4
4
  "description": "React components to build Pushwoosh products",
5
5
  "main": "index.js",
6
6
  "module": "index.js",