@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 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";
@@ -1,4 +1,4 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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("img", { src: block.ad.imageUrl, alt: block.ad.title }), block.slot.sponsoredLabel && (_jsx("span", { style: labelStyle, children: block.slot.sponsoredLabel }))] })), block.pitch && (_jsxs("a", { className: "promoot-cta", href: block.pitch.purchaseUrl, target: "_blank", rel: "noopener", children: [_jsx("strong", { children: block.pitch.headline }), block.pitch.pitch && _jsx("span", { className: "promoot-pitch", children: block.pitch.pitch }), block.pitch.proof && _jsx("span", { className: "promoot-proof", children: block.pitch.proof }), _jsx("span", { className: "promoot-terms", children: block.pitch.terms })] })), _jsx("img", { className: "promoot-beacon", src: block.beaconUrl, width: 1, height: 1, alt: "", "aria-hidden": "true" })] }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@konvert7/promoot",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Render a Promoot sponsor slot inline as a React server component, with views counted by the visitor's own browser.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",