Skip to content
Closed
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
49 changes: 43 additions & 6 deletions src/components/SectionCampaign/SectionCampaign.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
Expand All @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use with locale

`${window.Shopify.routes.root}cart/update.js`

const payload = {
attributes: {
[`nosto_${element.placement}_title`]: rec.title || "",
[`nosto_${element.placement}_handles`]: handles
},
sections: element.section
}
const reponse = await postJSON<{ sections: Record<string, string> }>(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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

${window.Shopify.routes.root}search'

target.searchParams.set("section_id", element.section)
target.searchParams.set("q", handles)
Expand Down
23 changes: 21 additions & 2 deletions src/utils/fetch.ts
Original file line number Diff line number Diff line change
@@ -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}`)
}
Expand Down Expand Up @@ -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<T extends object>(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<T>
}
63 changes: 63 additions & 0 deletions test/components/SectionCampaign.bundled.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 = `<div class=\"inner\">Bundled Render</div>`
addHandlers(
http.post("/cart/update.js", () => {
return HttpResponse.json({
sections: {
"nosto-product-titles": `<nosto-bundled-campaign placement=\"placement1\">${innerHTML}</nosto-bundled-campaign>`
}
})
})
)

const el = (
<nosto-section-campaign mode="bundled" placement="placement1" section="nosto-product-titles" />
) 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 = (
<nosto-section-campaign mode="bundled" placement="placement1" section="nosto-product-titles" />
) 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 = (
<nosto-section-campaign mode="bundled" placement="placement1" section="nosto-product-titles" />
) as SectionCampaign

await expect(el.connectedCallback()).rejects.toThrow()
expect(el.hasAttribute("loading")).toBe(false)
})
})
7 changes: 1 addition & 6 deletions test/setup.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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()
Expand Down