diff --git a/src/components/DynamicCards/DynamicCards.ts b/src/components/DynamicCards/DynamicCards.ts new file mode 100644 index 00000000..98c3edeb --- /dev/null +++ b/src/components/DynamicCards/DynamicCards.ts @@ -0,0 +1,236 @@ +import { assertRequired } from "@/utils/assertRequired" +import { getShopifyUrl } from "@/shopify/getShopifyUrl" +import { customElement, property } from "../decorators" +import { applyDefaults } from "@/utils/applyDefaults" +import { nostojs } from "@nosto/nosto-js" +import { JSONResult } from "@nosto/nosto-js/client" +import { getText } from "@/utils/fetch" +import { NostoElement } from "../Element" + +/** Event name for the DynamicCards loaded event */ +const DYNAMIC_CARDS_LOADED_EVENT = "@nosto/DynamicCards/loaded" + +type Product = { + id: string + handle: string +} + +type DefaultProps = Pick + +/** Default values for DynamicCards attributes */ +let dynamicCardsDefaults: DefaultProps = {} + +/** + * A custom element that renders multiple products by fetching their markup from Shopify, using Storefront search API, based on the provided handles and the section. + * + * This component is designed to be used in a Shopify environment and fetches product card markup dynamically for a list of products. + * + * + * @category Campaign level templating + * + * @property {string} [section] - The section to use for rendering the product cards. section should be supplied as an attribute or through the defaults. + * @property {boolean} [lazy] - If true, the component will only fetch data when it comes into view. Defaults to false. + * @property {string} [placement] - Optional placement identifier to include in the request for analytics and campaign targeting purposes. Required when `loadRecommendations` is true. + * @property {boolean} [loadRecommendations] - If true, the component will load product ids from Nosto. Defaults to false. + * @property {boolean} [searchPerformed] - Internal flag to prevent multiple recommendation loads. Not settable via attribute. + * @property {string} [productsContainerSelector] - Optional CSS selector to identify the container within the fetched markup where product card items are located. Used for appending additional batches of products while preserving existing content. + * @property {string} [productItemSelector] - Optional CSS selector to identify individual product card items within the fetched markup. Used for sorting products based on the original order of the input product list. + */ +@customElement("nosto-dynamic-cards") +export class DynamicCards extends NostoElement { + private BATCH_SIZE = 10 + #products: Product[] = [] + @property(String) section?: string + @property(Boolean) lazy?: boolean + @property(String) placement!: string + @property(Boolean) loadRecommendations?: boolean + @property(Boolean) searchPerformed?: boolean + @property(String) productsContainerSelector?: string + @property(String) productItemSelector?: string + + set products(products: Product[]) { + this.#products = products + if (this.isConnected) { + if (this.lazy) { + const observer = new IntersectionObserver(async entries => { + if (entries[0].isIntersecting) { + observer.disconnect() + await this.render() + } + }) + observer.observe(this) + } else { + void this.render() + } + } + } + + get products() { + return this.#products + } + + async connectedCallback() { + // Apply default values before rendering + applyDefaults(this, dynamicCardsDefaults as this) + + if (!this.loadRecommendations || (this.loadRecommendations && !this.placement) || this.searchPerformed) { + return + } + + const api = await new Promise(nostojs) + const { recommendations } = await api + .createRecommendationRequest({ includeTagging: true }) + .disableCampaignInjection() + .setElements([this.placement]) + .setResponseMode("JSON_ORIGINAL") + .load() + + const recs = recommendations[this.placement] as JSONResult + if (recs && recs.products) { + this.searchPerformed = true + this.products = recs.products.map(p => ({ + id: p.product_id, + handle: p.handle + })) + + console.log("Loaded recommendations for DynamicCards:", this.products) + } + } + + async render() { + assertRequired(this, "section") + if (!this.products.length) { + this.innerHTML = "No products to display" + return + } + + this.toggleAttribute("loading", true) + try { + const batches = this.#chunkProducts(this.products) + + let batchIndex = 0 + for (const batch of batches) { + const markup = await this.#getMarkup(batch) + this.#extractDOM(markup, batchIndex) + batchIndex++ + } + + this.dispatchEvent(new CustomEvent(DYNAMIC_CARDS_LOADED_EVENT, { bubbles: true, cancelable: true })) + } finally { + this.toggleAttribute("loading", false) + } + } + + async #getMarkup(batch: Product[]) { + const target = this.#generateUrl(false, batch) + + const markup = await getText(target, { cached: true }) + + if (/<(body|html)/.test(markup)) { + throw new Error( + `Invalid markup for section ${this.section}, make sure that no or tags are included.` + ) + } + return markup + } + + #sortResultsIfApplicable(recommendations: HTMLElement, batchIndex: number) { + if (this.productItemSelector) { + this.products.forEach((product, index) => { + const productUrl = `/products/${product.handle}` + const productCardItem = recommendations.querySelector( + `${this.productItemSelector!}:has(a[href*="${productUrl}"])` + ) + if (productCardItem) { + productCardItem.style.order = `${index + batchIndex * this.BATCH_SIZE}` + } + }) + } + + return recommendations + } + + #generateUrl(predictiveSearch: boolean, batch: Product[]) { + if (predictiveSearch) { + const url = getShopifyUrl(`/search/suggest`) + url.searchParams.set("section_id", this.section!) + url.searchParams.set("q", this.#generateQuery(batch)) + url.searchParams.set("resources[type]", "product") + url.searchParams.set("resources[limit_scope]", "each") + url.searchParams.set("resources[options][unavailable_products]", "hide") + return url.href + } + + const url = getShopifyUrl(`/search`) + url.searchParams.set("section_id", this.section!) + url.searchParams.set("q", this.#generateQuery(batch)) + url.searchParams.set("type", "product") + url.searchParams.set("options[prefix]", "none") + url.searchParams.set("options[unavailable_products]", "hide") + return url.href + } + + #generateQuery(products: Product[]) { + const query = products.map(p => `id:${p.id.trim()}`).join(" OR ") + return query + } + + #chunkProducts(products: Product[]) { + const batches: Product[][] = [] + for (let index = 0; index < products.length; index += this.BATCH_SIZE) { + batches.push(products.slice(index, index + this.BATCH_SIZE)) + } + return batches + } + + #extractDOM(markup: string, batchIndex: number) { + const html = document.createElement("div") + html.innerHTML = markup + const recommendations = html.querySelector(`nosto-dynamic-cards[id="${this.id}"]`) + if (!recommendations) { + return html + } + const sortedContent = this.#sortResultsIfApplicable(recommendations, batchIndex) + + if (batchIndex === 0) { + if (sortedContent.children.length > 0) { + this.replaceChildren(...sortedContent.children) + } else { + this.replaceChildren(sortedContent) + } + } else if (this.productItemSelector && this.productsContainerSelector) { + const items = sortedContent.querySelectorAll(this.productItemSelector) + const container = this.querySelector(this.productsContainerSelector) + if (container && items.length) { + items.forEach(item => container.appendChild(item)) + } + } + } +} + +/** + * Sets default values for DynamicCards attributes. + * These defaults will be applied to all DynamicCards instances created after this function is called. + * + * @param defaults - An object containing default values for DynamicCards attributes + * + * @example + * ```typescript + * import { setDynamicCardsDefaults } from '@nosto/web-components' + * + * setDynamicCardsDefaults({ + * lazy: true, + * loadRecommendations: true, + * section: 'product-card' + * }) + * ``` + */ +export function setDynamicCardsDefaults(defaults: DefaultProps) { + dynamicCardsDefaults = { ...defaults } +} + +declare global { + interface HTMLElementTagNameMap { + "nosto-dynamic-cards": DynamicCards + } +} diff --git a/src/components/DynamicCards/productResultsParser.ts b/src/components/DynamicCards/productResultsParser.ts new file mode 100644 index 00000000..3914f68b --- /dev/null +++ b/src/components/DynamicCards/productResultsParser.ts @@ -0,0 +1,188 @@ +export type RepeatingItemsResult = { + /** The parent element whose direct children are the repeated items */ + container: Element + /** The repeated child elements (direct children of container) */ + items: Element[] + /** Tag name of the repeated items (e.g., "LI", "DIV") */ + itemTag: string + /** + * Best-effort selector for the repeated items under the container. + * Usually something like ".resource-list__item" or "li.grid__item". + * If no stable class exists, may be a tag selector like "li" or "div". + */ + itemSelector: string + debug?: { + score: number + depth: number + reason: string + } +} + +export type FindRepeatingOptions = { + minItems?: number // default 2 + maxDepth?: number // default 8 + /** Prefer UL/OL containers with LI children */ + preferLists?: boolean // default true + /** + * Optional: filter items (e.g., only count items that contain an ). + * This is the most effective way to avoid matching menus, etc. + */ + isItemElement?: (el: Element) => boolean +} + +/** + * Convenience predicate for Shopify product cards: + * item element must contain at least one anchor linking to /products/. + */ +export function isShopifyProductItem(el: Element): boolean { + return el.querySelector('a[href*="/products/"]') !== null +} + +/** + * Find the best repeating "item" element group within a scoped root element. + * You can pass `isItemElement: isShopifyProductItem` to strongly lock onto product cards. + */ +export function findRepeatingItems(root: Element, opts: FindRepeatingOptions = {}): RepeatingItemsResult | null { + const { minItems = 2, maxDepth = 8, preferLists = true, isItemElement } = opts + + function children(el: Element) { + return Array.from(el.children).filter(c => c.tagName !== "SCRIPT" && c.tagName !== "STYLE") + } + + function depthBetween(ancestor: Element, node: Element): number { + let d = 0 + let cur: Element | null = node + while (cur && cur !== ancestor) { + cur = cur.parentElement + d++ + if (d > 80) break + } + return cur === ancestor ? d : 999 + } + + function cssEscape(ident: string): string { + // Minimal escape suitable for common Shopify classnames. + return ident.replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1") + } + + function pickBestItemSelector(items: Element[], tag: string): { selector: string; reason: string } { + // Prefer a class that is present on most/all items. + const classCounts = new Map() + for (const it of items) { + for (const cls of Array.from(it.classList)) { + classCounts.set(cls, (classCounts.get(cls) ?? 0) + 1) + } + } + + const sorted = Array.from(classCounts.entries()) + .filter(([, count]) => count >= Math.ceil(items.length * 0.8)) // appears on >=80% of items + .sort((a, b) => { + const byCount = b[1] - a[1] + if (byCount !== 0) return byCount + return b[0].length - a[0].length + }) + + if (sorted.length > 0) { + const bestClass = sorted[0][0] + return { selector: `${tag.toLowerCase()}.${cssEscape(bestClass)}`, reason: "class-shared-by-items" } + } + + return { selector: tag.toLowerCase(), reason: "tag-only-fallback" } + } + + function sharedClassCount(items: Element[]): number { + if (items.length === 0) return 0 + const counts = new Map() + for (const it of items) for (const cls of Array.from(it.classList)) counts.set(cls, (counts.get(cls) ?? 0) + 1) + return Array.from(counts.values()).filter(c => c === items.length).length + } + + function allowedItemForContainer(container: Element, item: Element): boolean { + // Strong semantic rule: UL/OL should have LI items. + if ((container.tagName === "UL" || container.tagName === "OL") && item.tagName !== "LI") return false + return true + } + + type Candidate = { + container: Element + items: Element[] + itemTag: string + score: number + depth: number + reason: string + } + + let best: Candidate | null = null + + const queue: Array<{ el: Element; depth: number }> = [{ el: root, depth: 0 }] + + while (queue.length) { + const { el, depth } = queue.shift()! + if (depth > maxDepth) continue + + const kids = children(el) + + if (kids.length >= minItems) { + // Apply container->item semantic filter first + const semanticKids = kids.filter(k => allowedItemForContainer(el, k)) + if (semanticKids.length >= minItems) { + // Apply item predicate (Shopify product anchor) if provided + const predicateKids = isItemElement ? semanticKids.filter(isItemElement) : semanticKids + if (predicateKids.length >= minItems) { + // Group by tag (LI vs DIV, etc.) + const byTag = new Map() + for (const k of predicateKids) byTag.set(k.tagName, [...(byTag.get(k.tagName) ?? []), k]) + + for (const [tag, items] of byTag.entries()) { + if (items.length < minItems) continue + + const dominance = items.length / predicateKids.length // 1.0 if all are same tag + const trueDepth = depthBetween(root, el) + + let score = 0 + + // Base: more items is better + score += items.length * 12 + + // Prefer uniform repeated structure + score += Math.round(dominance * 25) + + // Strongly prefer UL/OL + LI + if (preferLists && (el.tagName === "UL" || el.tagName === "OL") && tag === "LI") score += 45 + + // Prefer items that share a class (helps ".resource-list__item" and ".grid__item") + if (sharedClassCount(items) > 0) score += 20 + + // Prefer closer to root (avoid deep nested repeats like inside each card) + score -= trueDepth * 6 + + // Penalize huge containers (avoid matching root-ish wrappers) + score -= Math.min(220, el.querySelectorAll("*").length) * 0.12 + + const reason = isItemElement + ? 'repeated-direct-children-with-a[href*="/products/"]' + : "repeated-direct-children" + + if (!best || score > best.score) { + best = { container: el, items, itemTag: tag, score, depth: trueDepth, reason } + } + } + } + } + } + + for (const c of kids) queue.push({ el: c, depth: depth + 1 }) + } + + if (!best) return null + + const { selector: itemSelector, reason: selectorReason } = pickBestItemSelector(best.items, best.itemTag) + + return { + container: best.container, + items: best.items, + itemTag: best.itemTag, + itemSelector, + debug: { score: best.score, depth: best.depth, reason: `${best.reason}; selector=${selectorReason}` } + } +} diff --git a/src/main.ts b/src/main.ts index c5a0f2a8..1c4c37cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ export { Bundle } from "./components/Bundle/Bundle" export { Campaign } from "./components/Campaign/Campaign" export { Control } from "./components/Control/Control" export { DynamicCard, setDynamicCardDefaults } from "./components/DynamicCard/DynamicCard" +export { DynamicCards, setDynamicCardsDefaults } from "./components/DynamicCards/DynamicCards" export { Image } from "./components/Image/Image" export { Product } from "./components/Product/Product" export { SectionCampaign } from "./components/SectionCampaign/SectionCampaign"