From fda94b2c97ded04f4c3e92b6c2721730b299e750 Mon Sep 17 00:00:00 2001 From: manikandan-ravikumar Date: Mon, 16 Feb 2026 09:35:53 +0200 Subject: [PATCH 1/2] feat: introducing a custom element for rendering multiple product cards --- .../DynamicCards/DynamicCards.stories.tsx | 105 ++++++++ src/components/DynamicCards/DynamicCards.ts | 240 ++++++++++++++++++ src/components/DynamicCards/markup.ts | 17 ++ .../DynamicCards/productResultsParser.ts | 186 ++++++++++++++ src/components/DynamicCards/styles.css | 56 ++++ src/main.ts | 1 + 6 files changed, 605 insertions(+) create mode 100644 src/components/DynamicCards/DynamicCards.stories.tsx create mode 100644 src/components/DynamicCards/DynamicCards.ts create mode 100644 src/components/DynamicCards/markup.ts create mode 100644 src/components/DynamicCards/productResultsParser.ts create mode 100644 src/components/DynamicCards/styles.css diff --git a/src/components/DynamicCards/DynamicCards.stories.tsx b/src/components/DynamicCards/DynamicCards.stories.tsx new file mode 100644 index 00000000..7d646c19 --- /dev/null +++ b/src/components/DynamicCards/DynamicCards.stories.tsx @@ -0,0 +1,105 @@ +/** @jsx createElement */ +import type { Meta, StoryObj } from "@storybook/web-components-vite" +import { createElement } from "@/utils/jsx" +import { exampleHandlesLoader } from "@/storybook/loader" +import { setShopifyShop } from "@/shopify/getShopifyUrl" + +const shopifyShop = "nosto-shopify1.myshopify.com" +setShopifyShop(shopifyShop) + +const meta = { + title: "Components/DynamicCard", + component: "nosto-dynamic-card", + decorators: [ + (story, context) => { + // Update Shopify shop hostname provided via args + if (context.args?.shopifyShop) { + setShopifyShop(context.args.shopifyShop) + } + return story() + } + ], + argTypes: { + shopifyShop: { + control: "text", + description: "The Shopify store hostname" + }, + template: { + control: "text", + description: "The template to use for rendering the product", + if: { arg: "section", truthy: false } + }, + section: { + control: "text", + description: "The section to use for rendering the product", + if: { arg: "template", truthy: false } + }, + mock: { + control: "boolean" + } + }, + args: { + shopifyShop, + template: "", + section: "", + mock: false + }, + tags: ["autodocs"] +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + loaders: [exampleHandlesLoader], + argTypes: { + columns: { + description: "Number of columns to display in the grid", + control: { type: "range", min: 1, max: 8, step: 1 }, + table: { + category: "Layout options" + } + }, + products: { + description: "Number of products to display in the grid", + control: { type: "range", min: 1, max: 20, step: 1 }, + table: { + category: "Layout options" + } + } + }, + args: { + columns: 4, + products: 12 + }, + render: (args, { loaded }) => { + const handles = loaded?.handles as string[] + if (!args.template && !args.section) { + return

Please provide either a template or section id.

+ } + return ( +
+ {handles.map(handle => ( + + ))} +
+ ) + }, + decorators: [...(meta.decorators ?? []), story =>
{story()}
] +} + +export const Mock: Story = { + args: { + mock: true + }, + render: args => ( +
+ +
+ ) +} diff --git a/src/components/DynamicCards/DynamicCards.ts b/src/components/DynamicCards/DynamicCards.ts new file mode 100644 index 00000000..9d8e40ef --- /dev/null +++ b/src/components/DynamicCards/DynamicCards.ts @@ -0,0 +1,240 @@ +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" + +/** Event name for the DynamicCard loaded event */ +const DYNAMIC_CARD_LOADED_EVENT = "@nosto/DynamicCard/loaded" + +type Product = { + id: string + handle: string +} + +type DefaultProps = Pick + +/** Default values for DynamicCard attributes */ +let dynamicCardsDefaults: DefaultProps = {} + +/** + * A custom element that renders a product by fetching the markup from Shopify based on the provided handle and template. + * + * This component is designed to be used in a Shopify environment and fetches product data dynamically. + * + * {@include ./examples.md} + * + * @category Campaign level templating + * + * @property {string} [section] - The section to use for rendering the product. section or template is required. + * @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. + * @property {boolean} [loadRecommendations] - If true, the component will load product recommendations. 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 items are located. Used for appending additional batches of products while preserving existing content. + * @property {string} [productItemSelector] - Optional CSS selector to identify individual product 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 HTMLElement { + 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) + + assertRequired(this, "placement") + + if (!this.loadRecommendations || (this.loadRecommendations && !this.placement) || this.searchPerformed) { + return false + } + + const api = await new Promise(nostojs) + const { recommendations } = await api + .createRecommendationRequest({ includeTagging: true }) + .disableCampaignInjection() + .setElements([this.placement]) + .setResponseMode("JSON_ORIGINAL") + .load() + + // ts-expect-error + 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, + title: p.name.trim() + })) + + 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_CARD_LOADED_EVENT, { bubbles: true, cancelable: true })) + } finally { + this.toggleAttribute("loading", false) + } + } + + async #getMarkup(batch: Product[]) { + const target = this.#generateUrl(false, batch) + + let 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(recomendations: HTMLElement, batchIndex: number) { + if (this.productItemSelector) { + this.products.forEach((product, index) => { + const productUrl = `/products/${product.handle}` + const productCardItem = recomendations.querySelector( + `${this.productItemSelector!}:has(a[href*="${productUrl}"])` + ) + if (productCardItem) { + productCardItem.style.order = `${index + batchIndex * this.BATCH_SIZE}` + } + }) + } + + return recomendations + } + + #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 + } + var 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 DynamicCard attributes. + * These defaults will be applied to all DynamicCard instances created after this function is called. + * + * @param defaults - An object containing default values for DynamicCard attributes + * + * @example + * ```typescript + * import { setDynamicCardsDefaults } from '@nosto/web-components' + * + * setDynamicCardDefaults({ + * placeholder: true, + * lazy: 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/markup.ts b/src/components/DynamicCards/markup.ts new file mode 100644 index 00000000..b27707eb --- /dev/null +++ b/src/components/DynamicCards/markup.ts @@ -0,0 +1,17 @@ +import { html } from "@/templating/html" + +export function generateMockMarkup() { + return html` +
+
+ Mock Product Image +
+

