@konvert7/promoot 0.2.1 → 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 +23 -1
- package/dist/index.d.ts +1 -1
- package/dist/promoot-block.js +87 -4
- package/dist/types.d.ts +34 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,26 @@ import { PromootBlock } from "@konvert7/promoot";
|
|
|
16
16
|
The `url` is the embed URL from your Promoot dashboard — the same string the iframe
|
|
17
17
|
snippet uses, so one value serves either method.
|
|
18
18
|
|
|
19
|
+
## What it renders
|
|
20
|
+
|
|
21
|
+
Whatever the sponsor bought, without you choosing:
|
|
22
|
+
|
|
23
|
+
- **An image ad** — their artwork, cropped to fill the slot.
|
|
24
|
+
- **A text ad** — their favicon, a headline, a description and their domain, centred.
|
|
25
|
+
Headline and description are both optional; a link on its own renders the mark and
|
|
26
|
+
the bare domain. If no favicon could be captured, a monogram of the domain's first
|
|
27
|
+
letter is shown instead — never both, so a transparent icon can't show the letter
|
|
28
|
+
through it.
|
|
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
|
+
|
|
19
39
|
## Why inline
|
|
20
40
|
|
|
21
41
|
An iframe fails closed. If a content filter ever blocks the frame, your sponsor's ad
|
|
@@ -68,7 +88,9 @@ visitor's browser fetches regardless of how that HTML was produced.
|
|
|
68
88
|
The block **never declares a colour scheme of its own**. Its palette uses `light-dark()`,
|
|
69
89
|
which resolves against the scheme it inherits from your page — so a light-only site keeps
|
|
70
90
|
a light panel even for a visitor whose OS prefers dark, and a dark site gets a dark one.
|
|
71
|
-
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.
|
|
72
94
|
|
|
73
95
|
The one thing you cannot restyle is the "Sponsored" label, which renders from inline
|
|
74
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 } from "./types.js";
|
|
2
|
+
export type { BlockPayload, BlockRender, Creative, PitchLine } from "./types.js";
|
package/dist/promoot-block.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
export function blockEndpoint(embedUrl) {
|
|
3
3
|
return embedUrl.replace("/embed/", "/api/block/");
|
|
4
4
|
}
|
|
@@ -16,19 +16,60 @@ 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}; }
|
|
22
54
|
${scope} a { text-decoration: none; }
|
|
23
55
|
${scope} .promoot-ad { display: block; position: relative; height: 100%; width: 100%; }
|
|
24
|
-
${scope} .promoot-ad img { display: block; height: 100%; width: 100%; object-fit: cover; }
|
|
56
|
+
${scope} .promoot-ad > img { display: block; height: 100%; width: 100%; object-fit: cover; }
|
|
57
|
+
${scope} .promoot-text { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 100%; height: 100%; box-sizing: border-box; padding: 6px 12px; border: 1px solid var(--edge); border-radius: 10px; background: var(--surface); overflow: hidden; text-align: center; font-family: system-ui, -apple-system, sans-serif; }
|
|
58
|
+
${scope} .promoot-ad:hover .promoot-text { border-color: var(--edge-hover); background: var(--surface-hover); }
|
|
59
|
+
${scope} .promoot-mark { position: relative; flex: none; display: grid; place-items: center; width: 24px; height: 24px; margin-bottom: 1px; border-radius: 6px; background: var(--edge); color: var(--surface); font-weight: 700; font-size: 12px; overflow: hidden; }
|
|
60
|
+
${scope} .promoot-mark img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
|
|
61
|
+
${scope} .promoot-text > span:not(.promoot-mark) { max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
62
|
+
${scope} .promoot-text .promoot-headline { font-size: 14px; font-weight: 600; color: var(--ink); line-height: 1.25; }
|
|
63
|
+
${scope} .promoot-text .promoot-desc { font-size: 12px; color: var(--muted); line-height: 1.25; }
|
|
64
|
+
${scope} .promoot-text .promoot-host { font-size: 11px; color: var(--muted); line-height: 1.25; }
|
|
25
65
|
${scope} .promoot-cta { display: flex; flex-direction: column; align-items: center; gap: 3px; width: 100%; height: 100%; justify-content: center; padding: 0 10px; text-align: center; border: 1px dashed var(--edge); border-radius: 10px; box-sizing: border-box; color: var(--muted); background: var(--surface); font-family: system-ui, -apple-system, sans-serif; }
|
|
26
66
|
${scope} .promoot-cta:hover { border-color: var(--edge-hover); background: var(--surface-hover); }
|
|
27
67
|
${scope} .promoot-cta strong { font-size: 14px; color: var(--ink); }
|
|
28
68
|
${scope} .promoot-cta .promoot-pitch { font-size: 13px; color: var(--ink); }
|
|
29
69
|
${scope} .promoot-cta .promoot-proof { font-size: 11px; color: var(--muted); }
|
|
30
70
|
${scope} .promoot-cta .promoot-terms { font-size: 12px; }
|
|
31
|
-
${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)}`;
|
|
32
73
|
}
|
|
33
74
|
// Inline, not a class: a host can restyle everything else, but the disclosure
|
|
34
75
|
// is not theirs to remove.
|
|
@@ -43,10 +84,52 @@ const labelStyle = {
|
|
|
43
84
|
borderRadius: "4px",
|
|
44
85
|
padding: "1px 5px",
|
|
45
86
|
};
|
|
87
|
+
// One or the other, never layered: a favicon with transparent corners would let
|
|
88
|
+
// the monogram show through it, and a broken image painted over the letter
|
|
89
|
+
// hides it anyway.
|
|
90
|
+
function Mark({ creative }) {
|
|
91
|
+
return (_jsx("span", { className: "promoot-mark", children: creative.faviconUrl ? (_jsx("img", { src: creative.faviconUrl, alt: "" })) : (creative.monogram) }));
|
|
92
|
+
}
|
|
93
|
+
function AdCreative({ creative }) {
|
|
94
|
+
if (creative.kind === "IMAGE") {
|
|
95
|
+
return _jsx("img", { src: creative.imageUrl, alt: creative.title });
|
|
96
|
+
}
|
|
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 })] }));
|
|
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
|
+
}
|
|
46
129
|
export async function PromootBlock({ url, className, style, fallback = null, }) {
|
|
47
130
|
const block = await loadBlock(url);
|
|
48
131
|
if (!block || block.render === "blank") {
|
|
49
132
|
return _jsx(_Fragment, { children: fallback });
|
|
50
133
|
}
|
|
51
|
-
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(
|
|
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" })] }));
|
|
52
135
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
export type BlockRender = "ad" | "pitch" | "blank";
|
|
2
|
+
/** An ad is either artwork the sponsor uploaded, or words they wrote. */
|
|
3
|
+
export type Creative = {
|
|
4
|
+
kind: "IMAGE";
|
|
5
|
+
title: string;
|
|
6
|
+
imageUrl: string;
|
|
7
|
+
} | {
|
|
8
|
+
kind: "TEXT";
|
|
9
|
+
/** Optional: a link alone is a complete ad, and the host stands in. */
|
|
10
|
+
headline: string | null;
|
|
11
|
+
body: string | null;
|
|
12
|
+
/** Null when the sponsor's site has none, or it could not be captured. */
|
|
13
|
+
faviconUrl: string | null;
|
|
14
|
+
host: string;
|
|
15
|
+
monogram: string;
|
|
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
|
+
};
|
|
2
25
|
/** What GET /api/block/:slotId answers with. */
|
|
3
26
|
export type BlockPayload = {
|
|
4
27
|
slot: {
|
|
@@ -8,18 +31,25 @@ export type BlockPayload = {
|
|
|
8
31
|
sponsoredLabel: string | null;
|
|
9
32
|
};
|
|
10
33
|
render: BlockRender;
|
|
11
|
-
ad: {
|
|
12
|
-
title: string;
|
|
13
|
-
imageUrl: string;
|
|
34
|
+
ad: ({
|
|
14
35
|
clickUrl: string;
|
|
15
36
|
expiresAt: string | null;
|
|
16
|
-
} | null;
|
|
37
|
+
} & Creative) | null;
|
|
17
38
|
pitch: {
|
|
18
39
|
headline: string;
|
|
19
40
|
pitch: string | null;
|
|
20
41
|
proof: string | null;
|
|
21
42
|
terms: string;
|
|
22
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;
|
|
23
53
|
} | null;
|
|
24
54
|
beaconUrl: string;
|
|
25
55
|
paletteVars: string;
|
package/package.json
CHANGED