Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: use @lit/task library to manage async tasks for fetching the banner from the auction #42

Merged
merged 3 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

254 changes: 96 additions & 158 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
jbergstroem marked this conversation as resolved.
Show resolved Hide resolved

/* Set up global environment for TS_BANNERS */

Expand All @@ -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;
};
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) {
Expand All @@ -137,25 +95,26 @@ export class TopsortBanner extends LitElement {
const element = window.TS_BANNERS.getLoadingElement();
return html`${element}`;
}
return html`<div class="ts-banner-${this.state.status}">Loading</div>`;
// 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`<div class="ts-banner-${this.state.status}>
<pre>${error.message}</pre>
</div>`;
// By default, hide the component if there is an error
return html``;
}

private getNoWinnersElement(): TemplateResult {
if (window.TS_BANNERS.getNoWinnersElement) {
const element = window.TS_BANNERS.getNoWinnersElement();
return html`${element}`;
}
return html`<div class="ts-banner-${this.state.status}"></div>`;
// By default, hide the component if there are no winners
return html``;
}

private getBannerElement(banner: Banner): TemplateResult {
Expand All @@ -175,133 +134,112 @@ export class TopsortBanner extends LitElement {
<div style="${style}"
data-ts-clickable
data-ts-resolved-bid=${banner.resolvedBidId}
class="ts-banner-${this.state.status}">
class="ts-banner">
<a href="${href}">
<img src="${src}" alt="Topsort banner"></img>
</a>
</div>
`;
}

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<BannerState> {
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
Expand Down
Loading