From 4543c2c178354cea9cdbe8b47c36dd0218ef2e92 Mon Sep 17 00:00:00 2001 From: Pablo Reszczynski Date: Fri, 28 Jun 2024 12:27:04 -0400 Subject: [PATCH] chore: use @lit/task library to manage async tasks for fetching the banner from the auction (#42) --- package.json | 1 + pnpm-lock.yaml | 10 ++ src/index.ts | 254 +++++++++++++++++++------------------------------ src/types.ts | 34 +++++++ 4 files changed, 141 insertions(+), 158 deletions(-) create mode 100644 src/types.ts diff --git a/package.json b/package.json index b6ca5eb..6298dd2 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "vite-plugin-dts": "^3.8.3" }, "dependencies": { + "@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 c86df65..43de585 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@lit/task': + specifier: ^1.0.1 + version: 1.0.1 lit: specifier: ^3.1.3 version: 3.1.3 @@ -250,6 +253,9 @@ packages: '@lit/reactive-element@2.0.4': resolution: {integrity: sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==} + '@lit/task@1.0.1': + resolution: {integrity: sha512-fVLDtmwCau8NywnFIXaJxsCZjzaIxnVq+cFRKYC1Y4tA4/0rMTvF6DLZZ2JE51BwzOluaKtgJX8x1QDsQtAaIw==} + '@microsoft/api-extractor-model@7.28.13': resolution: {integrity: sha512-39v/JyldX4MS9uzHcdfmjjfS6cYGAoXV+io8B5a338pkHiSt+gy2eXQ0Q7cGFJ7quSa1VqqlMdlPrB6sLR/cAw==} @@ -849,6 +855,10 @@ snapshots: dependencies: '@lit-labs/ssr-dom-shim': 1.2.0 + '@lit/task@1.0.1': + dependencies: + '@lit/reactive-element': 2.0.4 + '@microsoft/api-extractor-model@7.28.13(@types/node@20.12.7)': dependencies: '@microsoft/tsdoc': 0.14.2 diff --git a/src/index.ts b/src/index.ts index 25b8179..9ce95d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ +import { Task } from "@lit/task"; import { LitElement, type TemplateResult, css, html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { customElement, property } from "lit/decorators.js"; import { TopsortConfigurationError, TopsortRequestError } from "./errors"; +import type { Auction, Banner, BannerState } from "./types"; /* Set up global environment for TS_BANNERS */ @@ -9,7 +11,7 @@ declare global { TS_BANNERS: { getLink(banner: Banner): string; getLoadingElement(): HTMLElement; - getErrorElement(error: Error): HTMLElement; + getErrorElement(error: unknown): HTMLElement; getNoWinnersElement(): HTMLElement; getBannerElement(banner: Banner): HTMLElement; }; @@ -44,50 +46,6 @@ const getDeviceType = (): "mobile" | "desktop" => { return "desktop"; }; -interface Loading { - status: "loading"; -} - -interface Errored { - status: "errored"; - error: Error; -} - -interface NoWinners { - status: "nowinners"; -} - -interface Ready { - status: "ready"; - banner: Banner; -} - -interface Auction { - type: "banners"; - slots: 1; - device: "mobile" | "desktop"; - slotId: string; - category?: { - id?: string; - ids?: string[]; - disjunctions?: string[][]; - }; - geoTargeting?: { - location: string; - }; - searchQuery?: string; -} - -/** The banner object returned from the auction request */ -export interface Banner { - type: "product" | "vendor" | "brand" | "url"; - id: string; - resolvedBidId: string; - asset: [{ url: string }]; -} - -type BannerState = Loading | Errored | NoWinners | Ready; - /** * A banner web component that runs an auction and renders the winning banner. */ @@ -117,10 +75,10 @@ export class TopsortBanner extends LitElement { @property({ attribute: "location", type: String }) readonly location?: string; - @state() - private state: BannerState = { - status: "loading", - }; + private task = new Task(this, { + task: this.runAuction, + args: () => [this.buildAuction()], + }); private getLink(banner: Banner): string { if (window.TS_BANNERS.getLink) { @@ -137,17 +95,17 @@ export class TopsortBanner extends LitElement { const element = window.TS_BANNERS.getLoadingElement(); return html`${element}`; } - return html`
Loading
`; + // By default, hide the component while loading + return html``; } - private getErrorElement(error: Error): TemplateResult { + private getErrorElement(error: unknown): TemplateResult { if (window.TS_BANNERS.getErrorElement) { const element = window.TS_BANNERS.getErrorElement(error); return html`${element}`; } - return html`
`; + // By default, hide the component if there are no winners + return html``; } private getBannerElement(banner: Banner): TemplateResult { @@ -175,7 +134,7 @@ export class TopsortBanner extends LitElement {
+ class="ts-banner"> Topsort banner @@ -183,125 +142,104 @@ export class TopsortBanner extends LitElement { `; } - private setState(state: BannerState) { - this.state = state; + private emitEvent(status: string) { const event = new CustomEvent("statechange", { - detail: { state, slotId: this.slotId }, + detail: { slotId: this.slotId, status }, bubbles: true, composed: true, }); this.dispatchEvent(event); } - private async runAuction() { + private buildAuction(): Auction { const device = getDeviceType(); - try { - const auction: Auction = { - type: "banners", - slots: 1, - device, - slotId: this.slotId, + const auction: Auction = { + type: "banners", + slots: 1, + device, + slotId: this.slotId, + }; + if (this.categoryId) { + auction.category = { + id: this.categoryId, }; - 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, - }; - } - 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], - }), - }); - if (res.ok) { - const data = await res.json(); - const result = data.results[0]; - if (result) { - if (result.error) { - logError(result.error); - this.setState({ - status: "errored", - error: Error("Unknown Error"), - }); - } else if (result.winners[0]) { - const winner = result.winners[0]; - this.setState({ - status: "ready", - banner: winner, - }); - } else { - this.setState({ - status: "nowinners", - }); - } - } - } else { - const error = await res.json(); - logError(error); - this.setState({ - status: "errored", - error: new TopsortRequestError(error.message, res.status), - }); - } - } catch (err) { - logError(err); - if (err instanceof Error) { - this.setState({ - status: "errored", - error: err, - }); - } else { - this.setState({ - status: "errored", - error: Error("Unknown Error"), - }); - } + } 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; } - // Runs when DOM is loaded. Much like React's `getInitialProps` - connectedCallback() { - super.connectedCallback(); - this.runAuction(); + private async runAuction( + [auction]: Auction[], + { signal }: { signal: AbortSignal }, + ): Promise { + 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], + }), + 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); + } + if (result.winners.length) { + return { + status: "ready", + banners: result.winners, + }; + } + return { + status: "nowinners", + }; } protected render() { if (!window.TS.token || !this.slotId) { return this.getErrorElement(new TopsortConfigurationError(window.TS.token, this.slotId)); } - switch (this.state.status) { - case "ready": - return this.getBannerElement(this.state.banner); - case "nowinners": - return this.getNoWinnersElement(); - case "loading": - return this.getLoadingElement(); - case "errored": - return this.getErrorElement(this.state.error); - } + return this.task.render({ + pending: () => this.getLoadingElement(), + complete: (value) => { + this.emitEvent(value.status); + if (value.status === "nowinners") { + return this.getNoWinnersElement(); + } + return this.getBannerElement(value.banners[0]); + }, + error: (error) => this.getErrorElement(error), + }); } // avoid shadow dom since we cannot attach to events via analytics.js diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..28b88f2 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,34 @@ +export interface NoWinners { + status: "nowinners"; +} + +export interface Ready { + status: "ready"; + banners: Banner[]; +} + +export interface Auction { + type: "banners"; + slots: 1; + device: "mobile" | "desktop"; + slotId: string; + category?: { + id?: string; + ids?: string[]; + disjunctions?: string[][]; + }; + geoTargeting?: { + location: string; + }; + searchQuery?: string; +} + +/** The banner object returned from the auction request */ +export interface Banner { + type: "product" | "vendor" | "brand" | "url"; + id: string; + resolvedBidId: string; + asset: [{ url: string }]; +} + +export type BannerState = NoWinners | Ready;