@pushwoosh/dumb-components 1.1.220 → 1.1.222

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.
Files changed (92) hide show
  1. package/EmailTemplatePreview/EmailTemplatePreview.d.ts +3 -1
  2. package/EmailTemplatePreview/EmailTemplatePreview.js +30 -7
  3. package/EmailTemplatePreview/__tests__/clickMap.test.d.ts +1 -0
  4. package/EmailTemplatePreview/__tests__/clickMap.test.js +107 -0
  5. package/EmailTemplatePreview/clickMap.d.ts +25 -0
  6. package/EmailTemplatePreview/clickMap.js +128 -0
  7. package/EmailTemplatePreview/index.d.ts +2 -0
  8. package/EmailTemplatePreview/index.js +2 -1
  9. package/EmailTemplatePreview/types.d.ts +33 -0
  10. package/InboxPreview/InboxPreview.d.ts +13 -0
  11. package/InboxPreview/InboxPreview.js +82 -0
  12. package/InboxPreview/__tests__/helpers.test.d.ts +1 -0
  13. package/InboxPreview/__tests__/helpers.test.js +201 -0
  14. package/InboxPreview/components/InboxAndroidCell/InboxAndroidCell.d.ts +4 -0
  15. package/InboxPreview/components/InboxAndroidCell/InboxAndroidCell.js +70 -0
  16. package/InboxPreview/components/InboxAndroidCell/index.d.ts +1 -0
  17. package/InboxPreview/components/InboxAndroidCell/index.js +1 -0
  18. package/InboxPreview/components/InboxCellAvatar/InboxCellAvatar.d.ts +4 -0
  19. package/InboxPreview/components/InboxCellAvatar/InboxCellAvatar.js +48 -0
  20. package/InboxPreview/components/InboxCellAvatar/index.d.ts +1 -0
  21. package/InboxPreview/components/InboxCellAvatar/index.js +1 -0
  22. package/InboxPreview/components/InboxCellAvatar/styles.d.ts +1 -0
  23. package/InboxPreview/components/InboxCellAvatar/styles.js +5 -0
  24. package/InboxPreview/components/InboxCellAvatar/types.d.ts +10 -0
  25. package/InboxPreview/components/InboxCellAvatar/types.js +1 -0
  26. package/InboxPreview/components/InboxCellCarousel/InboxCellCarousel.d.ts +4 -0
  27. package/InboxPreview/components/InboxCellCarousel/InboxCellCarousel.js +53 -0
  28. package/InboxPreview/components/InboxCellCarousel/index.d.ts +1 -0
  29. package/InboxPreview/components/InboxCellCarousel/index.js +1 -0
  30. package/InboxPreview/components/InboxCellCarousel/styles.d.ts +29 -0
  31. package/InboxPreview/components/InboxCellCarousel/styles.js +24 -0
  32. package/InboxPreview/components/InboxCellCarousel/types.d.ts +8 -0
  33. package/InboxPreview/components/InboxCellCarousel/types.js +1 -0
  34. package/InboxPreview/components/InboxCellMedia/InboxCellMedia.d.ts +4 -0
  35. package/InboxPreview/components/InboxCellMedia/InboxCellMedia.js +26 -0
  36. package/InboxPreview/components/InboxCellMedia/index.d.ts +1 -0
  37. package/InboxPreview/components/InboxCellMedia/index.js +1 -0
  38. package/InboxPreview/components/InboxCellMedia/types.d.ts +12 -0
  39. package/InboxPreview/components/InboxCellMedia/types.js +1 -0
  40. package/InboxPreview/components/InboxIOSCell/InboxIOSCell.d.ts +4 -0
  41. package/InboxPreview/components/InboxIOSCell/InboxIOSCell.js +125 -0
  42. package/InboxPreview/components/InboxIOSCell/index.d.ts +1 -0
  43. package/InboxPreview/components/InboxIOSCell/index.js +1 -0
  44. package/InboxPreview/components/InboxWebCell/InboxWebCell.d.ts +4 -0
  45. package/InboxPreview/components/InboxWebCell/InboxWebCell.js +83 -0
  46. package/InboxPreview/components/InboxWebCell/index.d.ts +1 -0
  47. package/InboxPreview/components/InboxWebCell/index.js +1 -0
  48. package/InboxPreview/constants.d.ts +14 -0
  49. package/InboxPreview/constants.js +30 -0
  50. package/InboxPreview/helpers.d.ts +29 -0
  51. package/InboxPreview/helpers.js +75 -0
  52. package/InboxPreview/index.d.ts +4 -0
  53. package/InboxPreview/index.js +3 -0
  54. package/InboxPreview/polyglot/index.d.ts +2 -0
  55. package/InboxPreview/polyglot/index.js +34 -0
  56. package/InboxPreview/polyglot/messages_ar.d.ts +3 -0
  57. package/InboxPreview/polyglot/messages_ar.js +4 -0
  58. package/InboxPreview/polyglot/messages_bn.d.ts +3 -0
  59. package/InboxPreview/polyglot/messages_bn.js +4 -0
  60. package/InboxPreview/polyglot/messages_de.d.ts +3 -0
  61. package/InboxPreview/polyglot/messages_de.js +4 -0
  62. package/InboxPreview/polyglot/messages_en.d.ts +3 -0
  63. package/InboxPreview/polyglot/messages_en.js +4 -0
  64. package/InboxPreview/polyglot/messages_es.d.ts +3 -0
  65. package/InboxPreview/polyglot/messages_es.js +4 -0
  66. package/InboxPreview/polyglot/messages_fr.d.ts +3 -0
  67. package/InboxPreview/polyglot/messages_fr.js +4 -0
  68. package/InboxPreview/polyglot/messages_he.d.ts +3 -0
  69. package/InboxPreview/polyglot/messages_he.js +4 -0
  70. package/InboxPreview/polyglot/messages_hi.d.ts +3 -0
  71. package/InboxPreview/polyglot/messages_hi.js +4 -0
  72. package/InboxPreview/polyglot/messages_ja.d.ts +3 -0
  73. package/InboxPreview/polyglot/messages_ja.js +4 -0
  74. package/InboxPreview/polyglot/messages_ko.d.ts +3 -0
  75. package/InboxPreview/polyglot/messages_ko.js +4 -0
  76. package/InboxPreview/polyglot/messages_pt.d.ts +3 -0
  77. package/InboxPreview/polyglot/messages_pt.js +4 -0
  78. package/InboxPreview/polyglot/messages_ru.d.ts +3 -0
  79. package/InboxPreview/polyglot/messages_ru.js +4 -0
  80. package/InboxPreview/polyglot/messages_th.d.ts +3 -0
  81. package/InboxPreview/polyglot/messages_th.js +4 -0
  82. package/InboxPreview/polyglot/messages_zh.d.ts +3 -0
  83. package/InboxPreview/polyglot/messages_zh.js +4 -0
  84. package/InboxPreview/polyglot/types.d.ts +5 -0
  85. package/InboxPreview/polyglot/types.js +1 -0
  86. package/InboxPreview/styles.d.ts +25 -0
  87. package/InboxPreview/styles.js +21 -0
  88. package/InboxPreview/types.d.ts +74 -0
  89. package/InboxPreview/types.js +1 -0
  90. package/index.d.ts +2 -1
  91. package/index.js +2 -1
  92. package/package.json +1 -1
