shopgpt 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 ADDED
@@ -0,0 +1,78 @@
1
+ # @enem/ctv-sdk — CTV Shoppable Ad SDK
2
+
3
+ TV ad plays → QR overlay → phone scans → PDP opens → Add to Cart → 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 { EnemCTV } from "@enem/ctv-sdk";
11
+
12
+ const ctv = new EnemCTV({ placementId: "hero-ctv" });
13
+
14
+ // TV side — when ad starts
15
+ const { session, qrImageUrl } = await ctv.createSession({
16
+ id: "sku-123",
17
+ title: "Enem Runner",
18
+ price: "3499 INR",
19
+ imageUrl: "https://…/shoe.png",
20
+ });
21
+ // render qrImageUrl in 10-foot overlay, mobileUrl = session.mobileUrl
22
+
23
+ // TV side — live updates (scanned → added_to_cart)
24
+ ctv.pollSession(session.id, (s) => {
25
+ if (s.status === "scanned") showToast("Phone connected");
26
+ if (s.status === "added_to_cart") showToast(`Added ×${s.cartCount}`);
27
+ });
28
+ ```
29
+
30
+ ```ts
31
+ // Phone side — /c/:sessionId page
32
+ await ctv.markScanned(sessionId); // TV shows "connected"
33
+ await ctv.addToCart(sessionId, { qty: 1 }); // TV shows "Added to cart"
34
+ ```
35
+
36
+ ## React
37
+
38
+ ```tsx
39
+ import { EnemCTVPlayer, EnemQROverlay, useEnemShoppableAd } from "@enem/ctv-sdk/react";
40
+
41
+ <EnemCTVPlayer videoSrc="https://…/ad.mp4" product={product} placementId="hero" />
42
+ ```
43
+
44
+ ## Backend (included in this repo)
45
+
46
+ | Method | Route | Purpose |
47
+ |---|---|---|
48
+ | POST | `/api/ctv/session` | create session `{ product, placementId, campaignId }` |
49
+ | GET | `/api/ctv/session/:id` | poll session (no-store) |
50
+ | PATCH | `/api/ctv/session/:id` | `{ status: "scanned" \| "viewing" \| "completed" }` |
51
+ | POST | `/api/ctv/cart` | `{ sessionId, qty }` → marks `added_to_cart` |
52
+ | GET | `/api/ctv/cart?sessionId=` | list cart items |
53
+
54
+ Demo: `/ctv-demo/tv` (TV) → scan QR → ShopGPT product dialog directly.
55
+
56
+ ## ShopGPT handoff (CTV → shopgpt.rankup.diy)
57
+
58
+ QR encodes the ShopGPT deep link directly (no interstitial page):
59
+
60
+ ```
61
+ https://shopgpt.rankup.diy/?product_id=<acp_item_id>&ctv=<sessionId>
62
+ ```
63
+
64
+ `?product_id=` opens the ShopGPT product dialog (chat + add to cart);
65
+ `&ctv=` lets ShopGPT beacon `scanned` / `added_to_cart` back to
66
+ `/api/ctv/session/:id` and `/api/ctv/cart` (CORS open) so the TV updates live.
67
+ `POST /api/ctv/session` returns this as `session.mobileUrl`, and per-product
68
+ URLs are included in `GET /api/ctv/products` as `shopgptUrl`
69
+ (append `&ctv=<sessionId>` at session time) — see `lib/ctv/shopgpt.ts`.
70
+
71
+ ## Production notes
72
+
73
+ - Current store is in-memory (`lib/ctv/store.ts`). Move to Supabase `ctv_sessions` + `ctv_cart_items` or Upstash Redis for multi-instance.
74
+ - QR uses `api.qrserver.com` image CDN (zero dep). Swap to server-side `qrcode` lib if offline needed.
75
+ - Wire `product` from Shopify / Woo / ACP feed (`lib/acp`, `app/shop/[slug]/[itemId]`).
76
+ - Analytics: `ctv.track("impression", { sessionId })` → POST `/api/ctv/session/track` (stub, add table).
77
+ ```
78
+
@@ -0,0 +1,48 @@
1
+ import type { EnemCTVConfig, EnemEvent, EnemProduct, EnemSession } from "./types";
2
+ export * from "./types";
3
+ export * from "./qr";
4
+ type Listener = (e: EnemEvent) => void;
5
+ /**
6
+ * EnemCTV — framework-agnostic core.
7
+ *
8
+ * TV side: createSession() → render QR → pollSession()
9
+ * Phone side: getSession() → markViewed() → addToCart()
10
+ *
11
+ * @example
12
+ * const ctv = new EnemCTV({ placementId: "living-room-hero" });
13
+ * const { session, qrImageUrl } = await ctv.createSession(product);
14
+ * ctv.pollSession(session.id, (e) => console.log(e.type));
15
+ */
16
+ export declare class EnemCTV {
17
+ private cfg;
18
+ private listeners;
19
+ private pollTimer;
20
+ constructor(config?: EnemCTVConfig);
21
+ get apiBase(): string;
22
+ onEvent(fn: Listener): () => void;
23
+ private emit;
24
+ private f;
25
+ createSession(product: EnemProduct, opts?: {
26
+ campaignId?: string;
27
+ placementId?: string;
28
+ ttlSec?: number;
29
+ }): Promise<{
30
+ session: EnemSession;
31
+ mobileUrl: string;
32
+ qrImageUrl: string;
33
+ }>;
34
+ getSession(sessionId: string): Promise<EnemSession>;
35
+ pollSession(sessionId: string, onUpdate?: (s: EnemSession) => void): () => void;
36
+ stopPolling(): void;
37
+ markScanned(sessionId: string): Promise<EnemSession>;
38
+ addToCart(sessionId: string, opts?: {
39
+ qty?: number;
40
+ product?: EnemProduct;
41
+ }): Promise<{
42
+ ok: boolean;
43
+ session: EnemSession;
44
+ }>;
45
+ track(event: string, props?: Record<string, unknown>): void;
46
+ }
47
+ /** Vanilla script-tag helper: <div data-enem-ctv data-product-id="..."> */
48
+ export declare function autoInit(selector?: string): EnemCTV;
package/dist/index.js ADDED
@@ -0,0 +1,157 @@
1
+ import { buildMobileUrl, buildQRImageUrl } from "./qr";
2
+ export * from "./types";
3
+ export * from "./qr";
4
+ function resolveBase(cfg) {
5
+ if (cfg?.apiBase)
6
+ return cfg.apiBase.replace(/\/$/, "");
7
+ if (typeof window !== "undefined" && window.location?.origin)
8
+ return window.location.origin;
9
+ return "";
10
+ }
11
+ /**
12
+ * EnemCTV — framework-agnostic core.
13
+ *
14
+ * TV side: createSession() → render QR → pollSession()
15
+ * Phone side: getSession() → markViewed() → addToCart()
16
+ *
17
+ * @example
18
+ * const ctv = new EnemCTV({ placementId: "living-room-hero" });
19
+ * const { session, qrImageUrl } = await ctv.createSession(product);
20
+ * ctv.pollSession(session.id, (e) => console.log(e.type));
21
+ */
22
+ export class EnemCTV {
23
+ constructor(config = {}) {
24
+ this.listeners = new Set();
25
+ this.pollTimer = null;
26
+ this.cfg = { pollIntervalMs: 2500, ...config };
27
+ }
28
+ get apiBase() {
29
+ return resolveBase(this.cfg);
30
+ }
31
+ onEvent(fn) {
32
+ this.listeners.add(fn);
33
+ return () => this.listeners.delete(fn);
34
+ }
35
+ emit(e) {
36
+ this.listeners.forEach((fn) => {
37
+ try {
38
+ fn(e);
39
+ }
40
+ catch {
41
+ /* noop */
42
+ }
43
+ });
44
+ }
45
+ f() {
46
+ return this.cfg.fetchImpl ?? fetch.bind(globalThis);
47
+ }
48
+ // ── TV: create a shoppable session ──────────────────────────────
49
+ async createSession(product, opts) {
50
+ const res = await this.f()(`${this.apiBase}/api/ctv/session`, {
51
+ method: "POST",
52
+ headers: { "content-type": "application/json" },
53
+ body: JSON.stringify({
54
+ product,
55
+ placementId: opts?.placementId ?? this.cfg.placementId,
56
+ campaignId: opts?.campaignId ?? this.cfg.campaignId,
57
+ ttlSec: opts?.ttlSec ?? 600,
58
+ }),
59
+ });
60
+ if (!res.ok)
61
+ throw new Error(`createSession failed: ${res.status}`);
62
+ const session = (await res.json()).session;
63
+ const mobileUrl = session.mobileUrl || buildMobileUrl(this.apiBase, session.id);
64
+ const qrImageUrl = buildQRImageUrl(mobileUrl);
65
+ this.emit({ type: "session_created", session });
66
+ return { session, mobileUrl, qrImageUrl };
67
+ }
68
+ // ── Shared: fetch one session ───────────────────────────────────
69
+ async getSession(sessionId) {
70
+ const res = await this.f()(`${this.apiBase}/api/ctv/session/${sessionId}`, {
71
+ cache: "no-store",
72
+ });
73
+ if (!res.ok)
74
+ throw new Error(`getSession failed: ${res.status}`);
75
+ return (await res.json()).session;
76
+ }
77
+ // ── TV: poll until scanned / added_to_cart ──────────────────────
78
+ pollSession(sessionId, onUpdate) {
79
+ this.stopPolling();
80
+ let lastStatus = "";
81
+ const tick = async () => {
82
+ try {
83
+ const s = await this.getSession(sessionId);
84
+ if (s.status !== lastStatus) {
85
+ lastStatus = s.status;
86
+ onUpdate?.(s);
87
+ this.emit({ type: "session_updated", session: s });
88
+ if (s.status === "scanned" || s.status === "viewing")
89
+ this.emit({ type: "scanned", session: s });
90
+ if (s.status === "added_to_cart" || s.status === "completed")
91
+ this.emit({ type: "added_to_cart", session: s });
92
+ }
93
+ if (s.status === "completed" || s.status === "expired")
94
+ this.stopPolling();
95
+ }
96
+ catch {
97
+ /* transient — keep polling */
98
+ }
99
+ };
100
+ void tick();
101
+ this.pollTimer = setInterval(tick, this.cfg.pollIntervalMs ?? 2500);
102
+ return () => this.stopPolling();
103
+ }
104
+ stopPolling() {
105
+ if (this.pollTimer)
106
+ clearInterval(this.pollTimer);
107
+ this.pollTimer = null;
108
+ }
109
+ // ── Phone: mark scanned/viewing ─────────────────────────────────
110
+ async markScanned(sessionId) {
111
+ const res = await this.f()(`${this.apiBase}/api/ctv/session/${sessionId}`, {
112
+ method: "PATCH",
113
+ headers: { "content-type": "application/json" },
114
+ body: JSON.stringify({ status: "scanned" }),
115
+ });
116
+ if (!res.ok)
117
+ throw new Error(`markScanned failed: ${res.status}`);
118
+ return (await res.json()).session;
119
+ }
120
+ // ── Phone: add to cart (marks session converted) ────────────────
121
+ async addToCart(sessionId, opts) {
122
+ const res = await this.f()(`${this.apiBase}/api/ctv/cart`, {
123
+ method: "POST",
124
+ headers: { "content-type": "application/json" },
125
+ body: JSON.stringify({ sessionId, qty: opts?.qty ?? 1, product: opts?.product }),
126
+ });
127
+ if (!res.ok)
128
+ throw new Error(`addToCart failed: ${res.status}`);
129
+ const data = await res.json();
130
+ if (data.session)
131
+ this.emit({ type: "added_to_cart", session: data.session });
132
+ return data;
133
+ }
134
+ // ── Analytics (fire-and-forget) ─────────────────────────────────
135
+ track(event, props) {
136
+ try {
137
+ void this.f()(`${this.apiBase}/api/ctv/session/track`, {
138
+ method: "POST",
139
+ headers: { "content-type": "application/json" },
140
+ body: JSON.stringify({ event, ...props }),
141
+ });
142
+ }
143
+ catch {
144
+ /* noop */
145
+ }
146
+ }
147
+ }
148
+ /** Vanilla script-tag helper: <div data-enem-ctv data-product-id="..."> */
149
+ export function autoInit(selector = "[data-enem-ctv]") {
150
+ const client = new EnemCTV();
151
+ if (typeof document !== "undefined") {
152
+ document.querySelectorAll(selector).forEach((el) => {
153
+ el.setAttribute("data-enem-ctv-ready", "true");
154
+ });
155
+ }
156
+ return client;
157
+ }
package/dist/qr.d.ts ADDED
@@ -0,0 +1,13 @@
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 ADDED
@@ -0,0 +1,25 @@
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
+ }
@@ -0,0 +1,41 @@
1
+ import { EnemCTV } from "./index";
2
+ import type { EnemProduct, EnemSession } from "./types";
3
+ export declare function useEnemSession(sessionId: string | null, apiBase?: string): {
4
+ session: EnemSession | null;
5
+ error: string | null;
6
+ };
7
+ export declare function useEnemShoppableAd(product: EnemProduct | null, opts?: {
8
+ placementId?: string;
9
+ campaignId?: string;
10
+ apiBase?: string;
11
+ autoCreate?: boolean;
12
+ }): {
13
+ session: EnemSession | null;
14
+ qrImageUrl: string | null;
15
+ loading: boolean;
16
+ create: () => Promise<EnemSession | null>;
17
+ };
18
+ export declare function EnemAddToCartButton({ session, client, apiBase, qty, label, onAdded, className, }: {
19
+ session: EnemSession | null;
20
+ client?: EnemCTV;
21
+ apiBase?: string;
22
+ qty?: number;
23
+ label?: string;
24
+ onAdded?: (s: EnemSession) => void;
25
+ className?: string;
26
+ }): import("react/jsx-runtime").JSX.Element | null;
27
+ export declare function EnemQROverlay({ session, qrImageUrl, position, theme, }: {
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, }: {
34
+ videoSrc: string;
35
+ product: EnemProduct;
36
+ placementId?: string;
37
+ campaignId?: string;
38
+ apiBase?: string;
39
+ showQR?: boolean;
40
+ onConverted?: (s: EnemSession) => void;
41
+ }): import("react/jsx-runtime").JSX.Element;
package/dist/react.js ADDED
@@ -0,0 +1,118 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { EnemCTV } from "./index";
5
+ import { buildQRImageUrl, shortCode } from "./qr";
6
+ // ── Hook: live session (TV side polling) ──────────────────────────
7
+ export function useEnemSession(sessionId, apiBase) {
8
+ const [session, setSession] = useState(null);
9
+ const [error, setError] = useState(null);
10
+ useEffect(() => {
11
+ if (!sessionId)
12
+ return;
13
+ const client = new EnemCTV(apiBase ? { apiBase } : {});
14
+ let stop = () => { };
15
+ client
16
+ .getSession(sessionId)
17
+ .then((s) => {
18
+ setSession(s);
19
+ stop = client.pollSession(sessionId, setSession);
20
+ })
21
+ .catch((e) => setError(String(e?.message ?? e)));
22
+ return () => stop();
23
+ }, [sessionId, apiBase]);
24
+ return { session, error };
25
+ }
26
+ // ── Hook: create session for a product (TV ad start) ──────────────
27
+ export function useEnemShoppableAd(product, opts) {
28
+ const [session, setSession] = useState(null);
29
+ const [qrImageUrl, setQrImageUrl] = useState(null);
30
+ const [loading, setLoading] = useState(false);
31
+ const clientRef = useRef(null);
32
+ if (!clientRef.current)
33
+ clientRef.current = new EnemCTV(opts?.apiBase ? { apiBase: opts.apiBase } : {});
34
+ const create = useCallback(async () => {
35
+ if (!product)
36
+ return null;
37
+ setLoading(true);
38
+ try {
39
+ const r = await clientRef.current.createSession(product, {
40
+ placementId: opts?.placementId,
41
+ campaignId: opts?.campaignId,
42
+ });
43
+ setSession(r.session);
44
+ setQrImageUrl(r.qrImageUrl);
45
+ clientRef.current.pollSession(r.session.id, setSession);
46
+ return r.session;
47
+ }
48
+ finally {
49
+ setLoading(false);
50
+ }
51
+ }, [product, opts?.placementId, opts?.campaignId]);
52
+ useEffect(() => {
53
+ if (opts?.autoCreate !== false && product && !session)
54
+ void create();
55
+ // eslint-disable-next-line react-hooks/exhaustive-deps
56
+ }, [product?.id]);
57
+ useEffect(() => () => clientRef.current?.stopPolling(), []);
58
+ return { session, qrImageUrl, loading, create };
59
+ }
60
+ // ── Component: Add-to-Cart button for CTV (10-foot UI, no QR needed) ──
61
+ // Calls ctv.addToCart(sessionId) so the SDK emits the `added_to_cart` flag;
62
+ // the TV poll then flips the spot to the converted state.
63
+ export function EnemAddToCartButton({ session, client, apiBase, qty = 1, label = "Add to Cart", onAdded, className, }) {
64
+ const [adding, setAdding] = useState(false);
65
+ const converted = session?.status === "added_to_cart" || session?.status === "completed";
66
+ if (!session)
67
+ return null;
68
+ if (converted) {
69
+ 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
+ }
71
+ return (_jsx("button", { type: "button", disabled: adding, onClick: async () => {
72
+ const c = client ?? new EnemCTV(apiBase ? { apiBase } : {});
73
+ setAdding(true);
74
+ try {
75
+ const res = await c.addToCart(session.id, { qty });
76
+ if (res.session)
77
+ onAdded?.(res.session);
78
+ }
79
+ finally {
80
+ setAdding(false);
81
+ }
82
+ }, 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…" : label }));
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) })] }))] })] }));
99
+ }
100
+ // ── Component: minimal CTV player wrapper ─────────────────────────
101
+ // Remote-first: shows Add to Cart (raises `added_to_cart` flag). Opt into
102
+ // the legacy QR overlay with showQR.
103
+ export function EnemCTVPlayer({ videoSrc, product, placementId, campaignId, apiBase, showQR = false, onConverted, }) {
104
+ const { session, qrImageUrl } = useEnemShoppableAd(product, {
105
+ placementId,
106
+ campaignId,
107
+ apiBase,
108
+ });
109
+ const mobileUrl = session
110
+ ? buildQRImageUrl(session.mobileUrl) && session.mobileUrl
111
+ : null;
112
+ useEffect(() => {
113
+ if (session &&
114
+ (session.status === "added_to_cart" || session.status === "completed"))
115
+ onConverted?.(session);
116
+ }, [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" }), showQR ? (_jsx(EnemQROverlay, { session: session, qrImageUrl: qrImageUrl })) : (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: _jsx(EnemAddToCartButton, { session: session, apiBase: apiBase }) }))), mobileUrl && showQR ? (_jsx("div", { className: "absolute left-6 top-6 rounded-full bg-black/60 px-3 py-1 font-mono text-[11px] text-white/80", children: mobileUrl })) : null] }));
118
+ }
@@ -0,0 +1,44 @@
1
+ export interface EnemProduct {
2
+ id: string;
3
+ title: string;
4
+ brand?: string;
5
+ price: string;
6
+ salePrice?: string;
7
+ imageUrl: string;
8
+ url?: string;
9
+ description?: string;
10
+ }
11
+ export type EnemSessionStatus = "created" | "scanned" | "viewing" | "added_to_cart" | "completed" | "expired";
12
+ export interface EnemSession {
13
+ id: string;
14
+ product: EnemProduct;
15
+ status: EnemSessionStatus;
16
+ placementId?: string;
17
+ campaignId?: string;
18
+ mobileUrl: string;
19
+ cartCount: number;
20
+ scanCount: number;
21
+ createdAt: string;
22
+ updatedAt: string;
23
+ }
24
+ export interface EnemCTVConfig {
25
+ /** e.g. "https://shop.example.com" — base the SDK falls back to for links. Defaults to window.location.origin */
26
+ apiBase?: string;
27
+ placementId?: string;
28
+ campaignId?: string;
29
+ pollIntervalMs?: number;
30
+ fetchImpl?: typeof fetch;
31
+ }
32
+ export type EnemEvent = {
33
+ type: "session_created";
34
+ session: EnemSession;
35
+ } | {
36
+ type: "session_updated";
37
+ session: EnemSession;
38
+ } | {
39
+ type: "scanned";
40
+ session: EnemSession;
41
+ } | {
42
+ type: "added_to_cart";
43
+ session: EnemSession;
44
+ };
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // Enem CTV SDK — shared types (framework agnostic)
2
+ export {};
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "shopgpt",
3
+ "version": "0.1.0",
4
+ "description": "Enem CTV Shoppable Ad SDK — QR to mobile PDP to cart. Works on any HTML5 CTV player (Tizen, webOS, FireTV, AndroidTV).",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./dist/index.js",
10
+ "./react": "./dist/react.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.json"
14
+ },
15
+ "peerDependencies": {
16
+ "react": ">=18"
17
+ },
18
+ "peerDependenciesMeta": {
19
+ "react": { "optional": true }
20
+ },
21
+ "license": "MIT"
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,171 @@
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 ADDED
@@ -0,0 +1,32 @@
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 ADDED
@@ -0,0 +1,232 @@
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 ADDED
@@ -0,0 +1,48 @@
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 ADDED
@@ -0,0 +1,16 @@
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
+ }