diff --git a/src/components/SectionCampaign/SectionCampaign.ts b/src/components/SectionCampaign/SectionCampaign.ts index 9dd118e0..9ddbb3d1 100644 --- a/src/components/SectionCampaign/SectionCampaign.ts +++ b/src/components/SectionCampaign/SectionCampaign.ts @@ -1,5 +1,5 @@ import { nostojs } from "@nosto/nosto-js" -import { getText } from "@/utils/fetch" +import { getText, postJSON } from "@/utils/fetch" import { customElement } from "../decorators" import { NostoElement } from "../Element" import { addRequest } from "../Campaign/orchestrator" @@ -9,19 +9,32 @@ import { JSONResult } from "@nosto/nosto-js/client" * NostoSectionCampaign is a custom element that fetches Nosto placement results and renders the results * using a Shopify section template via the Section Rendering API. * + * default mode: + * Section is fetched via /search endpoint with product handles as query param + * + * bundled mode: + * Section is fetched via /cart/update.js endpoint with section name in payload + * and campaign metadata is persisted in cart attributes + * * @property {string} placement - The placement identifier for the campaign. * @property {string} section - The section to be used for Section Rendering API based rendering. + * @property {string} handles - (internal) colon-separated list of product handles currently rendered + * @property {string} mode - (internal) rendering mode, supported values: "section" (default), "bundled" */ @customElement("nosto-section-campaign") export class SectionCampaign extends NostoElement { /** @private */ static attributes = { placement: String, - section: String + section: String, + handles: String, + mode: String } placement!: string section!: string + handles!: string + mode?: "section" | "bundled" async connectedCallback() { this.toggleAttribute("loading", true) @@ -41,14 +54,38 @@ export class SectionCampaign extends NostoElement { if (!rec) { return } - const markup = await getSectionMarkup(this, rec) - this.innerHTML = markup + const handles = rec.products.map(product => product.handle).join(":") + const bundled = this.mode === "bundled" + if (!bundled || this.handles !== handles) { + const markup = await (bundled ? getBundledMarkup : getSectionMarkup)(this, handles, rec) + if (markup) { + this.innerHTML = markup + } + } api.attributeProductClicksInCampaign(this, rec) } } -async function getSectionMarkup(element: SectionCampaign, rec: JSONResult) { - const handles = rec.products.map(product => product.handle).join(":") +async function getBundledMarkup(element: SectionCampaign, handles: string, rec: JSONResult) { + const target = new URL("/cart/update.js", window.location.href) + const payload = { + attributes: { + [`nosto_${element.placement}_title`]: rec.title || "", + [`nosto_${element.placement}_handles`]: handles + }, + sections: element.section + } + const reponse = await postJSON<{ sections: Record }>(target.href, payload) + const sectionHtml = reponse.sections[element.section] || "" + if (sectionHtml) { + const parser = new DOMParser() + const doc = parser.parseFromString(sectionHtml, "text/html") + return doc.querySelector(`nosto-bundled-campaign[placement="${element.placement}"]`)?.innerHTML || "" + } + return undefined +} + +async function getSectionMarkup(element: SectionCampaign, handles: string, rec: JSONResult) { const target = new URL("/search", window.location.href) target.searchParams.set("section_id", element.section) target.searchParams.set("q", handles) diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts index dc73a206..5ce2a10c 100644 --- a/src/utils/fetch.ts +++ b/src/utils/fetch.ts @@ -1,11 +1,12 @@ /** * Internal function to handle common fetch logic with error checking. * @param url - The URL to fetch + * @param options - Optional fetch options * @returns Promise that resolves to the Response object * @throws Error if the fetch request fails */ -async function fetchWithErrorHandling(url: string) { - const response = await fetch(url) +async function fetchWithErrorHandling(url: string, options?: RequestInit) { + const response = await fetch(url, options) if (!response.ok) { throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`) } @@ -33,3 +34,21 @@ export async function getJSON(url: string) { const response = await fetchWithErrorHandling(url) return response.json() } + +/** + * Posts a JSON payload to a URL and returns the response as a JSON object. + * @param url - The URL to post to + * @param data - The JSON payload to send + * @returns Promise that resolves to the parsed JSON response + * @throws Error if the fetch request fails or JSON parsing fails + */ +export async function postJSON(url: string, data: object) { + const response = await fetchWithErrorHandling(url, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(data) + }) + return response.json() as Promise +} diff --git a/test/components/SectionCampaign.bundled.spec.tsx b/test/components/SectionCampaign.bundled.spec.tsx new file mode 100644 index 00000000..855dc252 --- /dev/null +++ b/test/components/SectionCampaign.bundled.spec.tsx @@ -0,0 +1,63 @@ +/** @jsx createElement */ +import { describe, it, expect } from "vitest" +import { mockNostoRecs } from "../mockNostoRecs" +import { createElement } from "../utils/jsx" +import { addHandlers } from "../msw.setup" +import { http, HttpResponse } from "msw" +import { SectionCampaign } from "@/main" + +describe("SectionCampaign bundled", () => { + it("renders bundled section markup and attributes product clicks", async () => { + const products = [{ handle: "product-a" }, { handle: "product-b" }] + const { attributeProductClicksInCampaign, load } = mockNostoRecs({ placement1: { products, title: "Rec Title" } }) + + const innerHTML = `
Bundled Render
` + addHandlers( + http.post("/cart/update.js", () => { + return HttpResponse.json({ + sections: { + "nosto-product-titles": `${innerHTML}` + } + }) + }) + ) + + const el = ( + + ) as SectionCampaign + document.body.appendChild(el) + + await el.connectedCallback() + + expect(load).toHaveBeenCalled() + expect(el.innerHTML).toBe(innerHTML) + expect(attributeProductClicksInCampaign).toHaveBeenCalledWith(el, { products, title: "Rec Title" }) + expect(el.hasAttribute("loading")).toBe(false) + }) + + it("skips re-render when current handles match computed handles", async () => { + const products = [{ handle: "product-a" }] + mockNostoRecs({ placement1: { products } }) + + // No handler needed, should skip network + const el = ( + + ) as SectionCampaign + el.handles = "product-a" // pre-set to match, should skip postJSON + + await el.connectedCallback() + // No fetch should be made, so nothing to assert here except no error thrown + }) + + it("propagates post errors and clears loading", async () => { + mockNostoRecs({ placement1: { products: [{ handle: "x" }] } }) + addHandlers(http.get("/cart/update.js", () => HttpResponse.text("Error", { status: 500 }))) + + const el = ( + + ) as SectionCampaign + + await expect(el.connectedCallback()).rejects.toThrow() + expect(el.hasAttribute("loading")).toBe(false) + }) +}) diff --git a/test/setup.ts b/test/setup.ts index ab8a75be..0b411387 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, vi } from "vitest" +import { beforeEach, vi } from "vitest" // Import all components to trigger their @customElement decorators import "@/components/Campaign/Campaign" import "@/components/Control/Control" @@ -9,11 +9,6 @@ import "@/components/ProductCard/ProductCard" import "@/components/SectionCampaign/SectionCampaign" import "@/components/SkuOptions/SkuOptions" -beforeAll(() => { - // Components are automatically registered by their @customElement decorators - // when the modules are imported above. -}) - beforeEach(() => { document.body.innerHTML = "" vi.resetAllMocks()