@konvert7/promoot 0.1.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 +78 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/promoot-block.d.ts +15 -0
- package/dist/promoot-block.js +52 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.js +1 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @konvert7/promoot
|
|
2
|
+
|
|
3
|
+
Render a [Promoot](https://promootlabs.com) sponsor slot inline, as part of your own
|
|
4
|
+
markup, instead of in an iframe.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
bun add @konvert7/promoot
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
import { PromootBlock } from "@konvert7/promoot";
|
|
12
|
+
|
|
13
|
+
<PromootBlock url="https://promootlabs.com/embed/YOUR_SLOT_ID" />;
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The `url` is the embed URL from your Promoot dashboard — the same string the iframe
|
|
17
|
+
snippet uses, so one value serves either method.
|
|
18
|
+
|
|
19
|
+
## Why inline
|
|
20
|
+
|
|
21
|
+
An iframe fails closed. If a content filter ever blocks the frame, your sponsor's ad
|
|
22
|
+
disappears and they get nothing. An inline block renders inside your own document, so the
|
|
23
|
+
worst a filter can do is drop the creative or the view beacon: the ad is still seen.
|
|
24
|
+
|
|
25
|
+
It also participates in your layout. No fixed-height box to defend, no frame to style
|
|
26
|
+
around.
|
|
27
|
+
|
|
28
|
+
## What it costs you
|
|
29
|
+
|
|
30
|
+
The creative lives in your DOM, so this is a weaker guarantee for sponsors than the
|
|
31
|
+
iframe: nothing stops a site owner swapping or hiding it after approval. If you want that
|
|
32
|
+
guarantee, keep the iframe. Both count views identically.
|
|
33
|
+
|
|
34
|
+
## Props
|
|
35
|
+
|
|
36
|
+
| Prop | Type | Default | |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| `url` | `string` | — | Required. The dashboard embed URL. |
|
|
39
|
+
| `className` | `string` | — | Applied to the container. |
|
|
40
|
+
| `style` | `CSSProperties` | — | Applied to the container. |
|
|
41
|
+
| `fallback` | `ReactNode` | `null` | Rendered when the slot is paused, empty, or unreachable. |
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
44
|
+
|
|
45
|
+
A React Server Components framework — Next.js App Router or equivalent. The block fetches
|
|
46
|
+
on the server and ships **no client JavaScript**; the view is counted by a plain `<img>`
|
|
47
|
+
the visitor's browser loads, which is what keeps the count a first-hand observation rather
|
|
48
|
+
than something your server claims.
|
|
49
|
+
|
|
50
|
+
**The page must render dynamically.** The block fetches with `cache: "no-store"`, but if
|
|
51
|
+
your route is statically generated the fetch runs once at build time and an expired ad
|
|
52
|
+
keeps rendering — the sponsor gets free time and the next buyer never appears. In Next.js:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
export const dynamic = "force-dynamic";
|
|
56
|
+
// or a short revalidate window
|
|
57
|
+
export const revalidate = 30;
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Counting is unaffected by caching, because the beacon is a tag in your HTML that every
|
|
61
|
+
visitor's browser fetches regardless of how that HTML was produced.
|
|
62
|
+
|
|
63
|
+
## Styling
|
|
64
|
+
|
|
65
|
+
`className` and `style` land on the container, and the internal structure carries
|
|
66
|
+
`data-promoot-*` attributes plus `promoot-*` class names you can target.
|
|
67
|
+
|
|
68
|
+
The block **never declares a colour scheme of its own**. Its palette uses `light-dark()`,
|
|
69
|
+
which resolves against the scheme it inherits from your page — so a light-only site keeps
|
|
70
|
+
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`.
|
|
72
|
+
|
|
73
|
+
The one thing you cannot restyle is the "Sponsored" label, which renders from inline
|
|
74
|
+
styles. Disclosure of a paid placement is not the site owner's to remove.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PromootBlock } from "./promoot-block.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import type { BlockPayload } from "./types.js";
|
|
3
|
+
export type PromootBlockProps = {
|
|
4
|
+
/** The embed URL from your Promoot dashboard, the same one the iframe uses. */
|
|
5
|
+
url: string;
|
|
6
|
+
/** Applied to the block's container. */
|
|
7
|
+
className?: string;
|
|
8
|
+
/** Applied to the block's container. */
|
|
9
|
+
style?: CSSProperties;
|
|
10
|
+
/** Rendered when the slot is paused, empty, or unreachable. */
|
|
11
|
+
fallback?: ReactNode;
|
|
12
|
+
};
|
|
13
|
+
export declare function blockEndpoint(embedUrl: string): string;
|
|
14
|
+
export declare function scopedCss(payload: BlockPayload): string;
|
|
15
|
+
export declare function PromootBlock({ url, className, style, fallback, }: PromootBlockProps): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
export function blockEndpoint(embedUrl) {
|
|
3
|
+
return embedUrl.replace("/embed/", "/api/block/");
|
|
4
|
+
}
|
|
5
|
+
// no-store, because a cached render keeps showing a creative whose run has
|
|
6
|
+
// already ended: the sponsor gets free time and the next buyer never appears.
|
|
7
|
+
async function loadBlock(embedUrl) {
|
|
8
|
+
try {
|
|
9
|
+
const response = await fetch(blockEndpoint(embedUrl), { cache: "no-store" });
|
|
10
|
+
return response.ok ? (await response.json()) : null;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// Every rule is scoped to this one block, and it never declares a colour scheme
|
|
17
|
+
// of its own: the palette uses light-dark(), which resolves against the scheme
|
|
18
|
+
// inherited from the host page, so a light-only site keeps a light panel.
|
|
19
|
+
export function scopedCss(payload) {
|
|
20
|
+
const scope = `[data-promoot-slot="${payload.slot.id}"]`;
|
|
21
|
+
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
|
+
${scope} a { text-decoration: none; }
|
|
23
|
+
${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; }
|
|
25
|
+
${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
|
+
${scope} .promoot-cta:hover { border-color: var(--edge-hover); background: var(--surface-hover); }
|
|
27
|
+
${scope} .promoot-cta strong { font-size: 14px; color: var(--ink); }
|
|
28
|
+
${scope} .promoot-cta .promoot-pitch { font-size: 13px; color: var(--ink); }
|
|
29
|
+
${scope} .promoot-cta .promoot-proof { font-size: 11px; color: var(--muted); }
|
|
30
|
+
${scope} .promoot-cta .promoot-terms { font-size: 12px; }
|
|
31
|
+
${scope} .promoot-beacon { position: absolute; bottom: 0; right: 0; width: 1px; height: 1px; }`;
|
|
32
|
+
}
|
|
33
|
+
// Inline, not a class: a host can restyle everything else, but the disclosure
|
|
34
|
+
// is not theirs to remove.
|
|
35
|
+
const labelStyle = {
|
|
36
|
+
position: "absolute",
|
|
37
|
+
right: "4px",
|
|
38
|
+
bottom: "4px",
|
|
39
|
+
fontSize: "10px",
|
|
40
|
+
lineHeight: "1.4",
|
|
41
|
+
color: "#ffffff",
|
|
42
|
+
background: "rgba(0,0,0,.55)",
|
|
43
|
+
borderRadius: "4px",
|
|
44
|
+
padding: "1px 5px",
|
|
45
|
+
};
|
|
46
|
+
export async function PromootBlock({ url, className, style, fallback = null, }) {
|
|
47
|
+
const block = await loadBlock(url);
|
|
48
|
+
if (!block || block.render === "blank") {
|
|
49
|
+
return _jsx(_Fragment, { children: fallback });
|
|
50
|
+
}
|
|
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" })] }));
|
|
52
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type BlockRender = "ad" | "pitch" | "blank";
|
|
2
|
+
/** What GET /api/block/:slotId answers with. */
|
|
3
|
+
export type BlockPayload = {
|
|
4
|
+
slot: {
|
|
5
|
+
id: string;
|
|
6
|
+
widthPx: number;
|
|
7
|
+
heightPx: number;
|
|
8
|
+
sponsoredLabel: string | null;
|
|
9
|
+
};
|
|
10
|
+
render: BlockRender;
|
|
11
|
+
ad: {
|
|
12
|
+
title: string;
|
|
13
|
+
imageUrl: string;
|
|
14
|
+
clickUrl: string;
|
|
15
|
+
expiresAt: string | null;
|
|
16
|
+
} | null;
|
|
17
|
+
pitch: {
|
|
18
|
+
headline: string;
|
|
19
|
+
pitch: string | null;
|
|
20
|
+
proof: string | null;
|
|
21
|
+
terms: string;
|
|
22
|
+
purchaseUrl: string;
|
|
23
|
+
} | null;
|
|
24
|
+
beaconUrl: string;
|
|
25
|
+
paletteVars: string;
|
|
26
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@konvert7/promoot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Render a Promoot sponsor slot inline as a React server component, with views counted by the visitor's own browser.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist", "README.md"],
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": ">=19"
|
|
21
|
+
},
|
|
22
|
+
"keywords": ["promoot", "sponsor", "slot", "react", "rsc", "server-component"],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/konvert7/promoot.git"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.build.json",
|
|
30
|
+
"type-check": "tsc --noEmit",
|
|
31
|
+
"test": "bun test",
|
|
32
|
+
"prepublishOnly": "bun run build"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "1.3.13",
|
|
36
|
+
"@types/react": "19.2.17",
|
|
37
|
+
"@types/react-dom": "19.2.3",
|
|
38
|
+
"react": "19.2.7",
|
|
39
|
+
"react-dom": "19.2.7",
|
|
40
|
+
"typescript": "6.0.3"
|
|
41
|
+
}
|
|
42
|
+
}
|