@konvert7/promoot 0.3.0 → 0.4.0
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/README.md +12 -1
- package/dist/index.d.ts +1 -1
- package/dist/promoot-block.js +65 -2
- package/dist/types.d.ts +17 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,6 +27,15 @@ Whatever the sponsor bought, without you choosing:
|
|
|
27
27
|
letter is shown instead — never both, so a transparent icon can't show the letter
|
|
28
28
|
through it.
|
|
29
29
|
|
|
30
|
+
And when nobody has bought it yet, the slot sells itself. If the owner composed an empty
|
|
31
|
+
state in their dashboard, it renders as a billboard: the lines they chose — their own
|
|
32
|
+
words, the price, the view count, their domain — over a faint chart of the last 30 days
|
|
33
|
+
of traffic, with a preview of the visitor's own ad fading in on hover. Owners who never
|
|
34
|
+
composed one get the plain pitch instead: headline, audience, proof and terms.
|
|
35
|
+
|
|
36
|
+
The empty state needs no configuration here. It arrives resolved from the server, so the
|
|
37
|
+
words are already the words, and this block only sets them.
|
|
38
|
+
|
|
30
39
|
## Why inline
|
|
31
40
|
|
|
32
41
|
An iframe fails closed. If a content filter ever blocks the frame, your sponsor's ad
|
|
@@ -79,7 +88,9 @@ visitor's browser fetches regardless of how that HTML was produced.
|
|
|
79
88
|
The block **never declares a colour scheme of its own**. Its palette uses `light-dark()`,
|
|
80
89
|
which resolves against the scheme it inherits from your page — so a light-only site keeps
|
|
81
90
|
a light panel even for a visitor whose OS prefers dark, and a dark site gets a dark one.
|
|
82
|
-
All of its CSS is scoped to the block; nothing is defined on `:root`.
|
|
91
|
+
All of its CSS is scoped to the block; nothing is defined on `:root`. The only name it
|
|
92
|
+
declares globally is the keyframes the empty state breathes with on touch devices, and
|
|
93
|
+
that name carries the slot's own id, so two blocks on one page never collide.
|
|
83
94
|
|
|
84
95
|
The one thing you cannot restyle is the "Sponsored" label, which renders from inline
|
|
85
96
|
styles. Disclosure of a paid placement is not the site owner's to remove.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { PromootBlock, type PromootBlockProps } from "./promoot-block.js";
|
|
2
|
-
export type { BlockPayload, BlockRender, Creative } from "./types.js";
|
|
2
|
+
export type { BlockPayload, BlockRender, Creative, PitchLine } from "./types.js";
|
package/dist/promoot-block.js
CHANGED
|
@@ -16,6 +16,38 @@ async function loadBlock(embedUrl) {
|
|
|
16
16
|
// Every rule is scoped to this one block, and it never declares a colour scheme
|
|
17
17
|
// of its own: the palette uses light-dark(), which resolves against the scheme
|
|
18
18
|
// inherited from the host page, so a light-only site keeps a light panel.
|
|
19
|
+
// A keyframe name cannot be scoped to a selector, so it carries the slot's own
|
|
20
|
+
// id instead, stripped to the characters a css identifier may hold.
|
|
21
|
+
function breatheName(slotId, face) {
|
|
22
|
+
return `promoot-breathe-${face}-${slotId.replaceAll(/[^A-Za-z0-9_-]/g, "")}`;
|
|
23
|
+
}
|
|
24
|
+
const mono = "ui-monospace, SFMono-Regular, Menlo, monospace";
|
|
25
|
+
// The face the owner composed, and the ghost that shows a visitor what their own
|
|
26
|
+
// ad would look like in its place. Both are stacked, and hover swaps them.
|
|
27
|
+
function billboardCss(payload, scope) {
|
|
28
|
+
const rest = breatheName(payload.slot.id, "rest");
|
|
29
|
+
const ghost = breatheName(payload.slot.id, "ghost");
|
|
30
|
+
return `${scope} .promoot-bb { position: relative; display: block; width: 100%; height: 100%; box-sizing: border-box; border: 1px solid var(--edge); border-radius: 10px; background: var(--surface); overflow: hidden; font-family: system-ui, -apple-system, sans-serif; }
|
|
31
|
+
${scope} .promoot-bb:hover { border-color: var(--edge-hover); }
|
|
32
|
+
${scope} .promoot-face { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 10px 16px; box-sizing: border-box; text-align: center; transition: opacity .3s ease; }
|
|
33
|
+
${scope} .promoot-bb:hover .promoot-rest { opacity: 0; }
|
|
34
|
+
${scope} .promoot-ghost { flex-direction: row; gap: 12px; text-align: left; opacity: 0; }
|
|
35
|
+
${scope} .promoot-bb:hover .promoot-ghost { opacity: 1; }
|
|
36
|
+
${scope} .promoot-spark { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: 34%; pointer-events: none; }
|
|
37
|
+
${scope} .promoot-spark polygon { fill: var(--surface-hover); }
|
|
38
|
+
${scope} .promoot-spark polyline { fill: none; stroke: var(--edge); stroke-width: 1.5; }
|
|
39
|
+
${scope} .promoot-line { position: relative; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
40
|
+
${scope} .promoot-line-text { font-size: 15px; font-weight: 600; color: var(--ink); }
|
|
41
|
+
${scope} .promoot-line-price { font-family: ${mono}; font-size: 12.5px; font-weight: 600; color: var(--ink); }
|
|
42
|
+
${scope} .promoot-line-proof, ${scope} .promoot-line-domain { font-family: ${mono}; font-size: 11px; color: var(--muted); }
|
|
43
|
+
${scope} .promoot-plus { flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 9px; border: 1.5px dashed var(--muted); color: var(--muted); font-size: 18px; }
|
|
44
|
+
${scope} .promoot-ghost-title { display: block; font-size: 13.5px; font-weight: 600; color: var(--ink); }
|
|
45
|
+
${scope} .promoot-ghost-body { display: block; font-size: 12px; color: var(--muted); }
|
|
46
|
+
@media (hover: none) { ${scope} .promoot-rest { animation: ${rest} 12s ease-in-out 2s infinite; } ${scope} .promoot-ghost { animation: ${ghost} 12s ease-in-out 2s infinite; } }
|
|
47
|
+
@media (prefers-reduced-motion: reduce) { ${scope} .promoot-rest, ${scope} .promoot-ghost { animation: none; } ${scope} .promoot-face { transition: none; } }
|
|
48
|
+
@keyframes ${rest} { 0%, 55%, 100% { opacity: 1; } 70%, 85% { opacity: 0; } }
|
|
49
|
+
@keyframes ${ghost} { 0%, 55%, 100% { opacity: 0; } 70%, 85% { opacity: 1; } }`;
|
|
50
|
+
}
|
|
19
51
|
export function scopedCss(payload) {
|
|
20
52
|
const scope = `[data-promoot-slot="${payload.slot.id}"]`;
|
|
21
53
|
return `${scope} { ${payload.paletteVars}; position: relative; display: block; width: 100%; max-width: ${payload.slot.widthPx}px; height: auto; max-height: ${payload.slot.heightPx}px; aspect-ratio: ${payload.slot.widthPx} / ${payload.slot.heightPx}; }
|
|
@@ -36,7 +68,8 @@ ${scope} .promoot-cta strong { font-size: 14px; color: var(--ink); }
|
|
|
36
68
|
${scope} .promoot-cta .promoot-pitch { font-size: 13px; color: var(--ink); }
|
|
37
69
|
${scope} .promoot-cta .promoot-proof { font-size: 11px; color: var(--muted); }
|
|
38
70
|
${scope} .promoot-cta .promoot-terms { font-size: 12px; }
|
|
39
|
-
${scope} .promoot-beacon { position: absolute; bottom: 0; right: 0; width: 1px; height: 1px; }
|
|
71
|
+
${scope} .promoot-beacon { position: absolute; bottom: 0; right: 0; width: 1px; height: 1px; }
|
|
72
|
+
${billboardCss(payload, scope)}`;
|
|
40
73
|
}
|
|
41
74
|
// Inline, not a class: a host can restyle everything else, but the disclosure
|
|
42
75
|
// is not theirs to remove.
|
|
@@ -63,10 +96,40 @@ function AdCreative({ creative }) {
|
|
|
63
96
|
}
|
|
64
97
|
return (_jsxs("span", { className: "promoot-text", children: [_jsx(Mark, { creative: creative }), creative.headline && _jsx("span", { className: "promoot-headline", children: creative.headline }), creative.body && _jsx("span", { className: "promoot-desc", children: creative.body }), _jsx("span", { className: "promoot-host", children: creative.host })] }));
|
|
65
98
|
}
|
|
99
|
+
// Oldest day first across a fixed box, which the slot's own aspect ratio then
|
|
100
|
+
// stretches: one path serves every size.
|
|
101
|
+
function sparkPoints(series) {
|
|
102
|
+
const max = Math.max(...series, 1);
|
|
103
|
+
const step = series.length > 1 ? 600 / (series.length - 1) : 600;
|
|
104
|
+
return series
|
|
105
|
+
.map((value, index) => {
|
|
106
|
+
const x = Math.round(index * step * 10) / 10;
|
|
107
|
+
const y = Math.round((44 - (value / max) * 38) * 10) / 10;
|
|
108
|
+
return `${x},${y}`;
|
|
109
|
+
})
|
|
110
|
+
.join(" ");
|
|
111
|
+
}
|
|
112
|
+
function Spark({ series }) {
|
|
113
|
+
const points = sparkPoints(series);
|
|
114
|
+
return (_jsxs("svg", { className: "promoot-spark", viewBox: "0 0 600 48", preserveAspectRatio: "none", "aria-hidden": "true", children: [_jsx("polygon", { points: `0,48 ${points} 600,48` }), _jsx("polyline", { points: points })] }));
|
|
115
|
+
}
|
|
116
|
+
// What the owner wrote, with a preview of somebody else's ad underneath it: the
|
|
117
|
+
// empty slot sells itself to the visitor who could fill it.
|
|
118
|
+
function Billboard({ pitch, lines }) {
|
|
119
|
+
return (_jsxs("a", { className: "promoot-bb", href: pitch.purchaseUrl, target: "_blank", rel: "noopener", children: [_jsxs("span", { className: "promoot-face promoot-rest", children: [pitch.spark?.length ? _jsx(Spark, { series: pitch.spark }) : null, lines.map((line, index) => (_jsx("span", { className: `promoot-line promoot-line-${line.kind}`, children: line.text }, `${line.kind}-${index}`)))] }), _jsxs("span", { className: "promoot-face promoot-ghost", children: [_jsx("span", { className: "promoot-plus", children: "+" }), _jsxs("span", { children: [_jsx("span", { className: "promoot-ghost-title", children: "Your product \u00B7 one line your buyers read" }), _jsxs("span", { className: "promoot-ghost-body", children: ["This is how it would look.", pitch.price ? ` ${pitch.price}.` : ""] })] })] })] }));
|
|
120
|
+
}
|
|
121
|
+
function ClassicPitch({ pitch }) {
|
|
122
|
+
return (_jsxs("a", { className: "promoot-cta", href: pitch.purchaseUrl, target: "_blank", rel: "noopener", children: [_jsx("strong", { children: pitch.headline }), pitch.pitch && _jsx("span", { className: "promoot-pitch", children: pitch.pitch }), pitch.proof && _jsx("span", { className: "promoot-proof", children: pitch.proof }), _jsx("span", { className: "promoot-terms", children: pitch.terms })] }));
|
|
123
|
+
}
|
|
124
|
+
// A server that has not learned to compose an empty state sends no lines, and
|
|
125
|
+
// the pitch it does send is rendered exactly as it always was.
|
|
126
|
+
function EmptySlot({ pitch }) {
|
|
127
|
+
return pitch.lines?.length ? (_jsx(Billboard, { pitch: pitch, lines: pitch.lines })) : (_jsx(ClassicPitch, { pitch: pitch }));
|
|
128
|
+
}
|
|
66
129
|
export async function PromootBlock({ url, className, style, fallback = null, }) {
|
|
67
130
|
const block = await loadBlock(url);
|
|
68
131
|
if (!block || block.render === "blank") {
|
|
69
132
|
return _jsx(_Fragment, { children: fallback });
|
|
70
133
|
}
|
|
71
|
-
return (_jsxs("div", { "data-promoot-slot": block.slot.id, className: className, style: style, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: scopedCss(block) } }), block.ad && (_jsxs("a", { className: "promoot-ad", href: block.ad.clickUrl, target: "_blank", rel: "noopener nofollow sponsored", children: [_jsx(AdCreative, { creative: block.ad }), block.slot.sponsoredLabel && (_jsx("span", { style: labelStyle, children: block.slot.sponsoredLabel }))] })), block.pitch &&
|
|
134
|
+
return (_jsxs("div", { "data-promoot-slot": block.slot.id, className: className, style: style, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: scopedCss(block) } }), block.ad && (_jsxs("a", { className: "promoot-ad", href: block.ad.clickUrl, target: "_blank", rel: "noopener nofollow sponsored", children: [_jsx(AdCreative, { creative: block.ad }), block.slot.sponsoredLabel && (_jsx("span", { style: labelStyle, children: block.slot.sponsoredLabel }))] })), block.pitch && _jsx(EmptySlot, { pitch: block.pitch }), _jsx("img", { className: "promoot-beacon", src: block.beaconUrl, width: 1, height: 1, alt: "", "aria-hidden": "true" })] }));
|
|
72
135
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -14,6 +14,14 @@ export type Creative = {
|
|
|
14
14
|
host: string;
|
|
15
15
|
monogram: string;
|
|
16
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* One line of the empty state the slot's owner composed, already resolved into
|
|
19
|
+
* the words it prints. The kind only decides how it is set.
|
|
20
|
+
*/
|
|
21
|
+
export type PitchLine = {
|
|
22
|
+
kind: "text" | "price" | "proof" | "domain";
|
|
23
|
+
text: string;
|
|
24
|
+
};
|
|
17
25
|
/** What GET /api/block/:slotId answers with. */
|
|
18
26
|
export type BlockPayload = {
|
|
19
27
|
slot: {
|
|
@@ -33,6 +41,15 @@ export type BlockPayload = {
|
|
|
33
41
|
proof: string | null;
|
|
34
42
|
terms: string;
|
|
35
43
|
purchaseUrl: string;
|
|
44
|
+
/**
|
|
45
|
+
* The owner's composed empty state. Present from Promoot 0.4 onwards; an
|
|
46
|
+
* older server omits it and the classic pitch is rendered instead.
|
|
47
|
+
*/
|
|
48
|
+
lines?: PitchLine[] | null;
|
|
49
|
+
/** Daily views behind the lines, already gated by the owner's traffic floor. */
|
|
50
|
+
spark?: number[] | null;
|
|
51
|
+
/** The price on its own, formatted. */
|
|
52
|
+
price?: string;
|
|
36
53
|
} | null;
|
|
37
54
|
beaconUrl: string;
|
|
38
55
|
paletteVars: string;
|
package/package.json
CHANGED