From 4b897656fdb2fe42abc13f14bfe32b4d8cfa11e4 Mon Sep 17 00:00:00 2001 From: Pablo Reszczynski Date: Tue, 2 Jul 2024 11:32:55 -0400 Subject: [PATCH 1/9] wip --- package.json | 1 + pnpm-lock.yaml | 10 ++ src/auction.ts | 61 +++++++++ src/index.ts | 336 ++++++++++++++++++++++++------------------------- src/mixin.ts | 90 +++++++++++++ src/types.ts | 11 +- 6 files changed, 339 insertions(+), 170 deletions(-) create mode 100644 src/auction.ts create mode 100644 src/mixin.ts diff --git a/package.json b/package.json index 746dfac..f11b35d 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "vite-plugin-dts": "^3.8.3" }, "dependencies": { + "@lit/context": "^1.1.2", "@lit/task": "^1.0.1", "lit": "^3.1.3", "skeleton-webcomponent-loader": "^2.1.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43de585..4ad0467 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@lit/context': + specifier: ^1.1.2 + version: 1.1.2 '@lit/task': specifier: ^1.0.1 version: 1.0.1 @@ -250,6 +253,9 @@ packages: '@lit-labs/ssr-dom-shim@1.2.0': resolution: {integrity: sha512-yWJKmpGE6lUURKAaIltoPIE/wrbY3TEkqQt+X0m+7fQNnAv0keydnYvbiJFP1PnMhizmIWRWOG5KLhYyc/xl+g==} + '@lit/context@1.1.2': + resolution: {integrity: sha512-S0nw2C6Tkm7fVX5TGYqeROGD+Z9Coa2iFpW+ysYBDH3YvCqOY3wVQvSgwbaliLJkjTnSEYCBe9qFqKV8WUFpVw==} + '@lit/reactive-element@2.0.4': resolution: {integrity: sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==} @@ -851,6 +857,10 @@ snapshots: '@lit-labs/ssr-dom-shim@1.2.0': {} + '@lit/context@1.1.2': + dependencies: + '@lit/reactive-element': 2.0.4 + '@lit/reactive-element@2.0.4': dependencies: '@lit-labs/ssr-dom-shim': 1.2.0 diff --git a/src/auction.ts b/src/auction.ts new file mode 100644 index 0000000..a2b5441 --- /dev/null +++ b/src/auction.ts @@ -0,0 +1,61 @@ +import { TopsortRequestError } from "./errors"; +import type { Auction, Banner } from "./types"; + +export const getDeviceType = (): "mobile" | "desktop" => { + const ua = navigator.userAgent; + if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) { + //return "tablet"; + return "mobile"; + } + if ( + /Mobile|iP(hone|od)|Android|BlackBerry|IEMobile|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test( + ua, + ) + ) { + return "mobile"; + } + return "desktop"; +}; + +interface AuctionOptions { + signal: AbortSignal; + logError: (error: unknown) => void; +} + +export async function runAuction( + auction: Auction, + { signal, logError }: AuctionOptions, +): Promise { + console.debug("Running auction", auction); + const device = getDeviceType(); + const token = window.TS.token; + const url = window.TS.url || "https://api.topsort.com"; + console.debug(JSON.stringify({ auctions: [auction] }, null, 2)); + const res = await fetch(new URL(`${url}/v2/auctions`), { + method: "POST", + mode: "cors", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-UA": `topsort/banners-${import.meta.env.PACKAGE_VERSION} (${device}})`, + }, + body: JSON.stringify({ + auctions: [auction], + }), + signal, + }); + if (!res.ok) { + const error = await res.json(); + logError(error); + throw new Error(error.message); + } + const data = await res.json(); + console.debug(data); + const result = data.results[0]; + if (!result) throw new TopsortRequestError("No auction results", res.status); + if (result.error) { + logError(result.error); + throw new Error(result.error); + } + return result.winners; +} diff --git a/src/index.ts b/src/index.ts index dcb580b..5da4485 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,11 @@ +import { ContextProvider, consume, createContext, provide } from "@lit/context"; import { Task } from "@lit/task"; import { LitElement, type TemplateResult, css, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { TopsortConfigurationError, TopsortRequestError } from "./errors"; -import type { Auction, Banner } from "./types"; +import { customElement, property, state } from "lit/decorators.js"; +import { runAuction } from "./auction"; +import { TopsortConfigurationError } from "./errors"; +import { BannerComponent } from "./mixin"; +import type { Auction, Banner, BannerContext } from "./types"; /* Set up global environment for TS_BANNERS */ @@ -30,107 +33,59 @@ function logError(error: unknown) { } } -const getDeviceType = (): "mobile" | "desktop" => { - const ua = navigator.userAgent; - if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) { - //return "tablet"; - return "mobile"; +// The following methods are used to customize the appearance of the banner component. +function getLink(banner: Banner): string { + if (window.TS_BANNERS.getLink) { + return window.TS_BANNERS.getLink(banner); } - if ( - /Mobile|iP(hone|od)|Android|BlackBerry|IEMobile|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test( - ua, - ) - ) { - return "mobile"; + if (banner.type === "url") { + return banner.id; } - return "desktop"; -}; - -/** - * A banner web component that runs an auction and renders the winning banner. - */ -@customElement("topsort-banner") -export class TopsortBanner extends LitElement { - @property({ type: Number }) - readonly width = 0; - - @property({ type: Number }) - readonly height = 0; - - @property({ attribute: "id", type: String }) - readonly slotId: string = ""; - - @property({ attribute: "category-id", type: String }) - readonly categoryId?: string; - - @property({ attribute: "category-ids", type: String }) - readonly categoryIds?: string; - - @property({ attribute: "category-disjunctions", type: String }) - readonly categoryDisjunctions?: string; - - @property({ attribute: "search-query", type: String }) - readonly searchQuery?: string; - - @property({ attribute: "location", type: String }) - readonly location?: string; - - private task = new Task(this, { - task: (...args) => this.runAuction(...args), - args: () => [], - }); + return `${banner.type}/${banner.id}`; +} - private getLink(banner: Banner): string { - if (window.TS_BANNERS.getLink) { - return window.TS_BANNERS.getLink(banner); - } - if (banner.type === "url") { - return banner.id; - } - return `${banner.type}/${banner.id}`; +function getLoadingElement(): TemplateResult { + if (window.TS_BANNERS.getLoadingElement) { + const element = window.TS_BANNERS.getLoadingElement(); + return html`${element}`; } + // By default, hide the component while loading + return html``; +} - private getLoadingElement(): TemplateResult { - if (window.TS_BANNERS.getLoadingElement) { - const element = window.TS_BANNERS.getLoadingElement(); - return html`${element}`; - } - // By default, hide the component while loading - return html``; +function getErrorElement(error: unknown): TemplateResult { + if (window.TS_BANNERS.getErrorElement) { + const element = window.TS_BANNERS.getErrorElement(error); + return html`${element}`; } + // By default, hide the component if there is an error + return html``; +} - private getErrorElement(error: unknown): TemplateResult { - if (window.TS_BANNERS.getErrorElement) { - const element = window.TS_BANNERS.getErrorElement(error); - return html`${element}`; - } - // By default, hide the component if there is an error - return html``; +function getNoWinnersElement(): TemplateResult { + if (window.TS_BANNERS.getNoWinnersElement) { + const element = window.TS_BANNERS.getNoWinnersElement(); + return html`${element}`; } + // By default, hide the component if there are no winners + return html``; +} - private getNoWinnersElement(): TemplateResult { - if (window.TS_BANNERS.getNoWinnersElement) { - const element = window.TS_BANNERS.getNoWinnersElement(); - return html`${element}`; - } - // By default, hide the component if there are no winners - return html``; +function getBannerElement(banner: Banner, width: number, height: number): TemplateResult { + if (window.TS_BANNERS.getBannerElement) { + const element = window.TS_BANNERS.getBannerElement(banner); + return html`${element}`; } - - private getBannerElement(banner: Banner): TemplateResult { - if (window.TS_BANNERS.getBannerElement) { - const element = window.TS_BANNERS.getBannerElement(banner); - return html`${element}`; - } - const src = banner.asset[0].url; - const style = css` + console.debug(banner); + const src = banner.asset[0].url; + const style = css` img { - width: ${this.width}px; - height: ${this.height}px; + width: ${width}px; + height: ${height}px; } `; - const href = this.getLink(banner); - return html` + const href = getLink(banner); + return html`
`; - } +} - private emitEvent(status: string) { - const event = new CustomEvent("statechange", { - detail: { slotId: this.slotId, status }, - bubbles: true, - composed: true, +/** + * A banner web component that runs an auction and renders the winning banner. + */ +@customElement("topsort-banner") +export class TopsortBanner extends BannerComponent(LitElement) { + private task = new Task(this, { + task: (_, options) => runAuction(this.buildAuction(), { ...options, logError }), + args: () => [], + }); + + protected render() { + if (!window.TS.token || !this.slotId) { + return getErrorElement(new TopsortConfigurationError(window.TS.token, this.slotId)); + } + return this.task.render({ + pending: () => getLoadingElement(), + complete: (banners) => { + this.emitEvent(banners.length ? "ready" : "nowinners"); + if (!banners.length) { + return getNoWinnersElement(); + } + return getBannerElement(banners[0], this.height, this.width); + }, + error: (error) => getErrorElement(error), }); - this.dispatchEvent(event); } - private buildAuction(): Auction { - const device = getDeviceType(); - const auction: Auction = { - type: "banners", - slots: 1, - device, - slotId: this.slotId, - }; - if (this.categoryId) { - auction.category = { - id: this.categoryId, - }; - } else if (this.categoryIds) { - auction.category = { - ids: this.categoryIds.split(",").map((item) => item.trim()), - }; - } else if (this.categoryDisjunctions) { - auction.category = { - disjunctions: [this.categoryDisjunctions.split(",").map((item) => item.trim())], - }; - } else if (this.searchQuery) { - auction.searchQuery = this.searchQuery; - } - if (this.location) { - auction.geoTargeting = { - location: this.location, - }; + // avoid shadow dom since we cannot attach to events via analytics.js + protected createRenderRoot() { + return this; + } +} + +const bannerContext = createContext(Symbol("banner-context")); +const bannerContextHasChanged = (newVal: BannerContext, oldVal?: BannerContext) => { + if (!oldVal && newVal) { + return true; + } + if (!newVal || !oldVal) { + return false; + } + return ( + newVal.width !== oldVal.width || + newVal.height !== oldVal.height || + newVal.banners !== oldVal.banners || + newVal.error !== oldVal.error + ); +}; + +@customElement("topsort-banner-context") +export class TopsortBannerContext extends BannerComponent(LitElement) { + @provide({ context: bannerContext }) + @property({ + state: true, + attribute: false, + hasChanged: bannerContextHasChanged, + }) + protected context: BannerContext = { + width: this.width, + height: this.height, + }; + + // task = new Task(this, { + // task: async (_, options) => await runAuction(this.buildAuction(), { ...options, logError }), + // args: () => [], + // }); + + buildAuction(): Auction { + const auction = super.buildAuction(); + const slots = this.querySelectorAll("topsort-banner-slot").length; + if (slots > 1) { + auction.slots = slots; } return auction; } - private async runAuction(_: never[], { signal }: { signal: AbortSignal }): Promise { - const auction = this.buildAuction(); - console.debug("Running auction", auction); - const device = getDeviceType(); - const token = window.TS.token; - const url = window.TS.url || "https://api.topsort.com"; - const res = await fetch(new URL(`${url}/v2/auctions`), { - method: "POST", - mode: "cors", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "X-UA": `topsort/banners-${import.meta.env.PACKAGE_VERSION} (${device}})`, - }, - body: JSON.stringify({ - auctions: [auction], - }), + protected render() { + return html``; + } + + connectedCallback() { + super.connectedCallback(); + const signal = new AbortController().signal; + this.context = { + width: this.width, + height: this.height, + }; + runAuction(this.buildAuction(), { + logError, signal, - }); - if (!res.ok) { - const error = await res.json(); - logError(error); - throw new Error(error.message); - } - const data = await res.json(); - const result = data.results[0]; - if (!result) throw new TopsortRequestError("No auction results", res.status); - if (result.error) { - logError(result.error); - throw new Error(result.error); - } - return result.winners; + }) + .then((banners) => { + console.log("banners", banners); + this.context = { + width: this.width, + height: this.height, + banners, + }; + }) + .catch((error) => { + console.error(error); + this.context = { + width: this.width, + height: this.height, + error, + }; + }); } +} + +@customElement("topsort-banner-slot") +export class TopsortBannerSlot extends LitElement { + @consume({ context: bannerContext }) + @property({ attribute: false }) + public context?: BannerContext; + + @property({ attribute: "rank", type: Number }) + readonly rank = 0; protected render() { - if (!window.TS.token || !this.slotId) { - return this.getErrorElement(new TopsortConfigurationError(window.TS.token, this.slotId)); + console.log("render", this, this.context); + if (this.context?.error) { + return getErrorElement(this.context.error); } - return this.task.render({ - pending: () => this.getLoadingElement(), - complete: (banners) => { - this.emitEvent(banners.length ? "ready" : "nowinners"); - if (!banners.length) { - return this.getNoWinnersElement(); - } - return this.getBannerElement(banners[0]); - }, - error: (error) => this.getErrorElement(error), - }); + if (!this.context?.banners) { + return html``; + } + const banner = this.context.banners[this.rank - 1]; + if (!banner) { + return html``; + } + return getBannerElement(banner, this.context.width, this.context.height); } // avoid shadow dom since we cannot attach to events via analytics.js diff --git a/src/mixin.ts b/src/mixin.ts new file mode 100644 index 0000000..4668842 --- /dev/null +++ b/src/mixin.ts @@ -0,0 +1,90 @@ +import type { LitElement } from "lit"; +import { property } from "lit/decorators.js"; +import { getDeviceType } from "./auction"; +import type { Auction } from "./types"; + +// biome-ignore lint/suspicious/noExplicitAny: We need to use `any` here +type Constructor = new (...args: any[]) => T; + +export declare class BannerComponentInterface { + slotId: string; + width: number; + height: number; + categoryId?: string; + categoryIds?: string; + categoryDisjunctions?: string; + searchQuery?: string; + location?: string; + + emitEvent(status: string): void; + buildAuction(): Auction; +} + +export const BannerComponent = >(Base: T) => { + class BannerComponent extends Base { + @property({ type: Number }) + readonly width: number = 0; + + @property({ type: Number }) + readonly height: number = 0; + + @property({ attribute: "id", type: String }) + readonly slotId: string = ""; + + @property({ attribute: "category-id", type: String }) + readonly categoryId?: string; + + @property({ attribute: "category-ids", type: String }) + readonly categoryIds?: string; + + @property({ attribute: "category-disjunctions", type: String }) + readonly categoryDisjunctions?: string; + + @property({ attribute: "search-query", type: String }) + readonly searchQuery?: string; + + @property({ attribute: "location", type: String }) + readonly location?: string; + + buildAuction(): Auction { + const device = getDeviceType(); + const auction: Auction = { + type: "banners", + slots: 1, + device, + slotId: this.slotId, + }; + if (this.categoryId) { + auction.category = { + id: this.categoryId, + }; + } else if (this.categoryIds) { + auction.category = { + ids: this.categoryIds.split(",").map((item) => item.trim()), + }; + } else if (this.categoryDisjunctions) { + auction.category = { + disjunctions: [this.categoryDisjunctions.split(",").map((item) => item.trim())], + }; + } else if (this.searchQuery) { + auction.searchQuery = this.searchQuery; + } + if (this.location) { + auction.geoTargeting = { + location: this.location, + }; + } + return auction; + } + + emitEvent(status: string) { + const event = new CustomEvent("statechange", { + detail: { slotId: this.slotId, status }, + bubbles: true, + composed: true, + }); + this.dispatchEvent(event); + } + } + return BannerComponent as Constructor & T; +}; diff --git a/src/types.ts b/src/types.ts index 0706a20..5b4af16 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,8 @@ +import type { Task } from "@lit/task"; + export interface Auction { type: "banners"; - slots: 1; + slots: number; device: "mobile" | "desktop"; slotId: string; category?: { @@ -21,3 +23,10 @@ export interface Banner { resolvedBidId: string; asset: [{ url: string }]; } + +export interface BannerContext { + width: number; + height: number; + banners?: Banner[]; + error?: unknown; +} From eeb2509c2732aaa9476d7c45959b8408eacdcb70 Mon Sep 17 00:00:00 2001 From: Pablo Reszczynski Date: Wed, 3 Jul 2024 10:27:58 -0400 Subject: [PATCH 2/9] cleanup code --- .env.example | 1 + .gitignore | 1 + index.html | 52 +++++++++++++++++++++++++++++++++++++++++++++----- src/auction.ts | 3 --- src/index.ts | 15 ++++----------- src/types.ts | 2 -- 6 files changed, 53 insertions(+), 21 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..feb2325 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_TS_TOKEN= diff --git a/.gitignore b/.gitignore index 5996c5b..009bbc5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ .DS_Store dist/ types/ +.env diff --git a/index.html b/index.html index 8fec5f4..b339144 100644 --- a/index.html +++ b/index.html @@ -8,13 +8,12 @@ type="module" src="node_modules/skeleton-webcomponent-loader/dist/skeleton-webcomponent/skeleton-webcomponent.esm.js" > - +