diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 2912e2fd..d57dc646 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -49,8 +49,8 @@ applyTo: "test/*" - Available mock products include: - `mockProductWithVariants` - Product with multiple variants (Size, Color options) - `mockProductWithoutVariants` - Product with no options - - `mockSimpleCardProduct` - Basic product for SimpleCard tests - `mockProductWithSingleValueOption` - Product with one single-value option - `mockProductAllSingleValue` - Product where all options have single values + - `createMockShopifyProducts` - Function to create multiple mock products with unique handles and IDs - Additional specialized test products as needed - When adding new mock products, add them to `src/mock/products.ts` for reusability \ No newline at end of file diff --git a/README.md b/README.md index d4d57e00..44a27109 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,7 @@ This package provides the following custom elements: | DynamicCard | `nosto-dynamic-card` | Dynamic product card templating | Shopify only | | Image | `nosto-image` | Progressive image enhancement with optimization | | | Product | `nosto-product` | Product interaction and cart management | | -| SimpleCard | `nosto-simple-card` | Simple product card templating | Shopify only | | SkuOptions | `nosto-sku-options` | Product variant and SKU selection interface | | -| VariantSelector | `nosto-variant-selector` | Product variant options as clickable pills | Shopify only | ## Documentation diff --git a/src/components/Bundle/Bundle.stories.tsx b/src/components/Bundle/Bundle.stories.tsx index 1cba3fb9..ccae8323 100644 --- a/src/components/Bundle/Bundle.stories.tsx +++ b/src/components/Bundle/Bundle.stories.tsx @@ -67,9 +67,9 @@ export const Default: Story = {
{productsWithTitles.map(product => ( - - - +
+

{product.title}

+
))}
@@ -104,10 +104,10 @@ export const CheckboxCard: Story = {
{productsWithTitles.map(product => ( - - +
+

{product.title}

- +
))}
diff --git a/src/components/Bundle/Bundle.ts b/src/components/Bundle/Bundle.ts index 769d379c..59bea8a2 100644 --- a/src/components/Bundle/Bundle.ts +++ b/src/components/Bundle/Bundle.ts @@ -5,7 +5,7 @@ import { fetchProduct } from "@/shopify/graphql/fetchProduct" import { ShopifyProduct, VariantChangeDetail } from "@/shopify/graphql/types" import { formatPrice } from "@/shopify/formatPrice" import { parseId, toVariantGid } from "@/shopify/graphql/utils" -import { EVENT_NAME_VARIANT_CHANGE } from "../VariantSelector/emitVariantChange" +import { EVENT_NAME_VARIANT_CHANGE } from "../events" import { SelectableProduct } from "./types" /** Event name for the Bundle rendered event */ diff --git a/src/components/SimpleCard/SimpleCard.stories.tsx b/src/components/SimpleCard/SimpleCard.stories.tsx deleted file mode 100644 index f23843cf..00000000 --- a/src/components/SimpleCard/SimpleCard.stories.tsx +++ /dev/null @@ -1,198 +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: Meta = { - title: "Components/SimpleCard", - component: "nosto-simple-card", - decorators: [ - (story, context) => { - // Update Shopify shop hostname if provided via args - if (context.args?.shopifyShop) { - setShopifyShop(context.args.shopifyShop) - } - return story() - } - ], - loaders: [exampleHandlesLoader], - argTypes: { - shopifyShop: { - control: "text", - description: "The Shopify store hostname" - }, - imageMode: { - control: "select", - options: ["", "alternate", "carousel"], - description: - 'Image display mode. Use "alternate" for hover image swap or "carousel" for image carousel with navigation' - }, - brand: { - control: "boolean", - description: "Show brand/vendor data" - }, - discount: { - control: "boolean", - description: "Show discount data" - }, - rating: { - control: "number", - description: "Product rating (0-5 stars)" - }, - imageSizes: { - control: "text", - description: "The sizes attribute for responsive images" - } - }, - args: { - shopifyShop, - imageMode: "", - brand: false, - discount: false, - rating: 0, - imageSizes: "" - }, - tags: ["autodocs"] -} - -export default meta -type Story = StoryObj - -export const Default: Story = { - 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[] - return ( -
- {handles.map(handle => ( - - ))} -
- ) - }, - decorators: [story =>
{story()}
] -} - -export const SingleCard: Story = { - decorators: [story =>
{story()}
], - render: (args, { loaded }) => { - const handles = loaded?.handles as string[] - return ( - - - - ) - } -} - -export const WithVariantSelector: Story = { - decorators: [story =>
{story()}
], - render: (args, { loaded }) => { - const handles = loaded?.handles as string[] - return ( - - - - - ) - } -} - -export const WithAllFeatures: Story = { - render: (args, { loaded }) => { - const handles = loaded?.handles as string[] - return ( - - ) - }, - args: { - imageMode: "alternate", - brand: true, - discount: true, - rating: 4.2 - }, - decorators: [story =>
{story()}
] -} - -export const WithCarousel: Story = { - render: (args, { loaded }) => { - const handles = loaded?.handles as string[] - return ( - - ) - }, - args: { - imageMode: "carousel", - brand: true, - discount: true, - rating: 4.2 - }, - decorators: [story =>
{story()}
] -} - -export const Mocked: Story = { - render: args => , - args: { - mock: true - }, - decorators: [story =>
{story()}
] -} diff --git a/src/components/SimpleCard/SimpleCard.ts b/src/components/SimpleCard/SimpleCard.ts deleted file mode 100644 index 92d8f3da..00000000 --- a/src/components/SimpleCard/SimpleCard.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { assertRequired } from "@/utils/assertRequired" -import { customElement, property } from "../decorators" -import { ReactiveElement } from "../Element" -import { generateCardHTML } from "./markup" -import styles from "./styles.css?raw" -import type { VariantChangeDetail } from "@/shopify/graphql/types" -import { addSkuToCart } from "@nosto/nosto-js" -import { shadowContentFactory } from "@/utils/shadowContentFactory" -import { applyDefaults } from "@/utils/applyDefaults" -import { JSONProduct } from "@nosto/nosto-js/client" -import { convertProduct } from "./convertProduct" -import { fetchProduct } from "@/shopify/graphql/fetchProduct" -import { parseId } from "@/shopify/graphql/utils" -import { handleIndicatorClick, onCarouselScroll } from "./carousel" -import { mockProduct } from "./mockProduct" -import { EVENT_NAME_VARIANT_CHANGE } from "../VariantSelector/emitVariantChange" - -const setShadowContent = shadowContentFactory(styles) - -/** Event name for the SimpleCard rendered event */ -const SIMPLE_CARD_RENDERED_EVENT = "@nosto/SimpleCard/rendered" - -type DefaultProps = Pick - -/** Default values for SimpleCard attributes */ -let simpleCardDefaults: DefaultProps = {} - -/** - * A custom element that displays a product card using Shopify product data. - * - * Fetches product data from the Shopify Storefront GraphQL API and renders a card with - * product image, title, price, and optional brand, discount, and rating information. - * - * The component renders inside a shadow DOM with encapsulated styles. Styling can be - * customized using the following CSS custom properties: - * - * {@include ./examples.md} - * - * @category Campaign level templating - * - * @property {string} handle - The Shopify product handle to fetch data for. Required. - * @property {number} [variantId] - The specific variant ID to display. When set, shows this variant's data instead of the default variant. - * @property {string} [imageMode] - Image display mode. Use "alternate" for hover image swap or "carousel" for image carousel with navigation. Defaults to undefined. - * @property {boolean} [brand] - Show brand/vendor data. Defaults to false. - * @property {boolean} [discount] - Show discount data. Defaults to false. - * @property {number} [rating] - Product rating value. Displays star rating if set. Defaults to undefined. - * @property {string} [imageSizes] - The sizes attribute for responsive images to help the browser choose the right image size. - * @property {boolean} [mock] - If true, uses mock data instead of fetching from Shopify. Defaults to false. - * - * @fires @nosto/SimpleCard/rendered - Emitted when the component has finished rendering - */ -@customElement("nosto-simple-card", { observe: true }) -export class SimpleCard extends ReactiveElement { - @property(String) handle!: string - @property(Number) variantId?: number - @property(String) imageMode?: "alternate" | "carousel" - @property(Boolean) brand?: boolean - @property(Boolean) discount?: boolean - @property(Number) rating?: number - @property(String) imageSizes?: string - @property(Boolean) mock?: boolean - - product?: JSONProduct - - #productId?: number - - constructor() { - super() - this.attachShadow({ mode: "open" }) - } - - async connectedCallback() { - // Apply default values before rendering - applyDefaults(this, simpleCardDefaults as this) - assertRequired(this, "handle") - await this.render() - this.addEventListener("click", this) - this.shadowRoot?.addEventListener("click", this) - this.addEventListener(EVENT_NAME_VARIANT_CHANGE, this) - - // Add scroll listener for carousel to update indicators - if (this.imageMode === "carousel") { - this.shadowRoot?.addEventListener("scroll", this, { capture: true }) - } - } - - disconnectedCallback() { - this.removeEventListener("click", this) - this.shadowRoot?.removeEventListener("click", this) - this.removeEventListener(EVENT_NAME_VARIANT_CHANGE, this) - - if (this.imageMode === "carousel") { - this.shadowRoot?.removeEventListener("scroll", this, { capture: true }) - } - } - - async render() { - if (this.product) { - const normalized = convertProduct(this.product) - const cardHTML = generateCardHTML(this, normalized) - setShadowContent(this, cardHTML.html) - this.dispatchEvent(new CustomEvent(SIMPLE_CARD_RENDERED_EVENT, { bubbles: true, cancelable: true })) - } - this.toggleAttribute("loading", true) - try { - const productData = this.mock ? mockProduct : await fetchProduct(this.handle) - this.#productId = parseId(productData.id) - - const cardHTML = generateCardHTML(this, productData) - setShadowContent(this, cardHTML.html) - if (!this.product) { - this.dispatchEvent(new CustomEvent(SIMPLE_CARD_RENDERED_EVENT, { bubbles: true, cancelable: true })) - } - } finally { - this.toggleAttribute("loading", false) - } - } - - handleEvent(event: Event) { - switch (event.type) { - case "click": - this.#onClick(event as MouseEvent) - break - case EVENT_NAME_VARIANT_CHANGE: - this.#onVariantChange(event as CustomEvent) - break - case "scroll": - onCarouselScroll(this, event) - break - } - } - - async #onClick(event: MouseEvent) { - if (isCarouselIndicatorClick(event)) { - event.preventDefault() - event.stopPropagation() - handleIndicatorClick(this, event) - return - } - - if (isAddToCartClick(event) && this.#productId && this.variantId) { - event.stopPropagation() - await addSkuToCart({ - productId: this.#productId.toString(), - skuId: this.variantId.toString() - }) - } - } - - #onVariantChange(event: CustomEvent) { - const { productId, variantId, handle } = event.detail - const selectedProductId = parseId(productId) - const selectedVariantId = parseId(variantId) - if (this.#productId === selectedProductId && this.variantId === selectedVariantId) { - return - } - this.#productId = selectedProductId - this.variantId = selectedVariantId - if (handle && handle !== this.handle) { - this.handle = handle - } - } -} - -function isAddToCartClick(event: MouseEvent) { - return event.target instanceof HTMLElement && event.target.hasAttribute("n-atc") -} - -function isCarouselIndicatorClick(event: MouseEvent) { - return event.target instanceof HTMLElement && event.target.classList.contains("carousel-indicator") -} - -/** - * Sets default values for SimpleCard attributes. - * These defaults will be applied to all SimpleCard instances created after this function is called. - * - * @param defaults - An object containing default values for SimpleCard attributes - * - * @example - * ```typescript - * import { setSimpleCardDefaults } from '@nosto/web-components' - * - * setSimpleCardDefaults({ - * brand: true, - * discount: true, - * imageMode: 'alternate' - * }) - * ``` - */ -export function setSimpleCardDefaults(defaults: DefaultProps) { - simpleCardDefaults = { ...defaults } -} - -declare global { - interface HTMLElementTagNameMap { - "nosto-simple-card": SimpleCard - } -} diff --git a/src/components/SimpleCard/carousel.ts b/src/components/SimpleCard/carousel.ts deleted file mode 100644 index d0223492..00000000 --- a/src/components/SimpleCard/carousel.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { SimpleCard } from "./SimpleCard" -import type { ShopifyImage } from "@/shopify/graphql/types" -import { html } from "@/templating/html" -import { generateImgHtml } from "./markup" - -export function generateCarouselHTML(element: SimpleCard, title: string, images: ShopifyImage[]) { - return html` - - ` -} - -export function handleIndicatorClick(element: SimpleCard, event: MouseEvent) { - const target = event.target as HTMLElement - const indicators = element.shadowRoot?.querySelectorAll(".carousel-indicator") - const index = indicators ? Array.from(indicators).indexOf(target) : -1 - - if (index === -1) return - - const carouselImages = element.shadowRoot?.querySelector(".carousel-images") - const slide = carouselImages?.querySelector(`.carousel-slide:nth-child(${index + 1})`) - - if (slide) { - slide.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" }) - setTimeout(() => updateCarouselIndicators(element), 300) - } -} - -export function onCarouselScroll(element: SimpleCard, event: Event) { - const target = event.target as HTMLElement - if (!target.classList.contains("carousel-images")) return - - updateCarouselIndicators(element) -} - -export function updateCarouselIndicators(element: SimpleCard) { - const carouselImages = element.shadowRoot?.querySelector(".carousel-images") as HTMLElement - if (!carouselImages) return - - const slides = carouselImages.querySelectorAll(".carousel-slide") - const indicators = element.shadowRoot?.querySelectorAll(".carousel-indicator") - if (!slides.length || !indicators) return - - const containerLeft = carouselImages.scrollLeft - const containerWidth = carouselImages.clientWidth - const centerPoint = containerLeft + containerWidth / 2 - - let closestIndex = 0 - let closestDistance = Infinity - - slides.forEach((slide, index) => { - const slideElement = slide as HTMLElement - const slideLeft = slideElement.offsetLeft - const slideCenter = slideLeft + slideElement.clientWidth / 2 - const distance = Math.abs(slideCenter - centerPoint) - - if (distance < closestDistance) { - closestDistance = distance - closestIndex = index - } - }) - - indicators.forEach((indicator, index) => { - indicator.classList.toggle("active", index === closestIndex) - }) -} diff --git a/src/components/SimpleCard/convertProduct.ts b/src/components/SimpleCard/convertProduct.ts deleted file mode 100644 index 38f6715b..00000000 --- a/src/components/SimpleCard/convertProduct.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ShopifyImage, ShopifyProduct } from "@/shopify/graphql/types" -import { JSONProduct } from "@nosto/nosto-js/client" - -export function convertProduct({ - price_currency_code, - list_price, - price, - name, - brand, - image_url, - alternate_image_urls -}: JSONProduct) { - return { - images: [image_url!, ...(alternate_image_urls ?? [])].map(url => ({ url }) as ShopifyImage), - title: name, - vendor: brand!, - compareAtPrice: { - amount: String(list_price!), - currencyCode: price_currency_code! - }, - price: { - amount: String(price), - currencyCode: price_currency_code! - } - } as ShopifyProduct -} diff --git a/src/components/SimpleCard/examples.md b/src/components/SimpleCard/examples.md deleted file mode 100644 index 9139d45e..00000000 --- a/src/components/SimpleCard/examples.md +++ /dev/null @@ -1,40 +0,0 @@ -## Examples - -### Basic product card with all features - -This example shows a fully-featured product card that displays all available information: alternate product images on hover, brand/vendor information, discount badges, and product ratings. The card fetches data from the Shopify Storefront GraphQL API and renders a complete product presentation. Internally this component renders that product card in the shadow root and can be styled using part selectors. - -```html - -``` - -### Product card with carousel mode - -This example shows a product card with image carousel navigation, allowing customers to browse through all product images with indicators. - -```html - -``` - -### Product card with nested variant selector for interactive options - -This demonstrates how to embed a variant selector within a product card for products with multiple options (like size, color). The nested `nosto-variant-selector` allows customers to select variants directly from the card, with the `preselect` attribute automatically choosing the first available option. - -```html - - - -``` - -### Product card with custom image sizes for responsive images - -This example shows how to use the `image-sizes` attribute to specify responsive image sizes. The sizes attribute helps the browser choose the right image size based on the viewport. - -```html - -``` \ No newline at end of file diff --git a/src/components/SimpleCard/markup.ts b/src/components/SimpleCard/markup.ts deleted file mode 100644 index d6fda27a..00000000 --- a/src/components/SimpleCard/markup.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { html } from "@/templating/html" -import type { SimpleCard } from "./SimpleCard" -import { getShopifyUrl } from "@/shopify/getShopifyUrl" -import { transform } from "../Image/transform" -import { ShopifyImage, ShopifyMoney, ShopifyProduct } from "@/shopify/graphql/types" -import { generateCarouselHTML } from "./carousel" -import { parseId } from "@/shopify/graphql/utils" -import { formatPrice } from "@/shopify/formatPrice" - -export function generateCardHTML(element: SimpleCard, product: ShopifyProduct) { - const hasDiscount = element.discount && isDiscounted(product) - - const selectedVariant = - (element.variantId && product.combinedVariants.find(v => parseId(v.id) === element.variantId)) || undefined - const prices = selectedVariant ?? product - const images = selectedVariant?.image && !element.imageMode ? [selectedVariant.image] : product.images - - return html` - - ` -} - -function generateImageHTML(element: SimpleCard, title: string, images: ShopifyImage[]) { - // Use media objects first, fallback to images array - const primaryImage = images[0] - if (!primaryImage) { - return html`
` - } - - // Carousel mode takes precedence over alternate mode - if (element.imageMode === "carousel" && images?.length > 1) { - return generateCarouselHTML(element, title, images) - } - - const hasAlternate = element.imageMode === "alternate" && images?.length > 1 - const alternateImage = hasAlternate ? images[1] : undefined - - return html` -
- ${generateImgHtml(primaryImage, title, "img primary", element.imageSizes)} - ${hasAlternate && alternateImage - ? generateImgHtml(alternateImage, title, "img alternate", element.imageSizes) - : ""} -
- ` -} - -function normalizeUrl(url: string) { - if (!url || url.startsWith("//") || !url.startsWith("/")) { - return url - } - return getShopifyUrl(url).toString() -} - -const defaultImageSizes = `(min-width: 1024px) 25vw, - (min-width: 768px) 33.33vw, - (min-width: 375px) 50vw, - 100vw` - -export function generateImgHtml(image: ShopifyImage, alt: string, className: string, sizes?: string) { - const { style, ...props } = transform(getImageProps(image, sizes)) - return html`${alt} value != null) - .map(([key, value]) => html`${key}="${value}" `)} - style="${styleText(style as object)}" - />` -} - -function getImageProps(image: ShopifyImage, sizes?: string) { - return { - src: normalizeUrl(image.url), - width: image.width ?? undefined, - height: image.height ?? undefined, - sizes: sizes || defaultImageSizes - } -} - -function styleText(style: object) { - return Object.entries(style) - .map(([key, value]) => `${key}: ${value};`) - .join(" ") -} - -function generateRatingHTML(rating: number) { - // Generate star display based on numeric rating - const fullStars = Math.floor(rating) - const hasHalfStar = rating % 1 >= 0.5 - const starDisplay = - "★".repeat(fullStars) + (hasHalfStar ? "☆" : "") + "☆".repeat(5 - fullStars - (hasHalfStar ? 1 : 0)) - return html`
${starDisplay} (${rating.toFixed(1)})
` -} - -function isDiscounted(prices: { compareAtPrice: ShopifyMoney | null; price: ShopifyMoney }) { - return prices.compareAtPrice && +prices.compareAtPrice.amount > +prices.price.amount -} diff --git a/src/components/SimpleCard/mockProduct.ts b/src/components/SimpleCard/mockProduct.ts deleted file mode 100644 index 789bad1a..00000000 --- a/src/components/SimpleCard/mockProduct.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { ShopifyProduct } from "@/shopify/graphql/types" -import { toProductId } from "@/shopify/graphql/utils" - -export const mockProduct = { - id: toProductId(7001), - availableForSale: true, - title: "Mock Product", - handle: "mock-product", - vendor: "Mock Brand", - onlineStoreUrl: "/products/mock-product", - images: [ - { - url: "https://cdn.nosto.com/nosto/7/mock", - width: 800, - height: 800 - } - ], - price: { - amount: "10", - currencyCode: "USD" - }, - compareAtPrice: { - amount: "12", - currencyCode: "USD" - } -} as ShopifyProduct diff --git a/src/components/SimpleCard/styles.css b/src/components/SimpleCard/styles.css deleted file mode 100644 index fc5c9976..00000000 --- a/src/components/SimpleCard/styles.css +++ /dev/null @@ -1,169 +0,0 @@ -:host { - display: block; - overflow: hidden; - background: white; - --font-family: Arial, sans-serif; - --font-size: 0.9rem; - --content-padding: 0.5rem; - --brand-color: #666; - --discount-bg: #e74c3c; - --discount-color: white; - --rating-color: inherit; - --placeholder-bg: #f5f5f5; - --placeholder-color: #999; - --slot-padding: 1rem; - --slot-padding-top: 0.5rem; - --loading-opacity: 0.7; - --carousel-indicator-bg: rgba(255, 255, 255, 0.6); - --carousel-indicator-bg-hover: rgba(255, 255, 255, 0.8); - --carousel-indicator-bg-active: rgba(255, 255, 255, 1); -} - -:host([loading]) { - opacity: var(--loading-opacity); -} - -.card { - font-family: var(--font-family); - font-size: var(--font-size); - padding: var(--content-padding); - display: flex; - flex-direction: column; - height: 100%; -} - -.image { - position: relative; - width: 100%; - overflow: hidden; -} - -.img { - width: 100%; - height: 100%; - object-fit: cover; - transition: opacity 0.3s ease; -} - -.image.alternate .img.alternate { - position: absolute; - top: 0; - left: 0; - opacity: 0; -} - -.image.alternate:hover .img.primary { - opacity: 0; -} - -.image.alternate:hover .img.alternate { - opacity: 1; -} - -.brand { - margin-bottom: 0.5rem; -} - -.title { - margin: 0 0 0.5rem; -} - -.link { - text-decoration: none; - color: var(--link-color); - font-weight: 500; -} - -.price { - align-items: center; - margin: 0.5rem 0; - display: flex; - gap: 0.5rem; -} - -.price-current { - color: var(--price-color); -} - -.price-original { - text-decoration: line-through; -} - -.discount { - background: var(--discount-bg); - color: var(--discount-color); - padding: 0.25rem 0.5rem; - border-radius: 4px; - width: fit-content; -} - -.image.placeholder { - background: var(--placeholder-bg); - min-height: 200px; - display: flex; - align-items: center; - justify-content: center; - color: var(--placeholder-color); -} - -.image.placeholder::after { - content: "No image available"; -} - -/* Carousel styles */ -.image.carousel { - position: relative; -} - -.carousel-images { - display: flex; - overflow-x: auto; - overflow-y: hidden; - scroll-snap-type: x mandatory; - scroll-behavior: smooth; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - width: 100%; -} - -.carousel-images::-webkit-scrollbar { - display: none; -} - -.carousel-slide { - flex: 0 0 100%; - scroll-snap-align: start; - scroll-snap-stop: always; - width: 100%; -} - -.carousel-indicators { - position: absolute; - bottom: 0.5rem; - left: 50%; - transform: translateX(-50%); - display: flex; - gap: 0.5rem; - z-index: 2; - pointer-events: auto; -} - -.carousel-indicator { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; - border: none; - background: var(--carousel-indicator-bg); - cursor: pointer; - padding: 0; - transition: background 0.2s ease; -} - -.carousel-indicator:hover { - background: var(--carousel-indicator-bg-hover); -} - -.carousel-indicator.active { - background: var(--carousel-indicator-bg-active); -} - diff --git a/src/components/VariantSelector/VariantSelector.stories.tsx b/src/components/VariantSelector/VariantSelector.stories.tsx deleted file mode 100644 index d32d17b3..00000000 --- a/src/components/VariantSelector/VariantSelector.stories.tsx +++ /dev/null @@ -1,276 +0,0 @@ -/** @jsx createElement */ -import type { Meta, StoryObj } from "@storybook/web-components-vite" -import { createElement } from "@/utils/jsx" -import { exampleHandlesLoader } from "@/storybook/loader" -import "./VariantSelector" -import { setShopifyShop } from "@/shopify/getShopifyUrl" - -const shopifyShop = "nosto-shopify1.myshopify.com" -setShopifyShop(shopifyShop) - -const meta: Meta = { - title: "Components/VariantSelector", - component: "nosto-variant-selector", - decorators: [ - (story, context) => { - // Update Shopify shop hostname if provided via args - if (context.args?.shopifyShop) { - setShopifyShop(context.args.shopifyShop) - } - return story() - } - ], - loaders: [exampleHandlesLoader], - argTypes: { - shopifyShop: { - control: "text", - description: "The Shopify store hostname" - }, - imageMode: { - control: "select", - options: ["", "alternate", "carousel"], - description: - 'Image display mode. Use "alternate" for hover image swap or "carousel" for image carousel with navigation' - }, - brand: { - control: "boolean", - description: "Show brand/vendor data" - }, - discount: { - control: "boolean", - description: "Show discount data" - }, - rating: { - control: "number", - description: "Product rating (0-5 stars)" - }, - sizes: { - control: "text", - description: "The sizes attribute for responsive images" - } - }, - args: { - shopifyShop, - imageMode: "", - brand: false, - discount: false, - rating: 0, - sizes: "" - }, - tags: ["autodocs"] -} - -export default meta -type Story = StoryObj - -export const Default: Story = { - 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" - } - }, - mode: { - description: "Display mode for the variant selector", - control: { type: "select" }, - options: ["options", "compact"] - }, - placeholder: { - description: - "If true, the component will display cached content from a previous render while loading new data. Useful for preventing layout shifts", - control: { type: "boolean" } - }, - preselect: { - description: "Whether to automatically preselect the options of the first available variant", - control: { type: "boolean" } - }, - maxValues: { - description: "Maximum number of option values to display per option", - control: { type: "number", min: 1, max: 10, step: 1 } - } - }, - args: { - columns: 4, - products: 12, - mode: "compact", - placeholder: false, - preselect: false, - maxValues: 5 - }, - render: (args, { loaded }) => { - const handles = loaded?.handles as string[] - return ( -
- {handles.map(handle => ( - - - - ))} -
- ) - } -} - -export const SingleProduct: Story = { - decorators: [story =>
{story()}
], - render: (_args, { loaded }) => { - const handles = loaded?.handles as string[] - return - } -} - -export const InSimpleCard: Story = { - decorators: [story =>
{story()}
], - render: (args, { loaded }) => { - const handles = loaded?.handles as string[] - return ( - - - - ) - } -} - -export const InSimpleCard_AddToCart: Story = { - decorators: [story =>
{story()}
], - render: (args, { loaded }) => { - const handles = loaded?.handles as string[] - return ( - - - - - ) - } -} - -export const WithPlaceholder: Story = { - decorators: [story =>
{story()}
], - render: (args, { loaded }) => { - const handles = loaded?.handles as string[] - return ( - - - - ) - }, - parameters: { - docs: { - description: { - story: - "When the `placeholder` attribute is set, the component will display cached content from a previous render while loading new data. This is useful for preventing layout shifts and providing a better user experience." - } - } - } -} - -export const WithMaxValues: Story = { - argTypes: { - maxValues: { - description: "Maximum number of option values to display per option", - control: { type: "number", min: 1, max: 10, step: 1 } - } - }, - args: { - maxValues: 3 - }, - render: (args, { loaded }) => { - const handles = loaded?.handles as string[] - return ( -
- {handles.slice(0, 6).map(handle => ( - - - - ))} -
- ) - }, - parameters: { - docs: { - description: { - story: - "When the `maxValues` attribute is set, the component limits the number of option values displayed per option. An ellipsis (…) is shown when there are more values available than the specified limit. This is useful for products with many option values to keep the UI compact." - } - } - } -} - -export const CompactMode: Story = { - decorators: [story =>
{story()}
], - render: (args, { loaded }) => { - const handles = loaded?.handles as string[] - return ( - - - - ) - }, - parameters: { - docs: { - description: { - story: - "When the `mode` attribute is set to `compact`, the component renders a single dropdown for all variants with unavailable variants disabled. This provides a more compact UI for products with many variants." - } - } - } -} diff --git a/src/components/VariantSelector/VariantSelector.ts b/src/components/VariantSelector/VariantSelector.ts deleted file mode 100644 index 46452b37..00000000 --- a/src/components/VariantSelector/VariantSelector.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { assertRequired } from "@/utils/assertRequired" -import { customElement, property } from "../decorators" -import { ReactiveElement } from "../Element" -import { loadAndRenderMarkup } from "./options" -import { loadAndRenderCompact } from "./compact" - -const VARIANT_SELECTOR_RENDERED_EVENT = "@nosto/VariantSelector/rendered" - -/** - * A custom element that displays product variant options as clickable pills. - * - * Fetches product data from the Shopify Storefront GraphQL API and renders option rows with - * clickable value pills. Optionally preselects the first value for each option and highlights - * the currently selected choices. Emits a custom event when variant selections change. - * - * The component renders inside a shadow DOM with encapsulated styles. Styling can be - * customized using CSS custom properties. - * - * {@include ./examples.md} - * - * @category Campaign level templating - * - * @property {string} handle - The Shopify product handle to fetch data for. Required. - * @property {string} variantId - (Optional) The ID of the variant to preselect on load. - * @property {boolean} preselect - Whether to automatically preselect the options of the first available variant. Defaults to false. - * @property {boolean} placeholder - If true, the component will display placeholder content while loading. Defaults to false. - * @property {number} maxValues - (Optional) Maximum number of option values to display per option. When exceeded, shows an ellipsis indicator. - * @property {string} mode - (Optional) Display mode: "options" or "compact". Defaults to "options". - * - * @fires @nosto/variantchange - Emitted when variant selection changes, contains { variant, product } - * @fires @nosto/VariantSelector/rendered - Emitted when the component has finished rendering - */ -@customElement("nosto-variant-selector", { observe: true }) -export class VariantSelector extends ReactiveElement { - @property(String) handle!: string - @property(Number) variantId?: number - @property(Boolean) preselect?: boolean - @property(Boolean) placeholder?: boolean - @property(Number) maxValues?: number - @property(String) mode?: "options" | "compact" - - /** - * Internal state for current selections - * @hidden - */ - selectedOptions: Record = {} - - constructor() { - super() - this.attachShadow({ mode: "open" }) - } - - async connectedCallback() { - assertRequired(this, "handle") - await this.render(true) - } - - async render(initial = false) { - this.toggleAttribute("loading", true) - try { - if (this.mode === "compact") { - await loadAndRenderCompact(this) - } else { - await loadAndRenderMarkup(this, initial) - } - this.dispatchEvent(new CustomEvent(VARIANT_SELECTOR_RENDERED_EVENT, { bubbles: true, cancelable: true })) - } finally { - this.toggleAttribute("loading", false) - } - } -} - -declare global { - interface HTMLElementTagNameMap { - "nosto-variant-selector": VariantSelector - } -} diff --git a/src/components/VariantSelector/compact/index.ts b/src/components/VariantSelector/compact/index.ts deleted file mode 100644 index b0826263..00000000 --- a/src/components/VariantSelector/compact/index.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { fetchProduct } from "@/shopify/graphql/fetchProduct" -import { VariantSelector } from "../VariantSelector" -import { shadowContentFactory } from "@/utils/shadowContentFactory" -import { html } from "@/templating/html" -import styles from "./styles.css?raw" -import { ShopifyProduct, ShopifyVariant, ShopifySelectedOption } from "@/shopify/graphql/types" -import { toVariantGid } from "@/shopify/graphql/utils" -import { emitVariantChange } from "../emitVariantChange" - -const setShadowContent = shadowContentFactory(styles) - -export async function loadAndRenderCompact(element: VariantSelector) { - const productData = await fetchProduct(element.handle) - - // Determine which variant should be selected - const selectedVariant = getSelectedVariant(element, productData) - - const template = getCompactSelectorHTML(productData, selectedVariant.id) - setShadowContent(element, template.html) - - if (selectedVariant) { - emitVariantChange(element, { - productId: productData.id, - handle: productData.handle, - variantId: selectedVariant.id - }) - } - - setupDropdownListener(element) -} - -function getCompactSelectorHTML(product: ShopifyProduct, selectedVariantGid: string) { - // Don't render if there are no variants - if (!product.combinedVariants || product.combinedVariants.length <= 1) { - return html`` - } - - // Find option names that have only one value across all variants - const fixedOptions = product.options.filter(option => option.optionValues.length === 1).map(option => option.name) - - function getOptionIndex({ name, value }: ShopifySelectedOption) { - const option = product.options.find(o => o.name === name) - return option?.optionValues.findIndex(ov => ov.name === value) ?? -1 - } - - const sortedVariants = [...product.combinedVariants].sort((a, b) => { - const optsA = a.selectedOptions ?? [] - const optsB = b.selectedOptions ?? [] - const len = Math.min(optsA.length, optsB.length) - for (let i = 0; i < len; i++) { - const optionA = optsA[i] - const optionB = optsB[i] - - if (optionA.value !== optionB.value) { - return getOptionIndex(optionA) - getOptionIndex(optionB) - } - } - return 0 - }) - - // Check if all variants are unavailable - const allVariantsUnavailable = product.combinedVariants.every(variant => !variant.availableForSale) - const disabledAttr = allVariantsUnavailable ? "disabled" : "" - - return html` -
- - -
- ` -} - -function getSelectedVariant(element: VariantSelector, product: ShopifyProduct) { - if (element.variantId) { - const variantIdStr = toVariantGid(element.variantId) - const variant = product.combinedVariants.find(v => v.id === variantIdStr) - if (variant) { - return variant - } - } - const variant = product.combinedVariants.find( - v => v.availableForSale && v.product.onlineStoreUrl === product.onlineStoreUrl - ) - return variant ? variant : product.combinedVariants[0] -} - -function generateVariantOption(variant: ShopifyVariant, selectedVariantGid: string, fixedOptions: string[]) { - const parts: string[] = [] - - if (selectedVariantGid === variant.id) { - parts.push("selected") - } - - if (!variant.availableForSale) { - parts.push("disabled") - } - - // Skip options that have only one fixed value across all variants - const variableOptions = variant.selectedOptions?.filter(o => !fixedOptions.includes(o.name)) || [] - let title = variant.title - if (variableOptions.length === 1) { - title = variableOptions[0].value - } else if (variableOptions.length > 1) { - title = variableOptions.map(o => o.value).join(" / ") - } - const additionalAttrs = parts.join(" ").trim() - - return html`` -} - -function setupDropdownListener(element: VariantSelector) { - element.shadowRoot!.addEventListener("change", async e => { - const target = e.target as HTMLSelectElement - if (target.matches("select") && !target.disabled) { - const productData = await fetchProduct(element.handle) - const variant = productData.combinedVariants.find(v => v.id === target.value) - if (variant) { - emitVariantChange(element, { productId: productData.id, handle: productData.handle, variantId: variant.id }) - } - } - }) -} diff --git a/src/components/VariantSelector/compact/styles.css b/src/components/VariantSelector/compact/styles.css deleted file mode 100644 index 13136333..00000000 --- a/src/components/VariantSelector/compact/styles.css +++ /dev/null @@ -1,64 +0,0 @@ -:host { - display: block; - font-family: inherit; - - --dropdown-bg: white; - --dropdown-border: 1px solid #e1e5e9; - --dropdown-padding: 0.5rem 1rem; - --dropdown-margin-bottom: 1rem; - --dropdown-border-radius: 4px; - --dropdown-focus-border: #007bff; - --dropdown-focus-outline: 2px solid #007bff; - --dropdown-focus-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); - --dropdown-disabled-opacity: 0.6; - --dropdown-disabled-bg: #f5f5f5; - --loading-opacity: 0.7; -} - -:host([loading]) { - opacity: var(--loading-opacity); -} - -.compact-selector { - display: block; - margin-bottom: var(--dropdown-margin-bottom); -} - -select { - width: 100%; - appearance: none; - background-color: var(--dropdown-bg); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23333' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 0.75rem center; - background-size: 12px 12px; - border: var(--dropdown-border); - border-radius: var(--dropdown-border-radius); - padding: var(--dropdown-padding); - padding-right: 2.5rem; - font-family: inherit; - font-size: inherit; - line-height: 1.5; - cursor: pointer; - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} - -select:hover { - border-color: var(--dropdown-focus-border); -} - -select:focus { - border-color: var(--dropdown-focus-border); - outline: 0; - box-shadow: var(--dropdown-focus-shadow); -} - -select:disabled { - opacity: var(--dropdown-disabled-opacity); - cursor: not-allowed; - background-color: var(--dropdown-disabled-bg); -} - -select option:disabled { - opacity: var(--dropdown-disabled-opacity); -} diff --git a/src/components/VariantSelector/emitVariantChange.ts b/src/components/VariantSelector/emitVariantChange.ts deleted file mode 100644 index de1377ee..00000000 --- a/src/components/VariantSelector/emitVariantChange.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { VariantChangeDetail } from "@/shopify/graphql/types" -import { VariantSelector } from "./VariantSelector" -import { parseId } from "@/shopify/graphql/utils" - -export const EVENT_NAME_VARIANT_CHANGE = "@nosto/variantchange" - -export function emitVariantChange(element: VariantSelector, detail: VariantChangeDetail) { - const selectedVariantId = parseId(detail.variantId) - if (element.variantId === selectedVariantId && element.handle === detail.handle) { - return - } - element.handle = detail.handle - element.variantId = selectedVariantId - element.dispatchEvent( - new CustomEvent(EVENT_NAME_VARIANT_CHANGE, { - detail, - bubbles: true - }) - ) -} diff --git a/src/components/VariantSelector/examples.md b/src/components/VariantSelector/examples.md deleted file mode 100644 index ee133d58..00000000 --- a/src/components/VariantSelector/examples.md +++ /dev/null @@ -1,9 +0,0 @@ -## Examples - -### Basic variant selector - -This example shows a basic variant selector that fetches product data from the Shopify Storefront GraphQL API and displays product option rows with clickable value pills. Users can select different variant options (like size, color, material) and the component emits variant change events for integration with other components. Internally VariantSelector renders the content in the shadow root and exposes parts for external styling. - -```html - -``` \ No newline at end of file diff --git a/src/components/VariantSelector/options/index.ts b/src/components/VariantSelector/options/index.ts deleted file mode 100644 index c98e8595..00000000 --- a/src/components/VariantSelector/options/index.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { fetchProduct } from "@/shopify/graphql/fetchProduct" -import { VariantSelector } from "../VariantSelector" -import { generateVariantSelectorHTML } from "./markup" -import { shadowContentFactory } from "@/utils/shadowContentFactory" -import styles from "./styles.css?raw" -import { ShopifyProduct, ShopifyVariant } from "@/shopify/graphql/types" -import { toVariantGid } from "@/shopify/graphql/utils" -import { emitVariantChange } from "../emitVariantChange" - -const setShadowContent = shadowContentFactory(styles) - -let placeholder = "" - -export async function loadAndRenderMarkup(element: VariantSelector, initial = false) { - if (initial && element.placeholder && placeholder) { - setShadowContent(element, placeholder) - } - const productData = await fetchProduct(element.handle) - - // Initialize selections with first value of each option - initializeDefaultSelections(element, productData) - - const selectorHTML = generateVariantSelectorHTML(element, productData) - setShadowContent(element, selectorHTML.html) - - // Cache the rendered HTML for placeholder use - placeholder = selectorHTML.html - - // Setup event listeners for option buttons - setupOptionListeners(element) - - // active state for selected options - updateActiveStates(element) - // unavailable state for options without available variants - updateUnavailableStates(element, productData) - // TODO disabled state - - if (Object.keys(element.selectedOptions).length > 0) { - const variant = getSelectedVariant(element, productData) - if (variant) { - emitVariantChange(element, { productId: productData.id, handle: productData.handle, variantId: variant.id }) - } - } -} - -function initializeDefaultSelections(element: VariantSelector, product: ShopifyProduct) { - let variant: ShopifyVariant | undefined - if (element.variantId) { - const variantIdStr = toVariantGid(element.variantId) - variant = product.combinedVariants.find(v => v.id === variantIdStr) - } else if (element.preselect) { - variant = product.combinedVariants.find( - v => v.availableForSale && v.product.onlineStoreUrl === product.onlineStoreUrl - ) - } - if (variant && variant.selectedOptions) { - variant.selectedOptions.forEach(selectedOption => { - element.selectedOptions[selectedOption.name] = selectedOption.value - }) - } else { - product.options.forEach(option => { - if (option.optionValues.length === 1) { - element.selectedOptions[option.name] = option.optionValues[0].name - } - }) - } -} - -function setupOptionListeners(element: VariantSelector) { - element.shadowRoot!.addEventListener("click", async e => { - const target = e.target as HTMLElement - if (target.classList.contains("value")) { - e.preventDefault() - const { optionName, optionValue } = target.dataset - if (optionName && optionValue) { - await selectOption(element, optionName, optionValue) - } - } - }) -} - -export async function selectOption(element: VariantSelector, optionName: string, value: string) { - if (element.selectedOptions[optionName] === value) { - return - } - element.selectedOptions[optionName] = value - updateActiveStates(element) - - // Fetch product data and emit variant change - const productData = await fetchProduct(element.handle) - const variant = getSelectedVariant(element, productData) - if (variant) { - emitVariantChange(element, { productId: productData.id, handle: productData.handle, variantId: variant.id }) - } -} - -function updateActiveStates(element: VariantSelector) { - element.shadowRoot!.querySelectorAll(".value").forEach(button => { - const { optionName, optionValue } = button.dataset - const active = !!optionName && element.selectedOptions[optionName] === optionValue - togglePart(button, "active", active) - }) -} - -function updateUnavailableStates(element: VariantSelector, product: ShopifyProduct) { - const availableOptions = new Set() - product.combinedVariants - .filter(v => v.availableForSale) - .forEach(variant => { - if (variant.selectedOptions) { - variant.selectedOptions.forEach(selectedOption => { - availableOptions.add(`${selectedOption.name}::${selectedOption.value}`) - }) - } - }) - element.shadowRoot!.querySelectorAll(".value").forEach(button => { - const { optionName, optionValue } = button.dataset - const available = availableOptions.has(`${optionName}::${optionValue}`) - togglePart(button, "unavailable", !available) - }) -} - -function togglePart(element: HTMLElement, partName: string, enable: boolean) { - const parts = new Set(element.getAttribute("part")?.split(" ").filter(Boolean) || []) - if (enable) { - parts.add(partName) - } else { - parts.delete(partName) - } - element.setAttribute("part", Array.from(parts).join(" ")) -} - -export function getSelectedVariant(element: VariantSelector, product: ShopifyProduct): ShopifyVariant | null { - return ( - product.combinedVariants?.find(variant => { - if (!variant.selectedOptions) return false - return product.options.every(option => { - const selectedValue = element.selectedOptions[option.name] - const variantOption = variant.selectedOptions!.find(so => so.name === option.name) - return variantOption && selectedValue === variantOption.value - }) - }) || null - ) -} diff --git a/src/components/VariantSelector/options/markup.ts b/src/components/VariantSelector/options/markup.ts deleted file mode 100644 index afef2c53..00000000 --- a/src/components/VariantSelector/options/markup.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { html } from "@/templating/html" -import type { VariantSelector } from "../VariantSelector" -import { ShopifyOption, ShopifyOptionValue, ShopifyProduct } from "@/shopify/graphql/types" - -export function generateVariantSelectorHTML(element: VariantSelector, product: ShopifyProduct) { - // Don't render if there are no options - if (!product.options || product.options.length === 0) { - return html`` - } - - // Check if there are any multi-value options (options with more than one value) - const hasMultiValueOptions = product.options.some(option => option.optionValues.length > 1) - - // If all options are single-value, don't render the selector - if (!hasMultiValueOptions) { - return html`` - } - - return html` -
- ${product.options.map(option => generateOptionRowHTML(option, element.maxValues))} -
- ` -} - -function generateOptionRowHTML(option: ShopifyOption, maxValues?: number) { - if (option.optionValues.length <= 1) { - return "" - } - - const valuesToRender = maxValues ? option.optionValues.slice(0, maxValues) : option.optionValues - const hasMore = maxValues && option.optionValues.length > maxValues - - return html` -
-
${option.name}:
-
- ${valuesToRender.map(value => generateOptionValueHTML(option.name, value))}${hasMore - ? generateEllipsis(option.optionValues.length - maxValues!) - : ""} -
-
- ` -} - -function generateEllipsis(moreCount: number) { - return html`` -} - -function generateOptionValueHTML(name: string, value: ShopifyOptionValue) { - // TODO expand to actual swatch rendering - return html` - - ` -} diff --git a/src/components/VariantSelector/options/styles.css b/src/components/VariantSelector/options/styles.css deleted file mode 100644 index e100ba4b..00000000 --- a/src/components/VariantSelector/options/styles.css +++ /dev/null @@ -1,92 +0,0 @@ -:host { - display: block; - font-family: inherit; - - --row-gap: 0.5rem; - --row-margin: 1rem; - --values-gap: 0.5rem; - --value-bg: #f8f9fa; - --value-border: 1px solid #e1e5e9; - --value-padding: 0.5rem 1rem; - --value-margin-bottom: 1rem; - --value-hover-bg: #e9ecef; - --value-hover-border: #adb5bd; - --value-active-bg: #007bff; - --value-active-border: #007bff; - --value-active-color: white; - --value-active-hover-bg: #0056b3; - --value-active-hover-border: #0056b3; - --value-focus-color: #007bff; - --value-unavailable-opacity: 0.5; - --loading-opacity: 0.7; - --ellipsis-color: #6c757d; -} - -:host([loading]) { - opacity: var(--loading-opacity); -} - -.selector { - display: block; - margin-bottom: var(--value-margin-bottom); -} - -.row { - display: flex; - flex-direction: column; - gap: var(--row-gap); - margin-bottom: var(--row-margin); -} - -.row:last-child { - margin-bottom: 0; -} - -.values { - display: flex; - flex-wrap: wrap; - gap: var(--values-gap); -} - -.value { - background: var(--value-bg); - border: var(--value-border); - cursor: pointer; - padding: var(--value-padding); - transition: all 0.2s ease; - white-space: nowrap; -} - -.value:hover { - background: var(--value-hover-bg); - border-color: var(--value-hover-border); -} - -.value[part="value active"] { - background: var(--value-active-bg); - border-color: var(--value-active-border); - color: var(--value-active-color); -} - -.value[part="value active"]:hover { - background: var(--value-active-hover-bg); - border-color: var(--value-active-hover-border); -} - -.value[part="value unavailable"] { - cursor: not-allowed; - opacity: var(--value-unavailable-opacity); - pointer-events: none; -} - -.ellipsis { - display: inline-flex; - align-items: center; - justify-content: center; - padding: var(--value-padding); - color: var(--ellipsis-color); - font-size: 1.2em; - line-height: 1; - user-select: none; -} - diff --git a/src/components/events.ts b/src/components/events.ts new file mode 100644 index 00000000..111609fe --- /dev/null +++ b/src/components/events.ts @@ -0,0 +1,6 @@ +/** + * Shared event names used across components + */ + +/** Event name for variant change events */ +export const EVENT_NAME_VARIANT_CHANGE = "@nosto/variantchange" diff --git a/src/main.ts b/src/main.ts index c5a0f2a8..2c67f3d1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,5 @@ export { DynamicCard, setDynamicCardDefaults } from "./components/DynamicCard/Dy export { Image } from "./components/Image/Image" export { Product } from "./components/Product/Product" export { SectionCampaign } from "./components/SectionCampaign/SectionCampaign" -export { SimpleCard, setSimpleCardDefaults } from "./components/SimpleCard/SimpleCard" export { SkuOptions } from "./components/SkuOptions/SkuOptions" -export { VariantSelector } from "./components/VariantSelector/VariantSelector" export { setShopifyShop } from "./shopify/getShopifyUrl" diff --git a/test/components/Bundle/Bundle.spec.tsx b/test/components/Bundle/Bundle.spec.tsx index 1d3e8730..1e40e79a 100644 --- a/test/components/Bundle/Bundle.spec.tsx +++ b/test/components/Bundle/Bundle.spec.tsx @@ -1,38 +1,22 @@ import { describe, it, expect, vi, afterEach } from "vitest" import { Bundle } from "@/components/Bundle/Bundle" -import { SimpleCard } from "@/components/SimpleCard/SimpleCard" import { createElement } from "@/utils/jsx" import { createMockShopifyProducts } from "@/mock/products" import type { JSONProduct } from "@nosto/nosto-js/client" import { addProductHandlers } from "../../utils/addProductHandlers" -import { EVENT_NAME_VARIANT_CHANGE } from "@/components/VariantSelector/emitVariantChange" +import { EVENT_NAME_VARIANT_CHANGE } from "@/components/events" import { VariantChangeDetail } from "@/shopify/graphql/types" import { getEventPromise } from "../../utils/getEventPromise" async function waitForRender(bundle: Bundle) { - const cards = bundle.querySelectorAll("nosto-simple-card") - // Set up all listeners BEFORE appending to DOM or calling connectedCallback const bundlePromise = getEventPromise(bundle, "@nosto/Bundle/rendered") - const cardPromises = Array.from(cards).flatMap(card => { - const promises: Promise[] = [] - const variantSelector = card.querySelector("nosto-variant-selector") - - if (variantSelector) { - promises.push(getEventPromise(variantSelector, "@nosto/VariantSelector/rendered")) - } - - promises.push(getEventPromise(card, "@nosto/SimpleCard/rendered")) - - return promises - }) - // Now append to DOM to trigger connectedCallback document.body.appendChild(bundle) // Wait for all render events - return await Promise.all([bundlePromise, ...cardPromises]) + return await bundlePromise } describe("Bundle", () => { @@ -95,8 +79,8 @@ describe("Bundle", () => { }) const bundle = ( - - +
+
@@ -121,8 +105,8 @@ describe("Bundle", () => { }) const bundle = ( - - +
+
@@ -172,8 +156,8 @@ describe("Bundle", () => { const bundle = ( - - +
+
@@ -183,7 +167,7 @@ describe("Bundle", () => { await bundle.connectedCallback() const input = bundle.querySelector('input[type="checkbox"]')! - const card = bundle.querySelector('nosto-simple-card[handle="product1"]') + const card = bundle.querySelector('div[handle="product1"]') input.checked = true input.dispatchEvent(new Event("input", { bubbles: true })) @@ -199,8 +183,8 @@ describe("Bundle", () => { const bundle = ( - - +
+
@@ -210,7 +194,7 @@ describe("Bundle", () => { await bundle.connectedCallback() const input = bundle.querySelector('input[type="checkbox"]')! - const card = bundle.querySelector('nosto-simple-card[handle="product1"]') + const card = bundle.querySelector('div[handle="product1"]') // Remove product from selection input.checked = false @@ -227,12 +211,12 @@ describe("Bundle", () => { const bundle = ( - +
- - +
+
- +
) as Bundle @@ -240,7 +224,7 @@ describe("Bundle", () => { await bundle.connectedCallback() const input = bundle.querySelector('input[type="checkbox"]')! - const card = bundle.querySelector('nosto-simple-card[handle="product1"]') + const card = bundle.querySelector('div[handle="product1"]') // Remove product from selection input.checked = false @@ -257,12 +241,8 @@ describe("Bundle", () => { const bundle = ( - - - - - - +
+
@@ -274,23 +254,19 @@ describe("Bundle", () => { const summary = bundle.querySelector("span[n-summary-price]")! expect(summary.textContent).toBe("Total: $201.00") - const variantSelectorShadowRoot = document.querySelector("nosto-variant-selector")!.shadowRoot - expect(variantSelectorShadowRoot).toBeTruthy() - const select = variantSelectorShadowRoot!.querySelector("select") - expect(select).toBeTruthy() - - const variantChangePromise = new Promise(resolve => { - bundle.addEventListener(EVENT_NAME_VARIANT_CHANGE, event => { - const { variantId } = (event as CustomEvent).detail - expect(variantId).toBe("gid://shopify/ProductVariant/2") - expect(summary.textContent).toBe("Total: $221.00") - resolve() - }) + // Simulate variant change event + const variantChangeEvent = new CustomEvent(EVENT_NAME_VARIANT_CHANGE, { + detail: { + variantId: "gid://shopify/ProductVariant/2", + productId: "gid://shopify/Product/1", + handle: "product1" + }, + bubbles: true }) - select!.value = "gid://shopify/ProductVariant/2" - select!.dispatchEvent(new Event("change", { bubbles: true })) - await variantChangePromise + bundle.dispatchEvent(variantChangeEvent) + + expect(summary.textContent).toBe("Total: $221.00") }) it("triggers add to cart logic when clicking element with n-atc attribute", async () => { @@ -379,13 +355,9 @@ describe("Bundle", () => { }) const bundle = ( - - - - - - - s +
+
+ @@ -410,12 +382,8 @@ describe("Bundle", () => { ] as unknown as JSONProduct[] } > - - - - - - +
+
diff --git a/test/components/SimpleCard/SimpleCard.spec.tsx b/test/components/SimpleCard/SimpleCard.spec.tsx deleted file mode 100644 index 83c622c9..00000000 --- a/test/components/SimpleCard/SimpleCard.spec.tsx +++ /dev/null @@ -1,788 +0,0 @@ -/** @jsx createElement */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" -import { SimpleCard, setSimpleCardDefaults } from "@/components/SimpleCard/SimpleCard" -import { createElement } from "@/utils/jsx" -import type { GraphQLProduct, VariantChangeDetail } from "@/shopify/graphql/types" -import { JSONProduct } from "@nosto/nosto-js/client" -import { toProductId } from "@/shopify/graphql/utils" -import { clearProductCache } from "@/shopify/graphql/fetchProduct" -import { createMockShopifyProducts } from "@/mock/products" -import { EVENT_NAME_VARIANT_CHANGE } from "@/components/VariantSelector/emitVariantChange" -import { addProductHandlers } from "../../utils/addProductHandlers" - -describe("SimpleCard", () => { - const handle = "test-product" - const mockedProduct = createMockShopifyProducts(1)[0] - - beforeEach(() => { - clearProductCache() - }) - - afterEach(() => { - // Reset defaults after each test - setSimpleCardDefaults({}) - }) - - function getShadowContent(card: SimpleCard) { - const shadowContent = card.shadowRoot?.innerHTML || "" - // Remove the style tag and its content to get just the HTML content - return shadowContent.replace(/