diff --git a/package.json b/package.json
index 5e7328b..746dfac 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">
@@ -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;