Skip to content

Commit be8abbd

Browse files
committed
feat: add bundled SectionCampaign mode
1 parent d2ca5a9 commit be8abbd

File tree

4 files changed

+123
-14
lines changed

4 files changed

+123
-14
lines changed

src/components/SectionCampaign/SectionCampaign.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { nostojs } from "@nosto/nosto-js"
2-
import { getText } from "@/utils/fetch"
2+
import { getText, postJSON } from "@/utils/fetch"
33
import { customElement } from "../decorators"
44
import { NostoElement } from "../Element"
55
import { addRequest } from "../Campaign/orchestrator"
@@ -9,19 +9,32 @@ import { JSONResult } from "@nosto/nosto-js/client"
99
* NostoSectionCampaign is a custom element that fetches Nosto placement results and renders the results
1010
* using a Shopify section template via the Section Rendering API.
1111
*
12+
* default mode:
13+
* Section is fetched via /search endpoint with product handles as query param
14+
*
15+
* bundled mode:
16+
* Section is fetched via /cart/update.js endpoint with section name in payload
17+
* and campaign metadata is persisted in cart attributes
18+
*
1219
* @property {string} placement - The placement identifier for the campaign.
1320
* @property {string} section - The section to be used for Section Rendering API based rendering.
21+
* @property {string} handles - (internal) colon-separated list of product handles currently rendered
22+
* @property {string} mode - (internal) rendering mode, supported values: "section" (default), "bundled"
1423
*/
1524
@customElement("nosto-section-campaign")
1625
export class SectionCampaign extends NostoElement {
1726
/** @private */
1827
static attributes = {
1928
placement: String,
20-
section: String
29+
section: String,
30+
handles: String,
31+
mode: String
2132
}
2233

2334
placement!: string
2435
section!: string
36+
handles!: string
37+
mode?: "section" | "bundled"
2538

2639
async connectedCallback() {
2740
this.toggleAttribute("loading", true)
@@ -41,14 +54,39 @@ export class SectionCampaign extends NostoElement {
4154
if (!rec) {
4255
return
4356
}
44-
const markup = await getSectionMarkup(this, rec)
45-
this.innerHTML = markup
57+
const handles = rec.products.map(product => product.handle).join(":")
58+
const bundled = this.mode === "bundled"
59+
if (!bundled || this.handles !== handles) {
60+
const markup = await (bundled ? getBundledMarkup : getSectionMarkup)(this, handles, rec)
61+
if (markup) {
62+
this.innerHTML = markup
63+
}
64+
}
4665
api.attributeProductClicksInCampaign(this, rec)
4766
}
4867
}
4968

50-
async function getSectionMarkup(element: SectionCampaign, rec: JSONResult) {
51-
const handles = rec.products.map(product => product.handle).join(":")
69+
async function getBundledMarkup(element: SectionCampaign, handles: string, rec: JSONResult) {
70+
const target = new URL("/cart/update.js", window.location.href)
71+
const payload = {
72+
attributes: {
73+
[`nosto_${element.placement}_title`]: rec.title || "",
74+
[`nosto_${element.placement}_handles`]: handles
75+
},
76+
sections: element.section,
77+
}
78+
const reponse = await postJSON<{ sections: Record<string, string> }>(target.href, payload)
79+
const sectionHtml = reponse.sections[element.section] || ""
80+
if (sectionHtml) {
81+
const parser = new DOMParser()
82+
const doc = parser.parseFromString(sectionHtml, "text/html")
83+
return doc.querySelector(`nosto-bundled-campaign[placement="${element.placement}"]`)?.innerHTML || ""
84+
}
85+
return undefined
86+
}
87+
88+
89+
async function getSectionMarkup(element: SectionCampaign, handles: string, rec: JSONResult) {
5290
const target = new URL("/search", window.location.href)
5391
target.searchParams.set("section_id", element.section)
5492
target.searchParams.set("q", handles)

src/utils/fetch.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
/**
22
* Internal function to handle common fetch logic with error checking.
33
* @param url - The URL to fetch
4+
* @param options - Optional fetch options
45
* @returns Promise that resolves to the Response object
56
* @throws Error if the fetch request fails
67
*/
7-
async function fetchWithErrorHandling(url: string) {
8-
const response = await fetch(url)
8+
async function fetchWithErrorHandling(url: string, options?: RequestInit) {
9+
const response = await fetch(url, options)
910
if (!response.ok) {
1011
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
1112
}
@@ -33,3 +34,21 @@ export async function getJSON(url: string) {
3334
const response = await fetchWithErrorHandling(url)
3435
return response.json()
3536
}
37+
38+
/**
39+
* Posts a JSON payload to a URL and returns the response as a JSON object.
40+
* @param url - The URL to post to
41+
* @param data - The JSON payload to send
42+
* @returns Promise that resolves to the parsed JSON response
43+
* @throws Error if the fetch request fails or JSON parsing fails
44+
*/
45+
export async function postJSON<T extends object>(url: string, data: object) {
46+
const response = await fetchWithErrorHandling(url, {
47+
method: "POST",
48+
headers: {
49+
"Content-Type": "application/json"
50+
},
51+
body: JSON.stringify(data)
52+
})
53+
return response.json() as Promise<T>
54+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/** @jsx createElement */
2+
import { describe, it, expect } from "vitest"
3+
import { mockNostoRecs } from "../mockNostoRecs"
4+
import { createElement } from "../utils/jsx"
5+
import { addHandlers } from "../msw.setup"
6+
import { http, HttpResponse } from "msw"
7+
import { SectionCampaign } from "@/main"
8+
9+
describe("SectionCampaign bundled", () => {
10+
it("renders bundled section markup and attributes product clicks", async () => {
11+
const products = [{ handle: "product-a" }, { handle: "product-b" }]
12+
const { attributeProductClicksInCampaign, load } = mockNostoRecs({ placement1: { products, title: "Rec Title" } })
13+
14+
const innerHTML = `<div class=\"inner\">Bundled Render</div>`
15+
addHandlers(
16+
http.post("/cart/update.js", () => {
17+
return HttpResponse.json({
18+
sections: {
19+
"nosto-product-titles": `<nosto-bundled-campaign placement=\"placement1\">${innerHTML}</nosto-bundled-campaign>`
20+
}
21+
})
22+
})
23+
)
24+
25+
const el = (<nosto-section-campaign mode="bundled" placement="placement1" section="nosto-product-titles" />) as SectionCampaign
26+
document.body.appendChild(el)
27+
28+
await el.connectedCallback()
29+
30+
expect(load).toHaveBeenCalled()
31+
expect(el.innerHTML).toBe(innerHTML)
32+
expect(attributeProductClicksInCampaign).toHaveBeenCalledWith(el, { products, title: "Rec Title" })
33+
expect(el.hasAttribute("loading")).toBe(false)
34+
})
35+
36+
it("skips re-render when current handles match computed handles", async () => {
37+
const products = [{ handle: "product-a" }]
38+
mockNostoRecs({ placement1: { products } })
39+
40+
// No handler needed, should skip network
41+
const el = (<nosto-section-campaign mode="bundled" placement="placement1" section="nosto-product-titles" />) as SectionCampaign
42+
el.handles = "product-a" // pre-set to match, should skip postJSON
43+
44+
await el.connectedCallback()
45+
// No fetch should be made, so nothing to assert here except no error thrown
46+
})
47+
48+
it("propagates post errors and clears loading", async () => {
49+
mockNostoRecs({ placement1: { products: [{ handle: "x" }] } })
50+
addHandlers(http.get("/cart/update.js", () => HttpResponse.text("Error", { status: 500 })))
51+
52+
const el = (<nosto-section-campaign mode="bundled" placement="placement1" section="nosto-product-titles" />) as SectionCampaign
53+
54+
await expect(el.connectedCallback()).rejects.toThrow()
55+
expect(el.hasAttribute("loading")).toBe(false)
56+
})
57+
})

test/setup.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeAll, beforeEach, vi } from "vitest"
1+
import { beforeEach, vi } from "vitest"
22
// Import all components to trigger their @customElement decorators
33
import "@/components/Campaign/Campaign"
44
import "@/components/Control/Control"
@@ -9,11 +9,6 @@ import "@/components/ProductCard/ProductCard"
99
import "@/components/SectionCampaign/SectionCampaign"
1010
import "@/components/SkuOptions/SkuOptions"
1111

12-
beforeAll(() => {
13-
// Components are automatically registered by their @customElement decorators
14-
// when the modules are imported above.
15-
})
16-
1712
beforeEach(() => {
1813
document.body.innerHTML = ""
1914
vi.resetAllMocks()

0 commit comments

Comments
 (0)