shopgpt 0.1.0 → 0.3.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 +95 -78
- package/dist/index.d.ts +19 -25
- package/dist/index.js +22 -21
- package/dist/react.d.ts +26 -26
- package/dist/react.js +30 -53
- package/dist/types.d.ts +22 -15
- package/dist/types.js +1 -1
- package/package.json +28 -22
- package/dist/qr.d.ts +0 -13
- package/dist/qr.js +0 -25
- package/src/index.ts +0 -171
- package/src/qr.ts +0 -32
- package/src/react.tsx +0 -232
- package/src/types.ts +0 -48
- package/tsconfig.json +0 -16
package/README.md
CHANGED
|
@@ -1,78 +1,95 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
TV ad plays →
|
|
4
|
-
|
|
5
|
-
Works on any HTML5 CTV (Samsung Tizen, LG webOS, FireTV, AndroidTV, Roku webview). No native code.
|
|
6
|
-
|
|
7
|
-
## Quickstart
|
|
8
|
-
|
|
9
|
-
```ts
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
const ctv = new
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
1
|
+
# shopgpt — Shoppable CTV Ad SDK
|
|
2
|
+
|
|
3
|
+
TV ad plays → viewer presses the CTA → cart converts → TV updates live.
|
|
4
|
+
|
|
5
|
+
Works on any HTML5 CTV (Samsung Tizen, LG webOS, FireTV, AndroidTV, Roku webview). No native code.
|
|
6
|
+
|
|
7
|
+
## Quickstart
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { ShopGPTCTV } from "shopgpt";
|
|
11
|
+
|
|
12
|
+
const ctv = new ShopGPTCTV({ apiBase: "https://app.rankup.diy" });
|
|
13
|
+
|
|
14
|
+
// listen for conversions
|
|
15
|
+
ctv.onEvent((e) => {
|
|
16
|
+
if (e.type === "added_to_cart") showBadge(e.session);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// when the ad starts — pass the 3 VAST params as-is.
|
|
20
|
+
// The product (title, image, price) is looked up server-side from rankupid.
|
|
21
|
+
const { session } = await ctv.createSession({ shoppable, rankupid, cta });
|
|
22
|
+
if (!session) return; // shoppable: false → plain ad
|
|
23
|
+
|
|
24
|
+
// CTA press → raises the added_to_cart flag
|
|
25
|
+
await ctv.addToCart(session.id);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## CTA (fully customisable)
|
|
29
|
+
|
|
30
|
+
Two built-in presets, or any custom text:
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { ShopGPTAddToCartButton } from "shopgpt/react";
|
|
34
|
+
|
|
35
|
+
// preset 1 (default)
|
|
36
|
+
<ShopGPTAddToCartButton session={session} cta="add_to_cart" />
|
|
37
|
+
|
|
38
|
+
// preset 2
|
|
39
|
+
<ShopGPTAddToCartButton session={session} cta="send_to_phone" />
|
|
40
|
+
|
|
41
|
+
// fully custom — change it as you want
|
|
42
|
+
<ShopGPTAddToCartButton
|
|
43
|
+
session={session}
|
|
44
|
+
label="Buy Now"
|
|
45
|
+
className="my-cta-class"
|
|
46
|
+
onCta={(s) => analytics.track("ctv_cta", { sessionId: s.id })}
|
|
47
|
+
/>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## React
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
import { ShopGPTCTVPlayer, useShopGPTShoppableAd } from "shopgpt/react";
|
|
54
|
+
|
|
55
|
+
<ShopGPTCTVPlayer
|
|
56
|
+
videoSrc="https://…/ad.mp4"
|
|
57
|
+
ad={{ shoppable: true, rankupid: 10403886137619, cta: "Scan to buy" }}
|
|
58
|
+
cta="send_to_phone" // or ctaLabel="Buy Now"
|
|
59
|
+
onConverted={(s) => console.log("sold!", s.cartCount)}
|
|
60
|
+
/>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Hooks: `useShopGPTShoppableAd(ad, opts)` (create + poll automatically),
|
|
64
|
+
`useShopGPTSession(sessionId)` (follow a live session).
|
|
65
|
+
|
|
66
|
+
## Backend (included in this repo)
|
|
67
|
+
|
|
68
|
+
| Method | Route | Purpose |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| POST | `/api/ctv/session` | create session `{ shoppable, rankupid, cta }` → product resolved server-side (404 if unknown) |
|
|
71
|
+
| GET | `/api/ctv/session/:id` | poll session (no-store) |
|
|
72
|
+
| PATCH | `/api/ctv/session/:id` | `{ status: "scanned" \| "viewing" \| "completed" }` |
|
|
73
|
+
| POST | `/api/ctv/cart` | `{ sessionId, qty }` → marks `added_to_cart` |
|
|
74
|
+
| GET | `/api/ctv/cart?sessionId=` | list cart items |
|
|
75
|
+
| GET | `/api/ctv/products` | catalog products shaped for TV |
|
|
76
|
+
|
|
77
|
+
Demo: `/ctv-demo/tv` (TV) → press the CTA → spot flips to converted live.
|
|
78
|
+
|
|
79
|
+
## ShopGPT handoff (CTV → shopgpt.rankup.diy)
|
|
80
|
+
|
|
81
|
+
Sessions carry a ShopGPT deep link (`session.mobileUrl`):
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
https://shopgpt.rankup.diy/?product_id=<acp_item_id>&ctv=<sessionId>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`?product_id=` opens the ShopGPT product dialog (chat + add to cart);
|
|
88
|
+
`&ctv=` lets ShopGPT beacon `scanned` / `added_to_cart` back to
|
|
89
|
+
`/api/ctv/session/:id` and `/api/ctv/cart` (CORS open) so the TV updates live.
|
|
90
|
+
|
|
91
|
+
## Production notes
|
|
92
|
+
|
|
93
|
+
- Current store is in-memory (`lib/ctv/store.ts`). Move to Supabase `ctv_sessions` + `ctv_cart_items` or Upstash Redis for multi-instance.
|
|
94
|
+
- Wire `product` from Shopify / Woo / ACP feed (`lib/acp`, `app/shop/[slug]/[itemId]`).
|
|
95
|
+
- Analytics: `ctv.track("impression", { sessionId })` → POST `/api/ctv/session/track` (stub, add table).
|
package/dist/index.d.ts
CHANGED
|
@@ -1,48 +1,42 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ShopGPTAdParams, ShopGPTConfig, ShopGPTEvent, ShopGPTProduct, ShopGPTSession } from "./types";
|
|
2
2
|
export * from "./types";
|
|
3
|
-
|
|
4
|
-
type Listener = (e: EnemEvent) => void;
|
|
3
|
+
type Listener = (e: ShopGPTEvent) => void;
|
|
5
4
|
/**
|
|
6
|
-
*
|
|
5
|
+
* ShopGPTCTV — framework-agnostic core.
|
|
7
6
|
*
|
|
8
|
-
* TV side: createSession() → render
|
|
7
|
+
* TV side: createSession() → render CTA → pollSession() → onEvent(added_to_cart)
|
|
9
8
|
* Phone side: getSession() → markViewed() → addToCart()
|
|
10
9
|
*
|
|
11
10
|
* @example
|
|
12
|
-
* const ctv = new
|
|
13
|
-
* const { session
|
|
14
|
-
* ctv.
|
|
11
|
+
* const ctv = new ShopGPTCTV({ apiBase: "https://app.rankup.diy" });
|
|
12
|
+
* const { session } = await ctv.createSession({ shoppable: true, rankupid: 10403886137619, cta: "Scan to buy" });
|
|
13
|
+
* ctv.onEvent((e) => { if (e.type === "added_to_cart") showBadge(e.session); });
|
|
15
14
|
*/
|
|
16
|
-
export declare class
|
|
15
|
+
export declare class ShopGPTCTV {
|
|
17
16
|
private cfg;
|
|
18
17
|
private listeners;
|
|
19
18
|
private pollTimer;
|
|
20
|
-
constructor(config?:
|
|
19
|
+
constructor(config?: ShopGPTConfig);
|
|
21
20
|
get apiBase(): string;
|
|
22
21
|
onEvent(fn: Listener): () => void;
|
|
23
22
|
private emit;
|
|
24
23
|
private f;
|
|
25
|
-
createSession(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
ttlSec?: number;
|
|
29
|
-
}): Promise<{
|
|
30
|
-
session: EnemSession;
|
|
31
|
-
mobileUrl: string;
|
|
32
|
-
qrImageUrl: string;
|
|
24
|
+
createSession(ad: ShopGPTAdParams): Promise<{
|
|
25
|
+
session: ShopGPTSession | null;
|
|
26
|
+
mobileUrl: string | null;
|
|
33
27
|
}>;
|
|
34
|
-
getSession(sessionId: string): Promise<
|
|
35
|
-
pollSession(sessionId: string, onUpdate?: (s:
|
|
28
|
+
getSession(sessionId: string): Promise<ShopGPTSession>;
|
|
29
|
+
pollSession(sessionId: string, onUpdate?: (s: ShopGPTSession) => void): () => void;
|
|
36
30
|
stopPolling(): void;
|
|
37
|
-
markScanned(sessionId: string): Promise<
|
|
31
|
+
markScanned(sessionId: string): Promise<ShopGPTSession>;
|
|
38
32
|
addToCart(sessionId: string, opts?: {
|
|
39
33
|
qty?: number;
|
|
40
|
-
product?:
|
|
34
|
+
product?: ShopGPTProduct;
|
|
41
35
|
}): Promise<{
|
|
42
36
|
ok: boolean;
|
|
43
|
-
session:
|
|
37
|
+
session: ShopGPTSession;
|
|
44
38
|
}>;
|
|
45
39
|
track(event: string, props?: Record<string, unknown>): void;
|
|
46
40
|
}
|
|
47
|
-
/** Vanilla script-tag helper:
|
|
48
|
-
export declare function autoInit(selector?: string):
|
|
41
|
+
/** Vanilla script-tag helper: marks [data-shopgpt-ctv] elements ready. */
|
|
42
|
+
export declare function autoInit(selector?: string): ShopGPTCTV;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { buildMobileUrl, buildQRImageUrl } from "./qr";
|
|
2
1
|
export * from "./types";
|
|
3
|
-
export * from "./qr";
|
|
4
2
|
function resolveBase(cfg) {
|
|
5
3
|
if (cfg?.apiBase)
|
|
6
4
|
return cfg.apiBase.replace(/\/$/, "");
|
|
@@ -9,17 +7,17 @@ function resolveBase(cfg) {
|
|
|
9
7
|
return "";
|
|
10
8
|
}
|
|
11
9
|
/**
|
|
12
|
-
*
|
|
10
|
+
* ShopGPTCTV — framework-agnostic core.
|
|
13
11
|
*
|
|
14
|
-
* TV side: createSession() → render
|
|
12
|
+
* TV side: createSession() → render CTA → pollSession() → onEvent(added_to_cart)
|
|
15
13
|
* Phone side: getSession() → markViewed() → addToCart()
|
|
16
14
|
*
|
|
17
15
|
* @example
|
|
18
|
-
* const ctv = new
|
|
19
|
-
* const { session
|
|
20
|
-
* ctv.
|
|
16
|
+
* const ctv = new ShopGPTCTV({ apiBase: "https://app.rankup.diy" });
|
|
17
|
+
* const { session } = await ctv.createSession({ shoppable: true, rankupid: 10403886137619, cta: "Scan to buy" });
|
|
18
|
+
* ctv.onEvent((e) => { if (e.type === "added_to_cart") showBadge(e.session); });
|
|
21
19
|
*/
|
|
22
|
-
export class
|
|
20
|
+
export class ShopGPTCTV {
|
|
23
21
|
constructor(config = {}) {
|
|
24
22
|
this.listeners = new Set();
|
|
25
23
|
this.pollTimer = null;
|
|
@@ -45,25 +43,28 @@ export class EnemCTV {
|
|
|
45
43
|
f() {
|
|
46
44
|
return this.cfg.fetchImpl ?? fetch.bind(globalThis);
|
|
47
45
|
}
|
|
48
|
-
// ── TV: create a shoppable session
|
|
49
|
-
|
|
46
|
+
// ── TV: create a shoppable session from the VAST ad params ─────
|
|
47
|
+
// Only { shoppable, rankupid, cta } are needed — the server looks up the
|
|
48
|
+
// product. `session` is null when the ad isn't shoppable.
|
|
49
|
+
async createSession(ad) {
|
|
50
|
+
if (ad.shoppable === false)
|
|
51
|
+
return { session: null, mobileUrl: null };
|
|
50
52
|
const res = await this.f()(`${this.apiBase}/api/ctv/session`, {
|
|
51
53
|
method: "POST",
|
|
52
54
|
headers: { "content-type": "application/json" },
|
|
53
55
|
body: JSON.stringify({
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
ttlSec: opts?.ttlSec ?? 600,
|
|
56
|
+
shoppable: ad.shoppable ?? true,
|
|
57
|
+
rankupid: ad.rankupid,
|
|
58
|
+
cta: ad.cta,
|
|
58
59
|
}),
|
|
59
60
|
});
|
|
60
61
|
if (!res.ok)
|
|
61
62
|
throw new Error(`createSession failed: ${res.status}`);
|
|
62
63
|
const session = (await res.json()).session;
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
if (!session)
|
|
65
|
+
return { session: null, mobileUrl: null };
|
|
65
66
|
this.emit({ type: "session_created", session });
|
|
66
|
-
return { session, mobileUrl
|
|
67
|
+
return { session, mobileUrl: session.mobileUrl };
|
|
67
68
|
}
|
|
68
69
|
// ── Shared: fetch one session ───────────────────────────────────
|
|
69
70
|
async getSession(sessionId) {
|
|
@@ -145,12 +146,12 @@ export class EnemCTV {
|
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
|
-
/** Vanilla script-tag helper:
|
|
149
|
-
export function autoInit(selector = "[data-
|
|
150
|
-
const client = new
|
|
149
|
+
/** Vanilla script-tag helper: marks [data-shopgpt-ctv] elements ready. */
|
|
150
|
+
export function autoInit(selector = "[data-shopgpt-ctv]") {
|
|
151
|
+
const client = new ShopGPTCTV();
|
|
151
152
|
if (typeof document !== "undefined") {
|
|
152
153
|
document.querySelectorAll(selector).forEach((el) => {
|
|
153
|
-
el.setAttribute("data-
|
|
154
|
+
el.setAttribute("data-shopgpt-ctv-ready", "true");
|
|
154
155
|
});
|
|
155
156
|
}
|
|
156
157
|
return client;
|
package/dist/react.d.ts
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
export declare function
|
|
4
|
-
session:
|
|
1
|
+
import { ShopGPTCTV } from "./index";
|
|
2
|
+
import type { ShopGPTAdParams, ShopGPTSession } from "./types";
|
|
3
|
+
export declare function useShopGPTSession(sessionId: string | null, apiBase?: string): {
|
|
4
|
+
session: ShopGPTSession | null;
|
|
5
5
|
error: string | null;
|
|
6
6
|
};
|
|
7
|
-
export declare function
|
|
8
|
-
placementId?: string;
|
|
9
|
-
campaignId?: string;
|
|
7
|
+
export declare function useShopGPTShoppableAd(ad: ShopGPTAdParams | null, opts?: {
|
|
10
8
|
apiBase?: string;
|
|
11
9
|
autoCreate?: boolean;
|
|
12
10
|
}): {
|
|
13
|
-
session:
|
|
14
|
-
qrImageUrl: string | null;
|
|
11
|
+
session: ShopGPTSession | null;
|
|
15
12
|
loading: boolean;
|
|
16
|
-
create: () => Promise<
|
|
13
|
+
create: () => Promise<ShopGPTSession | null>;
|
|
17
14
|
};
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
export type ShopGPTCta = "add_to_cart" | "send_to_phone";
|
|
16
|
+
export declare const SHOPGPT_CTA_LABELS: Record<ShopGPTCta, string>;
|
|
17
|
+
export declare function ShopGPTAddToCartButton({ session, client, apiBase, qty, cta, label, onCta, onAdded, className, }: {
|
|
18
|
+
session: ShopGPTSession | null;
|
|
19
|
+
client?: ShopGPTCTV;
|
|
21
20
|
apiBase?: string;
|
|
22
21
|
qty?: number;
|
|
22
|
+
/** CTA preset: "add_to_cart" | "send_to_phone" (default "add_to_cart") */
|
|
23
|
+
cta?: ShopGPTCta;
|
|
24
|
+
/** Fully custom button text — overrides the preset (e.g. "Buy Now") */
|
|
23
25
|
label?: string;
|
|
24
|
-
|
|
26
|
+
/** Extra hook fired after a successful tap */
|
|
27
|
+
onCta?: (s: ShopGPTSession) => void | Promise<void>;
|
|
28
|
+
onAdded?: (s: ShopGPTSession) => void;
|
|
25
29
|
className?: string;
|
|
26
30
|
}): import("react/jsx-runtime").JSX.Element | null;
|
|
27
|
-
export declare function
|
|
28
|
-
session: EnemSession | null;
|
|
29
|
-
qrImageUrl: string | null;
|
|
30
|
-
position?: "bottom-right" | "bottom-left" | "right";
|
|
31
|
-
theme?: "dark" | "light";
|
|
32
|
-
}): import("react/jsx-runtime").JSX.Element | null;
|
|
33
|
-
export declare function EnemCTVPlayer({ videoSrc, product, placementId, campaignId, apiBase, showQR, onConverted, }: {
|
|
31
|
+
export declare function ShopGPTCTVPlayer({ videoSrc, ad, apiBase, cta, ctaLabel, onConverted, }: {
|
|
34
32
|
videoSrc: string;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
campaignId?: string;
|
|
33
|
+
/** VAST ad params: { shoppable, rankupid, cta } */
|
|
34
|
+
ad: ShopGPTAdParams;
|
|
38
35
|
apiBase?: string;
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
/** CTA preset passed to the button: "add_to_cart" | "send_to_phone" */
|
|
37
|
+
cta?: ShopGPTCta;
|
|
38
|
+
/** Fully custom CTA text (overrides the preset and the ad's `cta`) */
|
|
39
|
+
ctaLabel?: string;
|
|
40
|
+
onConverted?: (s: ShopGPTSession) => void;
|
|
41
41
|
}): import("react/jsx-runtime").JSX.Element;
|
package/dist/react.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import {
|
|
5
|
-
import { buildQRImageUrl, shortCode } from "./qr";
|
|
4
|
+
import { ShopGPTCTV } from "./index";
|
|
6
5
|
// ── Hook: live session (TV side polling) ──────────────────────────
|
|
7
|
-
export function
|
|
6
|
+
export function useShopGPTSession(sessionId, apiBase) {
|
|
8
7
|
const [session, setSession] = useState(null);
|
|
9
8
|
const [error, setError] = useState(null);
|
|
10
9
|
useEffect(() => {
|
|
11
10
|
if (!sessionId)
|
|
12
11
|
return;
|
|
13
|
-
const client = new
|
|
12
|
+
const client = new ShopGPTCTV(apiBase ? { apiBase } : {});
|
|
14
13
|
let stop = () => { };
|
|
15
14
|
client
|
|
16
15
|
.getSession(sessionId)
|
|
@@ -23,45 +22,43 @@ export function useEnemSession(sessionId, apiBase) {
|
|
|
23
22
|
}, [sessionId, apiBase]);
|
|
24
23
|
return { session, error };
|
|
25
24
|
}
|
|
26
|
-
// ── Hook: create session
|
|
27
|
-
export function
|
|
25
|
+
// ── Hook: create session from the VAST ad params (TV ad start) ────
|
|
26
|
+
export function useShopGPTShoppableAd(ad, opts) {
|
|
28
27
|
const [session, setSession] = useState(null);
|
|
29
|
-
const [qrImageUrl, setQrImageUrl] = useState(null);
|
|
30
28
|
const [loading, setLoading] = useState(false);
|
|
31
29
|
const clientRef = useRef(null);
|
|
32
30
|
if (!clientRef.current)
|
|
33
|
-
clientRef.current = new
|
|
31
|
+
clientRef.current = new ShopGPTCTV(opts?.apiBase ? { apiBase: opts.apiBase } : {});
|
|
34
32
|
const create = useCallback(async () => {
|
|
35
|
-
if (!
|
|
33
|
+
if (!ad)
|
|
36
34
|
return null;
|
|
37
35
|
setLoading(true);
|
|
38
36
|
try {
|
|
39
|
-
const r = await clientRef.current.createSession(
|
|
40
|
-
placementId: opts?.placementId,
|
|
41
|
-
campaignId: opts?.campaignId,
|
|
42
|
-
});
|
|
37
|
+
const r = await clientRef.current.createSession(ad);
|
|
43
38
|
setSession(r.session);
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
if (r.session)
|
|
40
|
+
clientRef.current.pollSession(r.session.id, setSession);
|
|
46
41
|
return r.session;
|
|
47
42
|
}
|
|
48
43
|
finally {
|
|
49
44
|
setLoading(false);
|
|
50
45
|
}
|
|
51
|
-
}, [
|
|
46
|
+
}, [ad]);
|
|
52
47
|
useEffect(() => {
|
|
53
|
-
if (opts?.autoCreate !== false &&
|
|
48
|
+
if (opts?.autoCreate !== false && ad && !session)
|
|
54
49
|
void create();
|
|
55
50
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
56
|
-
}, [
|
|
51
|
+
}, [ad?.rankupid, ad?.shoppable]);
|
|
57
52
|
useEffect(() => () => clientRef.current?.stopPolling(), []);
|
|
58
|
-
return { session,
|
|
53
|
+
return { session, loading, create };
|
|
59
54
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
55
|
+
export const SHOPGPT_CTA_LABELS = {
|
|
56
|
+
add_to_cart: "Add to Cart",
|
|
57
|
+
send_to_phone: "Send to Phone",
|
|
58
|
+
};
|
|
59
|
+
export function ShopGPTAddToCartButton({ session, client, apiBase, qty = 1, cta = "add_to_cart", label, onCta, onAdded, className, }) {
|
|
64
60
|
const [adding, setAdding] = useState(false);
|
|
61
|
+
const text = label ?? SHOPGPT_CTA_LABELS[cta];
|
|
65
62
|
const converted = session?.status === "added_to_cart" || session?.status === "completed";
|
|
66
63
|
if (!session)
|
|
67
64
|
return null;
|
|
@@ -69,50 +66,30 @@ export function EnemAddToCartButton({ session, client, apiBase, qty = 1, label =
|
|
|
69
66
|
return (_jsx("p", { className: "inline-block rounded-full bg-emerald-500 px-3 py-1 text-xs font-bold text-white", children: "\u2713 Added to cart" }));
|
|
70
67
|
}
|
|
71
68
|
return (_jsx("button", { type: "button", disabled: adding, onClick: async () => {
|
|
72
|
-
const c = client ?? new
|
|
69
|
+
const c = client ?? new ShopGPTCTV(apiBase ? { apiBase } : {});
|
|
73
70
|
setAdding(true);
|
|
74
71
|
try {
|
|
75
72
|
const res = await c.addToCart(session.id, { qty });
|
|
76
|
-
if (res.session)
|
|
73
|
+
if (res.session) {
|
|
77
74
|
onAdded?.(res.session);
|
|
75
|
+
await onCta?.(res.session);
|
|
76
|
+
}
|
|
78
77
|
}
|
|
79
78
|
finally {
|
|
80
79
|
setAdding(false);
|
|
81
80
|
}
|
|
82
81
|
}, className: className ??
|
|
83
|
-
"rounded-full bg-white px-6 py-2.5 text-sm font-bold text-black transition hover:bg-emerald-300 disabled:opacity-50", children: adding ? "Adding…" :
|
|
84
|
-
}
|
|
85
|
-
// ── Component: QR overlay for CTV (10-foot UI) ─────────────────────
|
|
86
|
-
// Legacy phone-scan path. Prefer EnemAddToCartButton for remote-driven
|
|
87
|
-
// spots — it raises the same `added_to_cart` SDK flag with no QR.
|
|
88
|
-
export function EnemQROverlay({ session, qrImageUrl, position = "bottom-right", theme = "dark", }) {
|
|
89
|
-
if (!session || !qrImageUrl)
|
|
90
|
-
return null;
|
|
91
|
-
const pos = position === "bottom-left"
|
|
92
|
-
? "left-6 bottom-6"
|
|
93
|
-
: position === "right"
|
|
94
|
-
? "right-6 top-1/2 -translate-y-1/2"
|
|
95
|
-
: "right-6 bottom-6";
|
|
96
|
-
const converted = session.status === "added_to_cart" || session.status === "completed";
|
|
97
|
-
const scanned = session.status === "scanned" || session.status === "viewing" || converted;
|
|
98
|
-
return (_jsxs("div", { className: `absolute ${pos} z-20 flex items-center gap-4 rounded-2xl p-4 shadow-2xl backdrop-blur ${theme === "dark" ? "bg-black/70 text-white" : "bg-white/95 text-gray-900"}`, children: [_jsx("img", { src: qrImageUrl, alt: "Scan to shop", width: 132, height: 132, className: "rounded-xl bg-white p-1" }), _jsxs("div", { className: "pr-2", children: [_jsx("p", { className: "text-[11px] font-bold uppercase tracking-[0.2em] opacity-70", children: "Shop this ad" }), _jsx("p", { className: "mt-1 max-w-[180px] truncate text-sm font-semibold", children: session.product.title }), _jsx("p", { className: "text-sm opacity-80", children: session.product.salePrice ?? session.product.price }), converted ? (_jsx("p", { className: "mt-2 inline-block rounded-full bg-emerald-500 px-3 py-1 text-xs font-bold text-white", children: "\u2713 Added to cart" })) : scanned ? (_jsx("p", { className: "mt-2 inline-block rounded-full bg-sky-500 px-3 py-1 text-xs font-bold text-white", children: "\u25CF Phone connected" })) : (_jsxs("p", { className: "mt-2 text-xs opacity-80", children: ["Scan with camera", _jsx("span", { className: "ml-2 rounded bg-white/20 px-1.5 py-0.5 font-mono", children: shortCode(session.id) })] }))] })] }));
|
|
82
|
+
"rounded-full bg-white px-6 py-2.5 text-sm font-bold text-black transition hover:bg-emerald-300 disabled:opacity-50", children: adding ? "Adding…" : text }));
|
|
99
83
|
}
|
|
100
84
|
// ── Component: minimal CTV player wrapper ─────────────────────────
|
|
101
|
-
// Remote
|
|
102
|
-
//
|
|
103
|
-
export function
|
|
104
|
-
const { session
|
|
105
|
-
placementId,
|
|
106
|
-
campaignId,
|
|
107
|
-
apiBase,
|
|
108
|
-
});
|
|
109
|
-
const mobileUrl = session
|
|
110
|
-
? buildQRImageUrl(session.mobileUrl) && session.mobileUrl
|
|
111
|
-
: null;
|
|
85
|
+
// Remote CTA button (raises `added_to_cart` flag). Overlay shows the
|
|
86
|
+
// product name only — no other chrome.
|
|
87
|
+
export function ShopGPTCTVPlayer({ videoSrc, ad, apiBase, cta = "add_to_cart", ctaLabel, onConverted, }) {
|
|
88
|
+
const { session } = useShopGPTShoppableAd(ad, { apiBase });
|
|
112
89
|
useEffect(() => {
|
|
113
90
|
if (session &&
|
|
114
91
|
(session.status === "added_to_cart" || session.status === "completed"))
|
|
115
92
|
onConverted?.(session);
|
|
116
93
|
}, [session, onConverted]);
|
|
117
|
-
return (_jsxs("div", { className: "relative aspect-video w-full overflow-hidden rounded-2xl bg-black", children: [_jsx("video", { src: videoSrc, controls: true, autoPlay: true, muted: true, loop: true, playsInline: true, className: "h-full w-full object-cover" }),
|
|
94
|
+
return (_jsxs("div", { className: "relative aspect-video w-full overflow-hidden rounded-2xl bg-black", children: [_jsx("video", { src: videoSrc, controls: true, autoPlay: true, muted: true, loop: true, playsInline: true, className: "h-full w-full object-cover" }), session && (_jsx("div", { className: "absolute bottom-6 right-6 z-20 rounded-2xl bg-black/70 p-4 text-white shadow-2xl backdrop-blur", children: _jsxs("div", { className: "pr-2", children: [_jsx("p", { className: "max-w-[240px] truncate text-sm font-semibold", children: session.product.title }), _jsx("div", { className: "mt-2", children: _jsx(ShopGPTAddToCartButton, { session: session, apiBase: apiBase, cta: cta, label: ctaLabel ?? session.cta }) })] }) }))] }));
|
|
118
95
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface ShopGPTProduct {
|
|
2
2
|
id: string;
|
|
3
3
|
title: string;
|
|
4
4
|
brand?: string;
|
|
@@ -8,37 +8,44 @@ export interface EnemProduct {
|
|
|
8
8
|
url?: string;
|
|
9
9
|
description?: string;
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
export interface
|
|
11
|
+
/** The three params a VAST ad carries. The product is resolved server-side from `rankupid`. */
|
|
12
|
+
export interface ShopGPTAdParams {
|
|
13
|
+
/** false → plain ad, no session is created */
|
|
14
|
+
shoppable?: boolean;
|
|
15
|
+
/** Our product id (unique identifier) */
|
|
16
|
+
rankupid: number | string;
|
|
17
|
+
/** CTA label shown on the TV, e.g. "Scan to buy" */
|
|
18
|
+
cta?: string;
|
|
19
|
+
}
|
|
20
|
+
export type ShopGPTSessionStatus = "created" | "scanned" | "viewing" | "added_to_cart" | "completed" | "expired";
|
|
21
|
+
export interface ShopGPTSession {
|
|
13
22
|
id: string;
|
|
14
|
-
product:
|
|
15
|
-
status:
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
product: ShopGPTProduct;
|
|
24
|
+
status: ShopGPTSessionStatus;
|
|
25
|
+
rankupid?: string;
|
|
26
|
+
cta?: string;
|
|
18
27
|
mobileUrl: string;
|
|
19
28
|
cartCount: number;
|
|
20
29
|
scanCount: number;
|
|
21
30
|
createdAt: string;
|
|
22
31
|
updatedAt: string;
|
|
23
32
|
}
|
|
24
|
-
export interface
|
|
33
|
+
export interface ShopGPTConfig {
|
|
25
34
|
/** e.g. "https://shop.example.com" — base the SDK falls back to for links. Defaults to window.location.origin */
|
|
26
35
|
apiBase?: string;
|
|
27
|
-
placementId?: string;
|
|
28
|
-
campaignId?: string;
|
|
29
36
|
pollIntervalMs?: number;
|
|
30
37
|
fetchImpl?: typeof fetch;
|
|
31
38
|
}
|
|
32
|
-
export type
|
|
39
|
+
export type ShopGPTEvent = {
|
|
33
40
|
type: "session_created";
|
|
34
|
-
session:
|
|
41
|
+
session: ShopGPTSession;
|
|
35
42
|
} | {
|
|
36
43
|
type: "session_updated";
|
|
37
|
-
session:
|
|
44
|
+
session: ShopGPTSession;
|
|
38
45
|
} | {
|
|
39
46
|
type: "scanned";
|
|
40
|
-
session:
|
|
47
|
+
session: ShopGPTSession;
|
|
41
48
|
} | {
|
|
42
49
|
type: "added_to_cart";
|
|
43
|
-
session:
|
|
50
|
+
session: ShopGPTSession;
|
|
44
51
|
};
|
package/dist/types.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
//
|
|
1
|
+
// ShopGPT CTV SDK — shared types (framework agnostic)
|
|
2
2
|
export {};
|
package/package.json
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "shopgpt",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"main": "./dist/index.js",
|
|
6
|
-
"types": "./dist/index.d.ts",
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
"."
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "shopgpt",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "ShopGPT Shoppable CTV Ad SDK — remote CTA to cart with live TV conversion events. Works on any HTML5 CTV player (Tizen, webOS, FireTV, AndroidTV).",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./dist/index.js",
|
|
14
|
+
"./react": "./dist/react.js"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"peerDependenciesMeta": {
|
|
23
|
+
"react": {
|
|
24
|
+
"optional": true
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT"
|
|
28
|
+
}
|
package/dist/qr.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/** QR helpers — zero dependency. Uses api.qrserver.com image CDN by default. */
|
|
2
|
-
/**
|
|
3
|
-
* Legacy fallback deep-link builder.
|
|
4
|
-
* Prefer `session.mobileUrl` from the server — it already points directly at
|
|
5
|
-
* the ShopGPT product dialog (?product_id=…&ctv=…).
|
|
6
|
-
*/
|
|
7
|
-
export declare function buildMobileUrl(apiBase: string, sessionId: string): string;
|
|
8
|
-
export declare function buildQRImageUrl(mobileUrl: string, size?: number, opts?: {
|
|
9
|
-
color?: string;
|
|
10
|
-
bgcolor?: string;
|
|
11
|
-
}): string;
|
|
12
|
-
/** Short human code shown under QR for manual entry (last 6 of session id). */
|
|
13
|
-
export declare function shortCode(sessionId: string): string;
|
package/dist/qr.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/** QR helpers — zero dependency. Uses api.qrserver.com image CDN by default. */
|
|
2
|
-
/**
|
|
3
|
-
* Legacy fallback deep-link builder.
|
|
4
|
-
* Prefer `session.mobileUrl` from the server — it already points directly at
|
|
5
|
-
* the ShopGPT product dialog (?product_id=…&ctv=…).
|
|
6
|
-
*/
|
|
7
|
-
export function buildMobileUrl(apiBase, sessionId) {
|
|
8
|
-
const base = apiBase.replace(/\/$/, "");
|
|
9
|
-
return `${base}/c/${sessionId}`;
|
|
10
|
-
}
|
|
11
|
-
export function buildQRImageUrl(mobileUrl, size = 220, opts) {
|
|
12
|
-
const params = new URLSearchParams({
|
|
13
|
-
size: `${size}x${size}`,
|
|
14
|
-
data: mobileUrl,
|
|
15
|
-
color: (opts?.color ?? "111827").replace("#", ""),
|
|
16
|
-
bgcolor: (opts?.bgcolor ?? "ffffff").replace("#", ""),
|
|
17
|
-
qzone: "1",
|
|
18
|
-
format: "png",
|
|
19
|
-
});
|
|
20
|
-
return `https://api.qrserver.com/v1/create-qr-code/?${params.toString()}`;
|
|
21
|
-
}
|
|
22
|
-
/** Short human code shown under QR for manual entry (last 6 of session id). */
|
|
23
|
-
export function shortCode(sessionId) {
|
|
24
|
-
return sessionId.replace(/[^a-zA-Z0-9]/g, "").slice(-6).toUpperCase() || "ENEM01";
|
|
25
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import type { EnemCTVConfig, EnemEvent, EnemProduct, EnemSession } from "./types";
|
|
2
|
-
import { buildMobileUrl, buildQRImageUrl } from "./qr";
|
|
3
|
-
|
|
4
|
-
export * from "./types";
|
|
5
|
-
export * from "./qr";
|
|
6
|
-
|
|
7
|
-
type Listener = (e: EnemEvent) => void;
|
|
8
|
-
|
|
9
|
-
function resolveBase(cfg?: EnemCTVConfig): string {
|
|
10
|
-
if (cfg?.apiBase) return cfg.apiBase.replace(/\/$/, "");
|
|
11
|
-
if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
|
|
12
|
-
return "";
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* EnemCTV — framework-agnostic core.
|
|
17
|
-
*
|
|
18
|
-
* TV side: createSession() → render QR → pollSession()
|
|
19
|
-
* Phone side: getSession() → markViewed() → addToCart()
|
|
20
|
-
*
|
|
21
|
-
* @example
|
|
22
|
-
* const ctv = new EnemCTV({ placementId: "living-room-hero" });
|
|
23
|
-
* const { session, qrImageUrl } = await ctv.createSession(product);
|
|
24
|
-
* ctv.pollSession(session.id, (e) => console.log(e.type));
|
|
25
|
-
*/
|
|
26
|
-
export class EnemCTV {
|
|
27
|
-
private cfg: EnemCTVConfig;
|
|
28
|
-
private listeners = new Set<Listener>();
|
|
29
|
-
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
30
|
-
|
|
31
|
-
constructor(config: EnemCTVConfig = {}) {
|
|
32
|
-
this.cfg = { pollIntervalMs: 2500, ...config };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
get apiBase(): string {
|
|
36
|
-
return resolveBase(this.cfg);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
onEvent(fn: Listener): () => void {
|
|
40
|
-
this.listeners.add(fn);
|
|
41
|
-
return () => this.listeners.delete(fn);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
private emit(e: EnemEvent) {
|
|
45
|
-
this.listeners.forEach((fn) => {
|
|
46
|
-
try {
|
|
47
|
-
fn(e);
|
|
48
|
-
} catch {
|
|
49
|
-
/* noop */
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
private f(): typeof fetch {
|
|
55
|
-
return this.cfg.fetchImpl ?? fetch.bind(globalThis);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ── TV: create a shoppable session ──────────────────────────────
|
|
59
|
-
async createSession(
|
|
60
|
-
product: EnemProduct,
|
|
61
|
-
opts?: { campaignId?: string; placementId?: string; ttlSec?: number }
|
|
62
|
-
): Promise<{ session: EnemSession; mobileUrl: string; qrImageUrl: string }> {
|
|
63
|
-
const res = await this.f()(`${this.apiBase}/api/ctv/session`, {
|
|
64
|
-
method: "POST",
|
|
65
|
-
headers: { "content-type": "application/json" },
|
|
66
|
-
body: JSON.stringify({
|
|
67
|
-
product,
|
|
68
|
-
placementId: opts?.placementId ?? this.cfg.placementId,
|
|
69
|
-
campaignId: opts?.campaignId ?? this.cfg.campaignId,
|
|
70
|
-
ttlSec: opts?.ttlSec ?? 600,
|
|
71
|
-
}),
|
|
72
|
-
});
|
|
73
|
-
if (!res.ok) throw new Error(`createSession failed: ${res.status}`);
|
|
74
|
-
const session = (await res.json()).session as EnemSession;
|
|
75
|
-
const mobileUrl = session.mobileUrl || buildMobileUrl(this.apiBase, session.id);
|
|
76
|
-
const qrImageUrl = buildQRImageUrl(mobileUrl);
|
|
77
|
-
this.emit({ type: "session_created", session });
|
|
78
|
-
return { session, mobileUrl, qrImageUrl };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ── Shared: fetch one session ───────────────────────────────────
|
|
82
|
-
async getSession(sessionId: string): Promise<EnemSession> {
|
|
83
|
-
const res = await this.f()(`${this.apiBase}/api/ctv/session/${sessionId}`, {
|
|
84
|
-
cache: "no-store",
|
|
85
|
-
});
|
|
86
|
-
if (!res.ok) throw new Error(`getSession failed: ${res.status}`);
|
|
87
|
-
return (await res.json()).session as EnemSession;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ── TV: poll until scanned / added_to_cart ──────────────────────
|
|
91
|
-
pollSession(sessionId: string, onUpdate?: (s: EnemSession) => void): () => void {
|
|
92
|
-
this.stopPolling();
|
|
93
|
-
let lastStatus = "";
|
|
94
|
-
const tick = async () => {
|
|
95
|
-
try {
|
|
96
|
-
const s = await this.getSession(sessionId);
|
|
97
|
-
if (s.status !== lastStatus) {
|
|
98
|
-
lastStatus = s.status;
|
|
99
|
-
onUpdate?.(s);
|
|
100
|
-
this.emit({ type: "session_updated", session: s });
|
|
101
|
-
if (s.status === "scanned" || s.status === "viewing")
|
|
102
|
-
this.emit({ type: "scanned", session: s });
|
|
103
|
-
if (s.status === "added_to_cart" || s.status === "completed")
|
|
104
|
-
this.emit({ type: "added_to_cart", session: s });
|
|
105
|
-
}
|
|
106
|
-
if (s.status === "completed" || s.status === "expired") this.stopPolling();
|
|
107
|
-
} catch {
|
|
108
|
-
/* transient — keep polling */
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
void tick();
|
|
112
|
-
this.pollTimer = setInterval(tick, this.cfg.pollIntervalMs ?? 2500);
|
|
113
|
-
return () => this.stopPolling();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
stopPolling() {
|
|
117
|
-
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
118
|
-
this.pollTimer = null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ── Phone: mark scanned/viewing ─────────────────────────────────
|
|
122
|
-
async markScanned(sessionId: string): Promise<EnemSession> {
|
|
123
|
-
const res = await this.f()(`${this.apiBase}/api/ctv/session/${sessionId}`, {
|
|
124
|
-
method: "PATCH",
|
|
125
|
-
headers: { "content-type": "application/json" },
|
|
126
|
-
body: JSON.stringify({ status: "scanned" }),
|
|
127
|
-
});
|
|
128
|
-
if (!res.ok) throw new Error(`markScanned failed: ${res.status}`);
|
|
129
|
-
return (await res.json()).session as EnemSession;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ── Phone: add to cart (marks session converted) ────────────────
|
|
133
|
-
async addToCart(
|
|
134
|
-
sessionId: string,
|
|
135
|
-
opts?: { qty?: number; product?: EnemProduct }
|
|
136
|
-
): Promise<{ ok: boolean; session: EnemSession }> {
|
|
137
|
-
const res = await this.f()(`${this.apiBase}/api/ctv/cart`, {
|
|
138
|
-
method: "POST",
|
|
139
|
-
headers: { "content-type": "application/json" },
|
|
140
|
-
body: JSON.stringify({ sessionId, qty: opts?.qty ?? 1, product: opts?.product }),
|
|
141
|
-
});
|
|
142
|
-
if (!res.ok) throw new Error(`addToCart failed: ${res.status}`);
|
|
143
|
-
const data = await res.json();
|
|
144
|
-
if (data.session) this.emit({ type: "added_to_cart", session: data.session });
|
|
145
|
-
return data;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ── Analytics (fire-and-forget) ─────────────────────────────────
|
|
149
|
-
track(event: string, props?: Record<string, unknown>): void {
|
|
150
|
-
try {
|
|
151
|
-
void this.f()(`${this.apiBase}/api/ctv/session/track`, {
|
|
152
|
-
method: "POST",
|
|
153
|
-
headers: { "content-type": "application/json" },
|
|
154
|
-
body: JSON.stringify({ event, ...props }),
|
|
155
|
-
});
|
|
156
|
-
} catch {
|
|
157
|
-
/* noop */
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Vanilla script-tag helper: <div data-enem-ctv data-product-id="..."> */
|
|
163
|
-
export function autoInit(selector = "[data-enem-ctv]"): EnemCTV {
|
|
164
|
-
const client = new EnemCTV();
|
|
165
|
-
if (typeof document !== "undefined") {
|
|
166
|
-
document.querySelectorAll(selector).forEach((el) => {
|
|
167
|
-
el.setAttribute("data-enem-ctv-ready", "true");
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
return client;
|
|
171
|
-
}
|
package/src/qr.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/** QR helpers — zero dependency. Uses api.qrserver.com image CDN by default. */
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Legacy fallback deep-link builder.
|
|
5
|
-
* Prefer `session.mobileUrl` from the server — it already points directly at
|
|
6
|
-
* the ShopGPT product dialog (?product_id=…&ctv=…).
|
|
7
|
-
*/
|
|
8
|
-
export function buildMobileUrl(apiBase: string, sessionId: string): string {
|
|
9
|
-
const base = apiBase.replace(/\/$/, "");
|
|
10
|
-
return `${base}/c/${sessionId}`;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function buildQRImageUrl(
|
|
14
|
-
mobileUrl: string,
|
|
15
|
-
size = 220,
|
|
16
|
-
opts?: { color?: string; bgcolor?: string }
|
|
17
|
-
): string {
|
|
18
|
-
const params = new URLSearchParams({
|
|
19
|
-
size: `${size}x${size}`,
|
|
20
|
-
data: mobileUrl,
|
|
21
|
-
color: (opts?.color ?? "111827").replace("#", ""),
|
|
22
|
-
bgcolor: (opts?.bgcolor ?? "ffffff").replace("#", ""),
|
|
23
|
-
qzone: "1",
|
|
24
|
-
format: "png",
|
|
25
|
-
});
|
|
26
|
-
return `https://api.qrserver.com/v1/create-qr-code/?${params.toString()}`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Short human code shown under QR for manual entry (last 6 of session id). */
|
|
30
|
-
export function shortCode(sessionId: string): string {
|
|
31
|
-
return sessionId.replace(/[^a-zA-Z0-9]/g, "").slice(-6).toUpperCase() || "ENEM01";
|
|
32
|
-
}
|
package/src/react.tsx
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { EnemCTV } from "./index";
|
|
4
|
-
import { buildQRImageUrl, shortCode } from "./qr";
|
|
5
|
-
import type { EnemProduct, EnemSession } from "./types";
|
|
6
|
-
|
|
7
|
-
// ── Hook: live session (TV side polling) ──────────────────────────
|
|
8
|
-
export function useEnemSession(sessionId: string | null, apiBase?: string) {
|
|
9
|
-
const [session, setSession] = useState<EnemSession | null>(null);
|
|
10
|
-
const [error, setError] = useState<string | null>(null);
|
|
11
|
-
|
|
12
|
-
useEffect(() => {
|
|
13
|
-
if (!sessionId) return;
|
|
14
|
-
const client = new EnemCTV(apiBase ? { apiBase } : {});
|
|
15
|
-
let stop = () => {};
|
|
16
|
-
client
|
|
17
|
-
.getSession(sessionId)
|
|
18
|
-
.then((s) => {
|
|
19
|
-
setSession(s);
|
|
20
|
-
stop = client.pollSession(sessionId, setSession);
|
|
21
|
-
})
|
|
22
|
-
.catch((e) => setError(String(e?.message ?? e)));
|
|
23
|
-
return () => stop();
|
|
24
|
-
}, [sessionId, apiBase]);
|
|
25
|
-
|
|
26
|
-
return { session, error };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// ── Hook: create session for a product (TV ad start) ──────────────
|
|
30
|
-
export function useEnemShoppableAd(
|
|
31
|
-
product: EnemProduct | null,
|
|
32
|
-
opts?: { placementId?: string; campaignId?: string; apiBase?: string; autoCreate?: boolean }
|
|
33
|
-
) {
|
|
34
|
-
const [session, setSession] = useState<EnemSession | null>(null);
|
|
35
|
-
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
|
|
36
|
-
const [loading, setLoading] = useState(false);
|
|
37
|
-
const clientRef = useRef<EnemCTV | null>(null);
|
|
38
|
-
if (!clientRef.current) clientRef.current = new EnemCTV(opts?.apiBase ? { apiBase: opts.apiBase } : {});
|
|
39
|
-
|
|
40
|
-
const create = useCallback(async () => {
|
|
41
|
-
if (!product) return null;
|
|
42
|
-
setLoading(true);
|
|
43
|
-
try {
|
|
44
|
-
const r = await clientRef.current!.createSession(product, {
|
|
45
|
-
placementId: opts?.placementId,
|
|
46
|
-
campaignId: opts?.campaignId,
|
|
47
|
-
});
|
|
48
|
-
setSession(r.session);
|
|
49
|
-
setQrImageUrl(r.qrImageUrl);
|
|
50
|
-
clientRef.current!.pollSession(r.session.id, setSession);
|
|
51
|
-
return r.session;
|
|
52
|
-
} finally {
|
|
53
|
-
setLoading(false);
|
|
54
|
-
}
|
|
55
|
-
}, [product, opts?.placementId, opts?.campaignId]);
|
|
56
|
-
|
|
57
|
-
useEffect(() => {
|
|
58
|
-
if (opts?.autoCreate !== false && product && !session) void create();
|
|
59
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
-
}, [product?.id]);
|
|
61
|
-
|
|
62
|
-
useEffect(() => () => clientRef.current?.stopPolling(), []);
|
|
63
|
-
|
|
64
|
-
return { session, qrImageUrl, loading, create };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ── Component: Add-to-Cart button for CTV (10-foot UI, no QR needed) ──
|
|
68
|
-
// Calls ctv.addToCart(sessionId) so the SDK emits the `added_to_cart` flag;
|
|
69
|
-
// the TV poll then flips the spot to the converted state.
|
|
70
|
-
export function EnemAddToCartButton({
|
|
71
|
-
session,
|
|
72
|
-
client,
|
|
73
|
-
apiBase,
|
|
74
|
-
qty = 1,
|
|
75
|
-
label = "Add to Cart",
|
|
76
|
-
onAdded,
|
|
77
|
-
className,
|
|
78
|
-
}: {
|
|
79
|
-
session: EnemSession | null;
|
|
80
|
-
client?: EnemCTV;
|
|
81
|
-
apiBase?: string;
|
|
82
|
-
qty?: number;
|
|
83
|
-
label?: string;
|
|
84
|
-
onAdded?: (s: EnemSession) => void;
|
|
85
|
-
className?: string;
|
|
86
|
-
}) {
|
|
87
|
-
const [adding, setAdding] = useState(false);
|
|
88
|
-
const converted =
|
|
89
|
-
session?.status === "added_to_cart" || session?.status === "completed";
|
|
90
|
-
if (!session) return null;
|
|
91
|
-
if (converted) {
|
|
92
|
-
return (
|
|
93
|
-
<p className="inline-block rounded-full bg-emerald-500 px-3 py-1 text-xs font-bold text-white">
|
|
94
|
-
✓ Added to cart
|
|
95
|
-
</p>
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
return (
|
|
99
|
-
<button
|
|
100
|
-
type="button"
|
|
101
|
-
disabled={adding}
|
|
102
|
-
onClick={async () => {
|
|
103
|
-
const c = client ?? new EnemCTV(apiBase ? { apiBase } : {});
|
|
104
|
-
setAdding(true);
|
|
105
|
-
try {
|
|
106
|
-
const res = await c.addToCart(session.id, { qty });
|
|
107
|
-
if (res.session) onAdded?.(res.session);
|
|
108
|
-
} finally {
|
|
109
|
-
setAdding(false);
|
|
110
|
-
}
|
|
111
|
-
}}
|
|
112
|
-
className={
|
|
113
|
-
className ??
|
|
114
|
-
"rounded-full bg-white px-6 py-2.5 text-sm font-bold text-black transition hover:bg-emerald-300 disabled:opacity-50"
|
|
115
|
-
}
|
|
116
|
-
>
|
|
117
|
-
{adding ? "Adding…" : label}
|
|
118
|
-
</button>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ── Component: QR overlay for CTV (10-foot UI) ─────────────────────
|
|
123
|
-
// Legacy phone-scan path. Prefer EnemAddToCartButton for remote-driven
|
|
124
|
-
// spots — it raises the same `added_to_cart` SDK flag with no QR.
|
|
125
|
-
export function EnemQROverlay({
|
|
126
|
-
session,
|
|
127
|
-
qrImageUrl,
|
|
128
|
-
position = "bottom-right",
|
|
129
|
-
theme = "dark",
|
|
130
|
-
}: {
|
|
131
|
-
session: EnemSession | null;
|
|
132
|
-
qrImageUrl: string | null;
|
|
133
|
-
position?: "bottom-right" | "bottom-left" | "right";
|
|
134
|
-
theme?: "dark" | "light";
|
|
135
|
-
}) {
|
|
136
|
-
if (!session || !qrImageUrl) return null;
|
|
137
|
-
const pos =
|
|
138
|
-
position === "bottom-left"
|
|
139
|
-
? "left-6 bottom-6"
|
|
140
|
-
: position === "right"
|
|
141
|
-
? "right-6 top-1/2 -translate-y-1/2"
|
|
142
|
-
: "right-6 bottom-6";
|
|
143
|
-
const converted = session.status === "added_to_cart" || session.status === "completed";
|
|
144
|
-
const scanned = session.status === "scanned" || session.status === "viewing" || converted;
|
|
145
|
-
return (
|
|
146
|
-
<div
|
|
147
|
-
className={`absolute ${pos} z-20 flex items-center gap-4 rounded-2xl p-4 shadow-2xl backdrop-blur ${
|
|
148
|
-
theme === "dark" ? "bg-black/70 text-white" : "bg-white/95 text-gray-900"
|
|
149
|
-
}`}
|
|
150
|
-
>
|
|
151
|
-
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
152
|
-
<img src={qrImageUrl} alt="Scan to shop" width={132} height={132} className="rounded-xl bg-white p-1" />
|
|
153
|
-
<div className="pr-2">
|
|
154
|
-
<p className="text-[11px] font-bold uppercase tracking-[0.2em] opacity-70">Shop this ad</p>
|
|
155
|
-
<p className="mt-1 max-w-[180px] truncate text-sm font-semibold">{session.product.title}</p>
|
|
156
|
-
<p className="text-sm opacity-80">{session.product.salePrice ?? session.product.price}</p>
|
|
157
|
-
{converted ? (
|
|
158
|
-
<p className="mt-2 inline-block rounded-full bg-emerald-500 px-3 py-1 text-xs font-bold text-white">
|
|
159
|
-
✓ Added to cart
|
|
160
|
-
</p>
|
|
161
|
-
) : scanned ? (
|
|
162
|
-
<p className="mt-2 inline-block rounded-full bg-sky-500 px-3 py-1 text-xs font-bold text-white">
|
|
163
|
-
● Phone connected
|
|
164
|
-
</p>
|
|
165
|
-
) : (
|
|
166
|
-
<p className="mt-2 text-xs opacity-80">
|
|
167
|
-
Scan with camera
|
|
168
|
-
<span className="ml-2 rounded bg-white/20 px-1.5 py-0.5 font-mono">{shortCode(session.id)}</span>
|
|
169
|
-
</p>
|
|
170
|
-
)}
|
|
171
|
-
</div>
|
|
172
|
-
</div>
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ── Component: minimal CTV player wrapper ─────────────────────────
|
|
177
|
-
// Remote-first: shows Add to Cart (raises `added_to_cart` flag). Opt into
|
|
178
|
-
// the legacy QR overlay with showQR.
|
|
179
|
-
export function EnemCTVPlayer({
|
|
180
|
-
videoSrc,
|
|
181
|
-
product,
|
|
182
|
-
placementId,
|
|
183
|
-
campaignId,
|
|
184
|
-
apiBase,
|
|
185
|
-
showQR = false,
|
|
186
|
-
onConverted,
|
|
187
|
-
}: {
|
|
188
|
-
videoSrc: string;
|
|
189
|
-
product: EnemProduct;
|
|
190
|
-
placementId?: string;
|
|
191
|
-
campaignId?: string;
|
|
192
|
-
apiBase?: string;
|
|
193
|
-
showQR?: boolean;
|
|
194
|
-
onConverted?: (s: EnemSession) => void;
|
|
195
|
-
}) {
|
|
196
|
-
const { session, qrImageUrl } = useEnemShoppableAd(product, {
|
|
197
|
-
placementId,
|
|
198
|
-
campaignId,
|
|
199
|
-
apiBase,
|
|
200
|
-
});
|
|
201
|
-
const mobileUrl = session
|
|
202
|
-
? buildQRImageUrl(session.mobileUrl) && session.mobileUrl
|
|
203
|
-
: null;
|
|
204
|
-
|
|
205
|
-
useEffect(() => {
|
|
206
|
-
if (
|
|
207
|
-
session &&
|
|
208
|
-
(session.status === "added_to_cart" || session.status === "completed")
|
|
209
|
-
)
|
|
210
|
-
onConverted?.(session);
|
|
211
|
-
}, [session, onConverted]);
|
|
212
|
-
|
|
213
|
-
return (
|
|
214
|
-
<div className="relative aspect-video w-full overflow-hidden rounded-2xl bg-black">
|
|
215
|
-
<video src={videoSrc} controls autoPlay muted loop playsInline className="h-full w-full object-cover" />
|
|
216
|
-
{showQR ? (
|
|
217
|
-
<EnemQROverlay session={session} qrImageUrl={qrImageUrl} />
|
|
218
|
-
) : (
|
|
219
|
-
session && (
|
|
220
|
-
<div className="absolute bottom-6 right-6 z-20 rounded-2xl bg-black/70 p-4 text-white shadow-2xl backdrop-blur">
|
|
221
|
-
<EnemAddToCartButton session={session} apiBase={apiBase} />
|
|
222
|
-
</div>
|
|
223
|
-
)
|
|
224
|
-
)}
|
|
225
|
-
{mobileUrl && showQR ? (
|
|
226
|
-
<div className="absolute left-6 top-6 rounded-full bg-black/60 px-3 py-1 font-mono text-[11px] text-white/80">
|
|
227
|
-
{mobileUrl}
|
|
228
|
-
</div>
|
|
229
|
-
) : null}
|
|
230
|
-
</div>
|
|
231
|
-
);
|
|
232
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
// Enem CTV SDK — shared types (framework agnostic)
|
|
2
|
-
|
|
3
|
-
export interface EnemProduct {
|
|
4
|
-
id: string;
|
|
5
|
-
title: string;
|
|
6
|
-
brand?: string;
|
|
7
|
-
price: string; // e.g. "999 INR"
|
|
8
|
-
salePrice?: string;
|
|
9
|
-
imageUrl: string;
|
|
10
|
-
url?: string;
|
|
11
|
-
description?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export type EnemSessionStatus =
|
|
15
|
-
| "created"
|
|
16
|
-
| "scanned"
|
|
17
|
-
| "viewing"
|
|
18
|
-
| "added_to_cart"
|
|
19
|
-
| "completed"
|
|
20
|
-
| "expired";
|
|
21
|
-
|
|
22
|
-
export interface EnemSession {
|
|
23
|
-
id: string;
|
|
24
|
-
product: EnemProduct;
|
|
25
|
-
status: EnemSessionStatus;
|
|
26
|
-
placementId?: string;
|
|
27
|
-
campaignId?: string;
|
|
28
|
-
mobileUrl: string; // deep link phone opens after scan
|
|
29
|
-
cartCount: number;
|
|
30
|
-
scanCount: number;
|
|
31
|
-
createdAt: string;
|
|
32
|
-
updatedAt: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface EnemCTVConfig {
|
|
36
|
-
/** e.g. "https://shop.example.com" — base the SDK falls back to for links. Defaults to window.location.origin */
|
|
37
|
-
apiBase?: string;
|
|
38
|
-
placementId?: string;
|
|
39
|
-
campaignId?: string;
|
|
40
|
-
pollIntervalMs?: number;
|
|
41
|
-
fetchImpl?: typeof fetch;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export type EnemEvent =
|
|
45
|
-
| { type: "session_created"; session: EnemSession }
|
|
46
|
-
| { type: "session_updated"; session: EnemSession }
|
|
47
|
-
| { type: "scanned"; session: EnemSession }
|
|
48
|
-
| { type: "added_to_cart"; session: EnemSession };
|
package/tsconfig.json
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2020",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"declaration": true,
|
|
7
|
-
"outDir": "dist",
|
|
8
|
-
"rootDir": "src",
|
|
9
|
-
"strict": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"jsx": "react-jsx",
|
|
12
|
-
"lib": ["ES2020", "DOM"],
|
|
13
|
-
"types": ["react"]
|
|
14
|
-
},
|
|
15
|
-
"include": ["src"]
|
|
16
|
-
}
|