@@ -2,9 +2,11 @@ import { type ReactElement } from 'react';
2
2
  import type { PreviewIframeProps } from './types';
3
3
  /**
4
4
  * Renders an email HTML template in a sandboxed iframe, resolving Liquid, localization
5
- * and placeholder values before display.
5
+ * and placeholder values before display. With `clickMap` it also draws click counts over
6
+ * the anchors they were recorded on, see {@link decorateClickMap}.
6
7
  *
7
8
  * @example
8
9
  * <EmailTemplatePreview template={html} localization={l10n} values={vars} scale={0.5} />
10
+ * <EmailTemplatePreview template={html} localization={l10n} values={vars} clickMap={{ items }} />
9
11
  */
10
12
  export declare function EmailTemplatePreview(props: PreviewIframeProps): ReactElement;
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Liquid } from 'liquidjs';
3
- import { useMemo, useState } from 'react';
3
+ import { useCallback, useMemo, useState } from 'react';
4
+ import { decorateClickMap } from './clickMap';
4
5
  import { replaceLocalization, replacePlaceholders } from './helpers';
5
6
  import { IframeStyled } from './styles';
7
+ import { SandboxedHtmlFrame } from '../SandboxedHtmlFrame';
6
8
  import { LIGHT_ISLAND_PROPS } from '../shared/lightIsland';