Mock Product Title

+

Mock Brand

+
+ XX.XX + XX.XX +
+
+ ` +} diff --git a/src/components/DynamicCards/productResultsParser.ts b/src/components/DynamicCards/productResultsParser.ts new file mode 100644 index 00000000..e1c25ade --- /dev/null +++ b/src/components/DynamicCards/productResultsParser.ts @@ -0,0 +1,186 @@ +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 + + const children = (el: Element) => 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/components/DynamicCards/styles.css b/src/components/DynamicCards/styles.css new file mode 100644 index 00000000..73b06033 --- /dev/null +++ b/src/components/DynamicCards/styles.css @@ -0,0 +1,56 @@ +:host { + --mock-border-color: #ccc; + --mock-bg: #f9f9f9; + --mock-pattern-dark: #e0e0e0; + --mock-pattern-light: #f5f5f5; + --mock-text-color: #999; + --mock-title-color: #666; + --mock-price-color: #333; +} + +.card { + border: 2px dashed var(--mock-border-color); + padding: 1rem; + background: var(--mock-bg, #f9f9f9); + opacity: 0.8; + text-align: center; +} + +.image { + min-height: 200px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; +} + +.text { + font-weight: bold; + font-size: 1.2rem; + color: var(--mock-text-color); +} + +.title { + margin: 0.5rem 0; + color: var(--mock-title-color); +} + +.brand { + margin: 0.5rem 0; + color: var(--mock-text-color); +} + +.price { + margin: 0.5rem 0; +} + +.price-current { + font-weight: bold; + color: var(--mock-price-color); +} + +.price-original { + text-decoration: line-through; + color: var(--mock-text-color); + margin-left: 0.5rem; +} \ No newline at end of file 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" From 6d3e6fdd0efcc76127339381e8f8224b92a396d4 Mon Sep 17 00:00:00 2001 From: manikandan-ravikumar Date: Mon, 16 Feb 2026 10:01:00 +0200 Subject: [PATCH 2/2] chore: review fix and cleanups --- .../DynamicCards/DynamicCards.stories.tsx | 105 ------------------ src/components/DynamicCards/DynamicCards.ts | 54 +++++---- src/components/DynamicCards/markup.ts | 17 --- .../DynamicCards/productResultsParser.ts | 4 +- src/components/DynamicCards/styles.css | 56 ---------- 5 files changed, 28 insertions(+), 208 deletions(-) delete mode 100644 src/components/DynamicCards/DynamicCards.stories.tsx delete mode 100644 src/components/DynamicCards/markup.ts delete mode 100644 src/components/DynamicCards/styles.css diff --git a/src/components/DynamicCards/DynamicCards.stories.tsx b/src/components/DynamicCards/DynamicCards.stories.tsx deleted file mode 100644 index 7d646c19..00000000 --- a/src/components/DynamicCards/DynamicCards.stories.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/** @jsx createElement */ -import type { Meta, StoryObj } from "@storybook/web-components-vite" -import { createElement } from "@/utils/jsx" -import { exampleHandlesLoader } from "@/storybook/loader" -import { setShopifyShop } from "@/shopify/getShopifyUrl" - -const shopifyShop = "nosto-shopify1.myshopify.com" -setShopifyShop(shopifyShop) - -const meta = { - title: "Components/DynamicCard", - component: "nosto-dynamic-card", - decorators: [ - (story, context) => { - // Update Shopify shop hostname provided via args - if (context.args?.shopifyShop) { - setShopifyShop(context.args.shopifyShop) - } - return story() - } - ], - argTypes: { - shopifyShop: { - control: "text", - description: "The Shopify store hostname" - }, - template: { - control: "text", - description: "The template to use for rendering the product", - if: { arg: "section", truthy: false } - }, - section: { - control: "text", - description: "The section to use for rendering the product", - if: { arg: "template", truthy: false } - }, - mock: { - control: "boolean" - } - }, - args: { - shopifyShop, - template: "", - section: "", - mock: false - }, - tags: ["autodocs"] -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Default: Story = { - loaders: [exampleHandlesLoader], - argTypes: { - columns: { - description: "Number of columns to display in the grid", - control: { type: "range", min: 1, max: 8, step: 1 }, - table: { - category: "Layout options" - } - }, - products: { - description: "Number of products to display in the grid", - control: { type: "range", min: 1, max: 20, step: 1 }, - table: { - category: "Layout options" - } - } - }, - args: { - columns: 4, - products: 12 - }, - render: (args, { loaded }) => { - const handles = loaded?.handles as string[] - if (!args.template && !args.section) { - return

Please provide either a template or section id.

- } - return ( -
- {handles.map(handle => ( - - ))} -
- ) - }, - decorators: [...(meta.decorators ?? []), story =>
{story()}
] -} - -export const Mock: Story = { - args: { - mock: true - }, - render: args => ( -
- -
- ) -} diff --git a/src/components/DynamicCards/DynamicCards.ts b/src/components/DynamicCards/DynamicCards.ts index 9d8e40ef..98c3edeb 100644 --- a/src/components/DynamicCards/DynamicCards.ts +++ b/src/components/DynamicCards/DynamicCards.ts @@ -5,9 +5,10 @@ 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 DynamicCard loaded event */ -const DYNAMIC_CARD_LOADED_EVENT = "@nosto/DynamicCard/loaded" +/** Event name for the DynamicCards loaded event */ +const DYNAMIC_CARDS_LOADED_EVENT = "@nosto/DynamicCards/loaded" type Product = { id: string @@ -16,28 +17,27 @@ type Product = { type DefaultProps = Pick -/** Default values for DynamicCard attributes */ +/** Default values for DynamicCards attributes */ let dynamicCardsDefaults: DefaultProps = {} /** - * A custom element that renders a product by fetching the markup from Shopify based on the provided handle and template. + * 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 data dynamically. + * This component is designed to be used in a Shopify environment and fetches product card markup dynamically for a list of products. * - * {@include ./examples.md} * * @category Campaign level templating * - * @property {string} [section] - The section to use for rendering the product. section or template is required. + * @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. - * @property {boolean} [loadRecommendations] - If true, the component will load product recommendations. 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 items are located. Used for appending additional batches of products while preserving existing content. - * @property {string} [productItemSelector] - Optional CSS selector to identify individual product items within the fetched markup. Used for sorting products based on the original order of the input product list. + * @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 HTMLElement { +export class DynamicCards extends NostoElement { private BATCH_SIZE = 10 #products: Product[] = [] @property(String) section?: string @@ -73,10 +73,8 @@ export class DynamicCards extends HTMLElement { // Apply default values before rendering applyDefaults(this, dynamicCardsDefaults as this) - assertRequired(this, "placement") - if (!this.loadRecommendations || (this.loadRecommendations && !this.placement) || this.searchPerformed) { - return false + return } const api = await new Promise(nostojs) @@ -87,14 +85,12 @@ export class DynamicCards extends HTMLElement { .setResponseMode("JSON_ORIGINAL") .load() - // ts-expect-error 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, - title: p.name.trim() + handle: p.handle })) console.log("Loaded recommendations for DynamicCards:", this.products) @@ -119,7 +115,7 @@ export class DynamicCards extends HTMLElement { batchIndex++ } - this.dispatchEvent(new CustomEvent(DYNAMIC_CARD_LOADED_EVENT, { bubbles: true, cancelable: true })) + this.dispatchEvent(new CustomEvent(DYNAMIC_CARDS_LOADED_EVENT, { bubbles: true, cancelable: true })) } finally { this.toggleAttribute("loading", false) } @@ -128,7 +124,7 @@ export class DynamicCards extends HTMLElement { async #getMarkup(batch: Product[]) { const target = this.#generateUrl(false, batch) - let markup = await getText(target, { cached: true }) + const markup = await getText(target, { cached: true }) if (/<(body|html)/.test(markup)) { throw new Error( @@ -138,11 +134,11 @@ export class DynamicCards extends HTMLElement { return markup } - #sortResultsIfApplicable(recomendations: HTMLElement, batchIndex: number) { + #sortResultsIfApplicable(recommendations: HTMLElement, batchIndex: number) { if (this.productItemSelector) { this.products.forEach((product, index) => { const productUrl = `/products/${product.handle}` - const productCardItem = recomendations.querySelector( + const productCardItem = recommendations.querySelector( `${this.productItemSelector!}:has(a[href*="${productUrl}"])` ) if (productCardItem) { @@ -151,7 +147,7 @@ export class DynamicCards extends HTMLElement { }) } - return recomendations + return recommendations } #generateUrl(predictiveSearch: boolean, batch: Product[]) { @@ -194,7 +190,7 @@ export class DynamicCards extends HTMLElement { if (!recommendations) { return html } - var sortedContent = this.#sortResultsIfApplicable(recommendations, batchIndex) + const sortedContent = this.#sortResultsIfApplicable(recommendations, batchIndex) if (batchIndex === 0) { if (sortedContent.children.length > 0) { @@ -213,18 +209,18 @@ export class DynamicCards extends HTMLElement { } /** - * Sets default values for DynamicCard attributes. - * These defaults will be applied to all DynamicCard instances created after this function is called. + * 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 DynamicCard attributes + * @param defaults - An object containing default values for DynamicCards attributes * * @example * ```typescript * import { setDynamicCardsDefaults } from '@nosto/web-components' * - * setDynamicCardDefaults({ - * placeholder: true, + * setDynamicCardsDefaults({ * lazy: true, + * loadRecommendations: true, * section: 'product-card' * }) * ``` diff --git a/src/components/DynamicCards/markup.ts b/src/components/DynamicCards/markup.ts deleted file mode 100644 index b27707eb..00000000 --- a/src/components/DynamicCards/markup.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { html } from "@/templating/html" - -export function generateMockMarkup() { - return html` -
-
- Mock Product Image -
-

Mock Product Title

-

Mock Brand

-
- XX.XX - XX.XX -
-
- ` -} diff --git a/src/components/DynamicCards/productResultsParser.ts b/src/components/DynamicCards/productResultsParser.ts index e1c25ade..3914f68b 100644 --- a/src/components/DynamicCards/productResultsParser.ts +++ b/src/components/DynamicCards/productResultsParser.ts @@ -45,7 +45,9 @@ export function isShopifyProductItem(el: Element): boolean { export function findRepeatingItems(root: Element, opts: FindRepeatingOptions = {}): RepeatingItemsResult | null { const { minItems = 2, maxDepth = 8, preferLists = true, isItemElement } = opts - const children = (el: Element) => Array.from(el.children).filter(c => c.tagName !== "SCRIPT" && c.tagName !== "STYLE") + 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 diff --git a/src/components/DynamicCards/styles.css b/src/components/DynamicCards/styles.css deleted file mode 100644 index 73b06033..00000000 --- a/src/components/DynamicCards/styles.css +++ /dev/null @@ -1,56 +0,0 @@ -:host { - --mock-border-color: #ccc; - --mock-bg: #f9f9f9; - --mock-pattern-dark: #e0e0e0; - --mock-pattern-light: #f5f5f5; - --mock-text-color: #999; - --mock-title-color: #666; - --mock-price-color: #333; -} - -.card { - border: 2px dashed var(--mock-border-color); - padding: 1rem; - background: var(--mock-bg, #f9f9f9); - opacity: 0.8; - text-align: center; -} - -.image { - min-height: 200px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 1rem; -} - -.text { - font-weight: bold; - font-size: 1.2rem; - color: var(--mock-text-color); -} - -.title { - margin: 0.5rem 0; - color: var(--mock-title-color); -} - -.brand { - margin: 0.5rem 0; - color: var(--mock-text-color); -} - -.price { - margin: 0.5rem 0; -} - -.price-current { - font-weight: bold; - color: var(--mock-price-color); -} - -.price-original { - text-decoration: line-through; - color: var(--mock-text-color); - margin-left: 0.5rem; -} \ No newline at end of file