@pushwoosh/dumb-components 1.1.219 → 1.1.221
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.d.ts +3 -1
- package/EmailTemplatePreview/EmailTemplatePreview.js +30 -7
- package/EmailTemplatePreview/__tests__/clickMap.test.d.ts +1 -0
- package/EmailTemplatePreview/__tests__/clickMap.test.js +107 -0
- package/EmailTemplatePreview/clickMap.d.ts +25 -0
- package/EmailTemplatePreview/clickMap.js +128 -0
- package/EmailTemplatePreview/index.d.ts +2 -0
- package/EmailTemplatePreview/index.js +2 -1
- package/EmailTemplatePreview/types.d.ts +33 -0
- package/RichMessageEditor/slate/DynamicContentElement.js +3 -1
- package/RichMessageEditor/slate/helpers.d.ts +1 -1
- package/RichMessageEditor/slate/helpers.js +3 -3
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/shared/dynamicContent/__tests__/token.test.d.ts +1 -0
- package/shared/dynamicContent/__tests__/token.test.js +32 -0
- package/shared/dynamicContent/index.d.ts +1 -1
- package/shared/dynamicContent/index.js +1 -1
- package/shared/dynamicContent/token.d.ts +2 -0
- package/shared/dynamicContent/token.js +10 -0
|
@@ -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(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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&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
|
};
|
|
@@ -3,6 +3,7 @@ import { Transforms } from 'slate';
|
|
|
3
3
|
import { useSelected, useSlateStatic } from 'slate-react';
|
|
4
4
|
import { findPath } from './helpers';
|
|
5
5
|
import { Chip } from '../../Chip';
|
|
6
|
+
import { isResolvableDc } from '../../shared/dynamicContent';
|
|
6
7
|
const DynamicContentElementComponent = ({
|
|
7
8
|
attributes,
|
|
8
9
|
element,
|
|
@@ -14,11 +15,12 @@ const DynamicContentElementComponent = ({
|
|
|
14
15
|
const {
|
|
15
16
|
tagName
|
|
16
17
|
} = element.dc;
|
|
18
|
+
const isResolvable = isResolvableDc(element.dc);
|
|
17
19
|
return _jsxs(Chip, {
|
|
18
20
|
...attributes,
|
|
19
21
|
as: "span",
|
|
20
22
|
contentEditable: false,
|
|
21
|
-
"$variant":
|
|
23
|
+
"$variant": isResolvable ? 'primary' : 'danger',
|
|
22
24
|
"$selected": selected,
|
|
23
25
|
"$onClose": event => {
|
|
24
26
|
event.preventDefault();
|
|
@@ -41,7 +41,7 @@ export declare function string2content(value: string): SlateEditorContent;
|
|
|
41
41
|
*/
|
|
42
42
|
export declare function content2string(state: SlateEditorContent): string;
|
|
43
43
|
/**
|
|
44
|
-
* Validates
|
|
44
|
+
* Validates that every dynamic content element is one the sender can substitute
|
|
45
45
|
* @param value - The Slate editor content to validate
|
|
46
46
|
* @returns True if all dynamic content elements are valid, false otherwise
|
|
47
47
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseDc, splitDynamicContent, stringifyDc } from '../../shared/dynamicContent';
|
|
1
|
+
import { isResolvableDc, parseDc, splitDynamicContent, stringifyDc } from '../../shared/dynamicContent';
|
|
2
2
|
/**
|
|
3
3
|
* Checks if a Slate node is a custom element
|
|
4
4
|
* @param x - The node to check
|
|
@@ -127,14 +127,14 @@ export function content2string(state) {
|
|
|
127
127
|
}).join('\n');
|
|
128
128
|
}
|
|
129
129
|
/**
|
|
130
|
-
* Validates
|
|
130
|
+
* Validates that every dynamic content element is one the sender can substitute
|
|
131
131
|
* @param value - The Slate editor content to validate
|
|
132
132
|
* @returns True if all dynamic content elements are valid, false otherwise
|
|
133
133
|
*/
|
|
134
134
|
export function validateContent(value) {
|
|
135
135
|
for (const el of value) {
|
|
136
136
|
if (isElement(el)) {
|
|
137
|
-
if (el.type === 'dc' && !el.dc
|
|
137
|
+
if (el.type === 'dc' && !isResolvableDc(el.dc)) {
|
|
138
138
|
return false;
|
|
139
139
|
}
|
|
140
140
|
if (!validateContent(el.children)) {
|
package/index.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ export { Tooltip, type TooltipPosition, TooltipTrigger } from './Tooltip';
|
|
|
32
32
|
export { Tab, TabBar, TabPanel, Tabs, type TabCode } from './Tabs';
|
|
33
33
|
export { Link, type LinkPropsWithoutAs, type LinkPropsWithAsTag, type LinkPropsWithAsComponent, } from './Link';
|
|
34
34
|
export { StatisticCard } from './StatisticCard';
|
|
35
|
-
export { EmailTemplatePreview, NoEmailTemplatePreview, replaceLocalization, replacePlaceholders } from './EmailTemplatePreview';
|
|
35
|
+
export { EmailTemplatePreview, NoEmailTemplatePreview, replaceLocalization, replacePlaceholders, matchClickMapItems, decorateClickMap, CLICK_MAP_BADGE_ATTRIBUTE, type EmailClickMapItem, type EmailClickMapPlacement, type EmailClickMapProps, type PreviewIframeProps, } from './EmailTemplatePreview';
|
|
36
36
|
export { PushPreview } from './PushPreview';
|
|
37
37
|
export { WhatsAppPreview } from './WhatsAppPreview';
|
|
38
38
|
export type { WhatsAppAttachment, WhatsAppAttachmentKind, WhatsAppMessage, WhatsAppMessageDirection, WhatsAppMessageStatus, WhatsAppPreviewProps, } from './WhatsAppPreview';
|
package/index.js
CHANGED
|
@@ -32,7 +32,7 @@ export { Tooltip, TooltipTrigger } from './Tooltip';
|
|
|
32
32
|
export { Tab, TabBar, TabPanel, Tabs } from './Tabs';
|
|
33
33
|
export { Link } from './Link';
|
|
34
34
|
export { StatisticCard } from './StatisticCard';
|
|
35
|
-
export { EmailTemplatePreview, NoEmailTemplatePreview, replaceLocalization, replacePlaceholders } from './EmailTemplatePreview';
|
|
35
|
+
export { EmailTemplatePreview, NoEmailTemplatePreview, replaceLocalization, replacePlaceholders, matchClickMapItems, decorateClickMap, CLICK_MAP_BADGE_ATTRIBUTE } from './EmailTemplatePreview';
|
|
36
36
|
export { PushPreview } from './PushPreview';
|
|
37
37
|
export { WhatsAppPreview } from './WhatsAppPreview';
|
|
38
38
|
export { Toast, ToastProvider, useToast } from './Toast';
|
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { validateContentSting } from '../../../RichMessageEditor/slate/helpers';
|
|
2
|
+
import { isResolvableDc, parseDc } from '../token';
|
|
3
|
+
// The lists below mirror channels/publisher
|
|
4
|
+
// internal/services/dynamiccontentreplacer/constants.go — keep them in sync.
|
|
5
|
+
const caseInsensitiveModifiers = ['capitalizefirst', 'capitalizeallfirst', 'uppercase', 'lowercase', 'regular', 'base64', 'cent', 'dollar', 'comma', 'euro', 'jpy', 'lira'];
|
|
6
|
+
const dateModifiers = ['M-d-y', 'm-d-y', 'M d y', 'M d Y', 'l', 'M d', 'H:i', 'm-d-y H:i'];
|
|
7
|
+
describe('isResolvableDc', () => {
|
|
8
|
+
test.each(caseInsensitiveModifiers)('accepts %s', modifier => {
|
|
9
|
+
expect(isResolvableDc(parseDc(`{Name|${modifier}}`))).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
test.each(caseInsensitiveModifiers)('accepts %s whatever the case', modifier => {
|
|
12
|
+
expect(isResolvableDc(parseDc(`{Name|${modifier.toUpperCase()}}`))).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
test.each(dateModifiers)('accepts the date modifier %s', modifier => {
|
|
15
|
+
expect(isResolvableDc(parseDc(`{Name|${modifier}}`))).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
test('rejects a date modifier in the wrong case, as the sender does', () => {
|
|
18
|
+
expect(isResolvableDc(parseDc('{Name|m d y}'))).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
test.each([['{Name|}', 'no modifier at all'], ['{Name|nope}', 'a modifier outside the list'], ['{|regular}', 'no tag name']])('rejects %s (%s)', token => {
|
|
21
|
+
expect(isResolvableDc(parseDc(token))).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
test('keeps a default value out of the verdict', () => {
|
|
24
|
+
expect(isResolvableDc(parseDc('{WalletPassLink|regular|no pass}'))).toBe(true);
|
|
25
|
+
expect(isResolvableDc(parseDc('{WalletPassLink||no pass}'))).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('validateContentSting', () => {
|
|
29
|
+
test.each([['Tap here!\n{WalletSerialNo|regular}', true], ['Tap here!\n{WalletSerialNo|}', false], ['{WalletSerialNo|regular}{WalletPassLink|}', false], ['Hi {{ user.name | upcase }}, plain liquid is not a token', true], ['no tokens at all', true], ['', true]])('%s -> %s', (content, expected) => {
|
|
30
|
+
expect(validateContentSting(content)).toBe(expected);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { DynamicContentChip, type DynamicContentChipProps } from './DynamicContentChip';
|
|
2
2
|
export { DynamicContentText, type DynamicContentTextProps } from './DynamicContentText';
|
|
3
|
-
export { parseDc, splitDynamicContent, stringifyDc } from './token';
|
|
3
|
+
export { isResolvableDc, parseDc, splitDynamicContent, stringifyDc } from './token';
|
|
4
4
|
export { Paragraph } from './styles';
|
|
5
5
|
export type { DynamicContent, DynamicContentPart, DynamicContentTone } from './types';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { DynamicContentChip } from './DynamicContentChip';
|
|
2
2
|
export { DynamicContentText } from './DynamicContentText';
|
|
3
|
-
export { parseDc, splitDynamicContent, stringifyDc } from './token';
|
|
3
|
+
export { isResolvableDc, parseDc, splitDynamicContent, stringifyDc } from './token';
|
|
4
4
|
export { Paragraph } from './styles';
|
|
@@ -18,3 +18,5 @@ export declare function splitDynamicContent(text: string): DynamicContentPart[];
|
|
|
18
18
|
export declare function parseDc(value: string): DynamicContent;
|
|
19
19
|
/** Serializes a token back to `{tagName|modifier|defaultValue}`. */
|
|
20
20
|
export declare function stringifyDc(dc: DynamicContent): string;
|
|
21
|
+
/** Whether the sender substitutes this token, or ships it verbatim (dates are case-sensitive, other modifiers are not). */
|
|
22
|
+
export declare function isResolvableDc(dc: DynamicContent): boolean;
|
|
@@ -42,4 +42,14 @@ export function stringifyDc(dc) {
|
|
|
42
42
|
defaultValue
|
|
43
43
|
} = dc;
|
|
44
44
|
return `{${tagName}|${modifier}${defaultValue ? `|${defaultValue}` : ''}}`;
|
|
45
|
+
}
|
|
46
|
+
const CASE_INSENSITIVE_MODIFIERS = ['capitalizefirst', 'capitalizeallfirst', 'uppercase', 'lowercase', 'regular', 'base64', 'cent', 'dollar', 'comma', 'euro', 'jpy', 'lira'];
|
|
47
|
+
const DATE_MODIFIERS = ['M-d-y', 'm-d-y', 'M d y', 'M d Y', 'l', 'M d', 'H:i', 'm-d-y H:i'];
|
|
48
|
+
/** Whether the sender substitutes this token, or ships it verbatim (dates are case-sensitive, other modifiers are not). */
|
|
49
|
+
export function isResolvableDc(dc) {
|
|
50
|
+
const {
|
|
51
|
+
tagName,
|
|
52
|
+
modifier
|
|
53
|
+
} = dc;
|
|
54
|
+
return Boolean(tagName) && (CASE_INSENSITIVE_MODIFIERS.includes(modifier.toLowerCase()) || DATE_MODIFIERS.includes(modifier));
|
|
45
55
|
}
|