7
9
  const createLiquidInstance = () => {
8
10
  const liquidInstance = new Liquid();
@@ -21,10 +23,12 @@ const createLiquidInstance = () => {
21
23
  };
22
24
  /**
23
25
  * Renders an email HTML template in a sandboxed iframe, resolving Liquid, localization
24
- * and placeholder values before display.
26
+ * and placeholder values before display. With `clickMap` it also draws click counts over
27
+ * the anchors they were recorded on, see {@link decorateClickMap}.
25
28
  *
26
29
  * @example
27
30
  * <EmailTemplatePreview template={html} localization={l10n} values={vars} scale={0.5} />
31
+ * <EmailTemplatePreview template={html} localization={l10n} values={vars} clickMap={{ items }} />
28
32
  */
29
33
  export function EmailTemplatePreview(props) {
30
34
  const {
@@ -33,8 +37,14 @@ export function EmailTemplatePreview(props) {
33
37
  height,
34
38
  template,
35
39
  localization,
36
- values
40
+ values,
41
+ clickMap,
42
+ onReady
37
43
  } = props;
44
+ const clickMapItems = clickMap?.items;
45
+ const clickMapActiveKey = clickMap?.activeKey;
46
+ const clickMapOnSelect = clickMap?.onSelect;
47
+ const clickMapOnPlaced = clickMap?.onPlaced;
38
48
  const [liquidInstance] = useState(createLiquidInstance);
39
49
  const src = useMemo(() => {
40
50
  let withDynamicContent = template;
@@ -47,15 +57,28 @@ export function EmailTemplatePreview(props) {
47
57
  return withDynamicContent;
48
58
  }
49
59
  }, [template, localization, values, liquidInstance]);
60
+ const handleReady = useCallback(frameDocument => {
61
+ const cleanups = [onReady?.(frameDocument), clickMapItems && decorateClickMap(frameDocument, {
62
+ items: clickMapItems,
63
+ activeKey: clickMapActiveKey,
64
+ scale,
65
+ onSelect: clickMapOnSelect,
66
+ onPlaced: clickMapOnPlaced
67
+ })];
68
+ return () => {
69
+ cleanups.forEach(cleanup => cleanup?.());
70
+ };
71
+ }, [onReady, clickMapItems, clickMapActiveKey, clickMapOnSelect, clickMapOnPlaced, scale]);
50
72
  return _jsx(IframeStyled, {
51
73
  ...LIGHT_ISLAND_PROPS,
52
74
  "$scale": scale,
53
75
  "$width": width,
54
76
  "$height": height,
55
- children: _jsx("iframe", {
56
- srcDoc: src,
57
- sandbox: "allow-same-origin",
58
- title: "preview"
77
+ children: _jsx(SandboxedHtmlFrame, {
78
+ mode: "inspectable",
79
+ title: "preview",
80
+ html: src,
81
+ onReady: handleReady
59
82
  })
60
83
  });
61
84
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,107 @@
1
+ import { CLICK_MAP_BADGE_ATTRIBUTE, decorateClickMap, matchClickMapItems } from '../clickMap';
2
+ const PROMO = 'http://spartak.com/matchtowin';
3
+ const TICKETS = 'https://tickets.spartak.com/zones/18956';
4
+ const HTML = `
5
+ <a href="${PROMO}?utm_source=email&amp;utm_content=img"><img src="banner.png" alt=""></a>
6
+ <p>Подробности на <a href="${PROMO}?utm_source=email">странице акции</a>.</p>
7
+ <a href="${PROMO}?utm_source=email"><strong>Перейти на страницу акции</strong></a>
8
+ <a href="${TICKETS}?utm_source=email">Купить билеты</a>
9
+ <a href="${TICKETS}?utm_source=email">Купить билеты</a>
10
+ <a href="[% param.url_unsub %]">Отписаться</a>
11
+ `;
12
+ const item = (key, link, title, clicks) => ({
13
+ key,
14
+ link,
15
+ title,
16
+ clicks
17
+ });
18
+ function createDocument() {
19
+ const frameDocument = document.implementation.createHTMLDocument('preview');
20
+ frameDocument.body.innerHTML = HTML;
21
+ return frameDocument;
22
+ }
23
+ describe('matchClickMapItems', () => {
24
+ it('tells anchors with the same link apart by their text and lands image links on the text-less anchor', () => {
25
+ const frameDocument = createDocument();
26
+ const items = [item('button', PROMO, 'Перейти на страницу акции', 1631), item('text', PROMO, 'странице акции', 398), item('image', PROMO, '', 172)];
27
+ const {
28
+ matches,
29
+ placement
30
+ } = matchClickMapItems(frameDocument, items);
31
+ expect(placement).toEqual({
32
+ placed: ['button', 'text', 'image'],
33
+ pooled: [],
34
+ unplaced: []
35
+ });
36
+ expect(matches.map(({
37
+ anchor,
38
+ items: matched
39
+ }) => [anchor.textContent?.trim(), matched.map(row => row.key)])).toEqual([['Перейти на страницу акции', ['button']], ['странице акции', ['text']], ['', ['image']]]);
40
+ });
41
+ it('ignores the query string of both sides when matching links', () => {
42
+ const {
43
+ placement
44
+ } = matchClickMapItems(createDocument(), [item('text', `${PROMO}?utm_source=other&x=1`, 'странице акции', 1)]);
45
+ expect(placement.placed).toEqual(['text']);
46
+ });
47
+ it('pools a row over every anchor that shares link and text', () => {
48
+ const {
49
+ matches,
50
+ placement
51
+ } = matchClickMapItems(createDocument(), [item('tickets', TICKETS, 'Купить билеты', 258)]);
52
+ expect(placement).toEqual({
53
+ placed: [],
54
+ pooled: ['tickets'],
55
+ unplaced: []
56
+ });
57
+ expect(matches).toHaveLength(2);
58
+ });
59
+ it('falls back to every anchor of the link when the recorded text matches none of them', () => {
60
+ const {
61
+ matches,
62
+ placement
63
+ } = matchClickMapItems(createDocument(), [item('promo', PROMO, 'Use this template →', 10)]);
64
+ expect(placement.pooled).toEqual(['promo']);
65
+ expect(matches).toHaveLength(3);
66
+ });
67
+ it('reports rows whose link is not in the template as unplaced', () => {
68
+ const {
69
+ matches,
70
+ placement
71
+ } = matchClickMapItems(createDocument(), [item('gone', 'https://old.example.com/', 'x', 5)]);
72
+ expect(placement).toEqual({
73
+ placed: [],
74
+ pooled: [],
75
+ unplaced: ['gone']
76
+ });
77
+ expect(matches).toHaveLength(0);
78
+ });
79
+ });
80
+ describe('decorateClickMap', () => {
81
+ it('draws one badge per matched anchor, highlights the active row and cleans up after itself', () => {
82
+ const frameDocument = createDocument();
83
+ const onPlaced = jest.fn();
84
+ const onSelect = jest.fn();
85
+ const items = [item('button', PROMO, 'Перейти на страницу акции', 1631), item('image', PROMO, '', 172), item('gone', 'https://old.example.com/', 'x', 5)];
86
+ const cleanup = decorateClickMap(frameDocument, {
87
+ items,
88
+ activeKey: 'button',
89
+ onPlaced,
90
+ onSelect
91
+ });
92
+ const badges = [...frameDocument.querySelectorAll(`[${CLICK_MAP_BADGE_ATTRIBUTE}]`)];
93
+ expect(badges.map(badge => [badge.getAttribute(CLICK_MAP_BADGE_ATTRIBUTE), badge.textContent])).toEqual([['button', '1.6K'], ['image', '172']]);
94
+ expect(badges[0].style.backgroundColor).not.toBe(badges[1].style.backgroundColor);
95
+ expect(frameDocument.querySelector('a strong')?.parentElement?.style.outline).toContain('solid');
96
+ expect(onPlaced).toHaveBeenCalledWith({
97
+ placed: ['button', 'image'],
98
+ pooled: [],
99
+ unplaced: ['gone']
100
+ });
101
+ badges[1].click();
102
+ expect(onSelect).toHaveBeenCalledWith('image');
103
+ cleanup();
104
+ expect(frameDocument.querySelectorAll(`[${CLICK_MAP_BADGE_ATTRIBUTE}]`)).toHaveLength(0);
105
+ expect(frameDocument.querySelector('a strong')?.parentElement?.style.outline).toBe('');
106
+ });
107
+ });
@@ -0,0 +1,25 @@
1
+ import type { EmailClickMapItem, EmailClickMapPlacement } from './types';
2
+ /** An anchor of the rendered template together with the rows recorded on it. */
3
+ export type EmailClickMapMatch = {
4
+ anchor: HTMLAnchorElement;
5
+ items: EmailClickMapItem[];
6
+ };
7
+ /** Outcome of {@link matchClickMapItems}. */
8
+ export type EmailClickMapMatchResult = {
9
+ matches: EmailClickMapMatch[];
10
+ placement: EmailClickMapPlacement;
11
+ };
12
+ /** Options of {@link decorateClickMap}. */
13
+ export type EmailClickMapDecorateOptions = {
14
+ items: EmailClickMapItem[];
15
+ activeKey?: string | null;
16
+ scale?: number;
17
+ onSelect?: (key: string) => void;
18
+ onPlaced?: (placement: EmailClickMapPlacement) => void;
19
+ };
20
+ /** Attribute every badge carries, so tests and consumers can find them. */
21
+ 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
+ export declare function matchClickMapItems(document: Document, items: EmailClickMapItem[]): EmailClickMapMatchResult;
24
+ /** Draws click-count badges over matched anchors, outlines `activeKey`; returns a cleanup. */
25
+ export declare function decorateClickMap(document: Document, options: EmailClickMapDecorateOptions): () => void;
@@ -0,0 +1,128 @@
1
+ /** Attribute every badge carries, so tests and consumers can find them. */
2
+ export const CLICK_MAP_BADGE_ATTRIBUTE = 'data-pw-click-map-badge';
3
+ const ACTIVE_COLOR = '#00b2ff';
4
+ const IDLE_COLOR = 'rgba(36, 48, 66, 0.82)';
5
+ const compactNumber = new Intl.NumberFormat('en', {
6
+ notation: 'compact',
7
+ maximumFractionDigits: 1
8
+ });
9
+ const stripQuery = url => url.split('?')[0].trim();
10
+ const normalizeText = text => text.replace(/\s+/g, ' ').trim();
11
+ const anchorText = anchor => normalizeText(anchor.textContent ?? '');
12
+ 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. */
14
+ export function matchClickMapItems(document, items) {
15
+ const anchorsByLink = new Map();
16
+ document.querySelectorAll('a[href]').forEach(anchor => {
17
+ const link = anchorLink(anchor);
18
+ if (link) {
19
+ anchorsByLink.set(link, [...(anchorsByLink.get(link) ?? []), anchor]);
20
+ }
21
+ });
22
+ const itemsByAnchor = new Map();
23
+ const placement = {
24
+ placed: [],
25
+ pooled: [],
26
+ unplaced: []
27
+ };
28
+ items.forEach(item => {
29
+ const candidates = anchorsByLink.get(stripQuery(item.link)) ?? [];
30
+ const title = normalizeText(item.title);
31
+ const exact = candidates.filter(anchor => anchorText(anchor) === title);
32
+ const targets = exact.length > 0 ? exact : candidates;
33
+ if (targets.length === 0) {
34
+ placement.unplaced.push(item.key);
35
+ return;
36
+ }
37
+ (targets.length === 1 ? placement.placed : placement.pooled).push(item.key);
38
+ targets.forEach(anchor => {
39
+ itemsByAnchor.set(anchor, [...(itemsByAnchor.get(anchor) ?? []), item]);
40
+ });
41
+ });
42
+ return {
43
+ matches: [...itemsByAnchor].map(([anchor, matched]) => ({
44
+ anchor,
45
+ items: matched
46
+ })),
47
+ placement
48
+ };
49
+ }
50
+ function anchorRect(anchor) {
51
+ const rects = [anchor, ...anchor.querySelectorAll('img')].map(node => node.getBoundingClientRect());
52
+ const top = Math.min(...rects.map(rect => rect.top));
53
+ const left = Math.min(...rects.map(rect => rect.left));
54
+ const right = Math.max(...rects.map(rect => rect.right));
55
+ const bottom = Math.max(...rects.map(rect => rect.bottom));
56
+ return new DOMRect(left, top, right - left, bottom - top);
57
+ }
58
+ function createBadge(document, match, options) {
59
+ const scale = options.scale ?? 1;
60
+ const keys = match.items.map(item => item.key);
61
+ const isActive = options.activeKey != null && keys.includes(options.activeKey);
62
+ const clicks = match.items.reduce((sum, item) => sum + item.clicks, 0);
63
+ const badge = document.createElement('span');
64
+ badge.setAttribute(CLICK_MAP_BADGE_ATTRIBUTE, keys.join(' '));
65
+ badge.textContent = compactNumber.format(clicks);
66
+ badge.title = match.items.map(item => `${item.title || item.link}: ${item.clicks}`).join('\n');
67
+ Object.assign(badge.style, {
68
+ position: 'absolute',
69
+ zIndex: '2147483647',
70
+ transform: 'translateY(-50%)',
71
+ padding: `${3 * scale}px ${8 * scale}px`,
72
+ borderRadius: '999px',
73
+ backgroundColor: isActive ? ACTIVE_COLOR : IDLE_COLOR,
74
+ color: '#fff',
75
+ font: `600 ${12 * scale}px/1 sans-serif`,
76
+ whiteSpace: 'nowrap',
77
+ boxShadow: '0 1px 3px rgba(0, 0, 0, 0.3)',
78
+ cursor: options.onSelect ? 'pointer' : 'default',
79
+ userSelect: 'none'
80
+ });
81
+ badge.addEventListener('click', event => {
82
+ event.preventDefault();
83
+ event.stopPropagation();
84
+ options.onSelect?.(keys[0]);
85
+ });
86
+ return badge;
87
+ }
88
+ /** Draws click-count badges over matched anchors, outlines `activeKey`; returns a cleanup. */
89
+ export function decorateClickMap(document, options) {
90
+ const view = document.defaultView;
91
+ const {
92
+ matches,
93
+ placement
94
+ } = 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]);
97
+ const isInlineText = anchor => view?.getComputedStyle(anchor).display === 'inline' && !anchor.querySelector('img');
98
+ const layout = () => {
99
+ const maxRight = document.documentElement.clientWidth;
100
+ matches.forEach((match, index) => {
101
+ const badge = badges[index];
102
+ const rect = anchorRect(match.anchor);
103
+ const right = Math.min(rect.right, maxRight);
104
+ const inline = isInlineText(match.anchor);
105
+ const left = inline ? Math.min(right, maxRight - badge.offsetWidth) : right - badge.offsetWidth;
106
+ 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`;
109
+ });
110
+ };
111
+ badges.forEach(badge => document.body.appendChild(badge));
112
+ outlined.forEach(([anchor]) => {
113
+ anchor.style.outline = `${2 * (options.scale ?? 1)}px solid ${ACTIVE_COLOR}`;
114
+ });
115
+ layout();
116
+ options.onPlaced?.(placement);
117
+ const observer = view && 'ResizeObserver' in view ? new view.ResizeObserver(layout) : undefined;
118
+ observer?.observe(document.documentElement);
119
+ view?.addEventListener('resize', layout);
120
+ return () => {
121
+ observer?.disconnect();
122
+ view?.removeEventListener('resize', layout);
123
+ badges.forEach(badge => badge.remove());
124
+ outlined.forEach(([anchor, outline]) => {
125
+ anchor.style.outline = outline;
126
+ });
127
+ };
128
+ }
@@ -1,3 +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';
@@ -1,3 +1,4 @@
1
1
  export { EmailTemplatePreview } from './EmailTemplatePreview';
2
2
  export { NoEmailTemplatePreview } from './components/NoEmailTemplatePreview';
3
- export { replaceLocalization, replacePlaceholders } from './helpers';
3
+ export { replaceLocalization, replacePlaceholders } from './helpers';
4
+ export { matchClickMapItems, decorateClickMap, CLICK_MAP_BADGE_ATTRIBUTE } from './clickMap';
@@ -1,3 +1,32 @@
1
+ /** One click-tracking row to be drawn over the anchor it was recorded on. */
2
+ export type EmailClickMapItem = {
3
+ /** Row identity, passed back through {@link EmailClickMapProps.onSelect}. */
4
+ key: string;
5
+ /** Destination URL as recorded by click tracking; the query string is ignored when matching. */
6
+ link: string;
7
+ /** Anchor text recorded with the click; empty for image links. */
8
+ title: string;
9
+ clicks: number;
10
+ };
11
+ /** How the rows of {@link EmailClickMapProps.items} landed on the rendered template. */
12
+ export type EmailClickMapPlacement = {
13
+ /** Keys drawn on exactly one anchor. */
14
+ placed: string[];
15
+ /** Keys drawn on several anchors that share link and text, so the count repeats. */
16
+ pooled: string[];
17
+ /** Keys with no matching anchor in the current template. */
18
+ unplaced: string[];
19
+ };
20
+ /** Click counts to overlay on the rendered email, see {@link EmailTemplatePreview}. */
21
+ export type EmailClickMapProps = {
22
+ items: EmailClickMapItem[];
23
+ /** Key whose badge and anchor are highlighted. */
24
+ activeKey?: string | null;
25
+ /** Called with the key of the badge the user clicked. */
26
+ onSelect?: (key: string) => void;
27
+ /** Called after every layout with the placement outcome. */
28
+ onPlaced?: (placement: EmailClickMapPlacement) => void;
29
+ };
1
30
  /** Props of {@link EmailTemplatePreview}. */
2
31
  export type PreviewIframeProps = {
3
32
  /** CSS `transform: scale()` factor applied to the rendered email. */
@@ -12,4 +41,8 @@ export type PreviewIframeProps = {
12
41
  localization: Record<string, string>;
13
42
  /** Placeholder values resolved by {@link replacePlaceholders}. */
14
43
  values: Record<string, any>;
44
+ /** Click counts drawn over the anchors of the rendered template. */
45
+ clickMap?: EmailClickMapProps;
46
+ /** Called with the frame document once rendered; may return a cleanup run before the next call. */
47
+ onReady?: (document: Document) => void | (() => void);
15
48
  };
@@ -0,0 +1,13 @@
1
+ import { type ReactElement } from 'react';
2
+ import type { InboxPreviewProps } from './types';
3
+ /**
4
+ * Renders an Inbox cell the way the SDK of the chosen platform draws it
5
+ * (`platform`: iOS card, Android flat list row, or Web widget card), with the
6
+ * SDK degradation rules: a layout that cannot render falls back to classic and
7
+ * a carousel shows at most five valid slides. Content without a single filled
8
+ * field renders the dashed placeholder of the requested layout.
9
+ *
10
+ * @example
11
+ * <InboxPreview platform="ios" localizedContent={preset.localizedContent} language="en" />
12
+ */
13
+ export declare function InboxPreview(props: InboxPreviewProps): ReactElement;
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Color, ShapeRadius } from '@pushwoosh/kit-constants';
3
+ import { Vertical } from '@pushwoosh/kit-helpers';
4
+ import { InboxAndroidCell } from './components/InboxAndroidCell';
5
+ import { InboxIOSCell } from './components/InboxIOSCell';
6
+ import { InboxWebCell } from './components/InboxWebCell';
7
+ import { INBOX_PREVIEW_MAX_WIDTH, INBOX_PREVIEW_MIN_WIDTH, INBOX_PREVIEW_SKELETON_HEIGHT } from './constants';
8
+ import { getEmptyLayout, getPreviewContent, getPreviewTexts, isEmptyContent, resolveInboxLayout } from './helpers';
9
+ import { Lang } from './polyglot';
10
+ import { RichMessageViewer } from '../RichMessageEditor';
11
+ import { LIGHT_ISLAND_PROPS } from '../shared/lightIsland';
12
+ const CELL_BY_PLATFORM = {
13
+ ios: InboxIOSCell,
14
+ android: InboxAndroidCell,
15
+ web: InboxWebCell
16
+ };
17
+ /**
18
+ * Renders an Inbox cell the way the SDK of the chosen platform draws it
19
+ * (`platform`: iOS card, Android flat list row, or Web widget card), with the
20
+ * SDK degradation rules: a layout that cannot render falls back to classic and
21
+ * a carousel shows at most five valid slides. Content without a single filled
22
+ * field renders the dashed placeholder of the requested layout.
23
+ *
24
+ * @example
25
+ * <InboxPreview platform="ios" localizedContent={preset.localizedContent} language="en" />
26
+ */
27
+ export function InboxPreview(props) {
28
+ const {
29
+ platform = 'ios',
30
+ content,
31
+ localizedContent,
32
+ language,
33
+ dateLabel = '12:12',
34
+ unread = true,
35
+ isLoading = false,
36
+ emptyTitle = _jsx(Lang, {
37
+ message: "DUMB.INBOX_PREVIEW.EMPTY_TITLE"
38
+ }),
39
+ emptyDescription = _jsx(Lang, {
40
+ message: "DUMB.INBOX_PREVIEW.EMPTY_DESCRIPTION"
41
+ }),
42
+ width = '100%'
43
+ } = props;
44
+ if (isLoading) {
45
+ return _jsx(Vertical, {
46
+ ...LIGHT_ISLAND_PROPS,
47
+ "$bgColor": Color.SOFT_LIGHT,
48
+ "$borderRadius": ShapeRadius.SECTION,
49
+ "$maxWidth": INBOX_PREVIEW_MAX_WIDTH,
50
+ "$minHeight": INBOX_PREVIEW_SKELETON_HEIGHT,
51
+ "$minWidth": INBOX_PREVIEW_MIN_WIDTH,
52
+ "$width": width
53
+ });
54
+ }
55
+ const picked = getPreviewContent(content, localizedContent, language) || {};
56
+ const isEmpty = isEmptyContent(picked);
57
+ const layout = isEmpty ? getEmptyLayout(picked) : resolveInboxLayout(picked);
58
+ const texts = getPreviewTexts(picked, layout);
59
+ const Cell = CELL_BY_PLATFORM[platform];
60
+ return _jsx(Vertical, {
61
+ ...LIGHT_ISLAND_PROPS,
62
+ "$maxWidth": INBOX_PREVIEW_MAX_WIDTH,
63
+ "$minWidth": INBOX_PREVIEW_MIN_WIDTH,
64
+ "$position": "relative",
65
+ "$width": width,
66
+ children: _jsx(Cell, {
67
+ body: isEmpty ? emptyDescription : texts.body && _jsx(RichMessageViewer, {
68
+ flat: true,
69
+ value: texts.body
70
+ }),
71
+ content: picked,
72
+ dateLabel: dateLabel,
73
+ isEmpty: isEmpty,
74
+ layout: layout,
75
+ title: isEmpty ? emptyTitle : _jsx(RichMessageViewer, {
76
+ flat: true,
77
+ value: texts.title
78
+ }),
79
+ unread: unread
80
+ })
81
+ });
82
+ }
@@ -0,0 +1 @@
1
+ export {};