UNPKG

@salla.sa/twilight-components

Version:
1,177 lines 51.6 kB
/*!
 * Crafted with ❤ by Salla
 */
import { Host, h } from "@stencil/core";
import anime from "animejs";
import ShoppingBag from "../../assets/svg/shopping-bag.svg";
import Helper from "../../Helpers/Helper";
export class SallaProductsList {
    constructor() {
        /**
         * Autoload next page when scroll
         */
        this.autoload = false;
        /**
         * Custom Card Component for the Salla Products List.
         *
         * This component allows you to customize the appearance of individual product cards within a Salla Products List.
         *
         * @example
         * <salla-products-list product-card-component="my-custom-card-style1" ...
         * <salla-products-list product-card-component="my-custom-card-style2" ...
         */
        this.productCardComponent = 'custom-salla-product-card';
        // State
        this.page = 1;
        this.placeholderText = salla.lang.get('pages.categories.no_products');
        this.endOfText = salla.lang.get('common.elements.end_of_content');
        this.failedLoadMore = salla.lang.get('common.elements.failed_to_load_more');
        this.currentPage = salla.config.get('page');
        this.filtersSnapshot = [];
        this.lastViewedProductKey = "lastViewedProductId";
        this.filtersKey = "filters";
        this.infiniteScrollStateKey = "infiniteScrollState";
        this.prevCategoryIdKey = "prevCategoryId";
        this.isProcessing = false; // Tracks if we are processing data
        this.scrollTimeout = null;
        this.boundScrollToLastViewedProduct = () => this.scrollToLastViewedProduct();
        this.boundHandleScroll = () => this.handleScroll();
        this.specialPagesWithoutIds = {
            'product.index.latest': 'latest',
            'product.index.offers': 'offers',
            'product.index.search': 'search',
            'product.index.sales': 'sales'
        };
        //TODO:: check why `this.includes` not working!!
        this.includes = Helper.parseJson(this.includes || this.host.getAttribute('includes'));
        if (!Array.isArray(this.includes)) {
            this.includes = null;
        }
        Helper.setIncludes(this.includes);
        salla.lang.onLoaded(() => {
            this.placeholderText = salla.lang.get('pages.categories.no_products');
            this.endOfText = salla.lang.get('common.elements.end_of_content');
            this.failedLoadMore = salla.lang.get('common.elements.failed_to_load_more');
            this.currentPage = salla.config.get('page');
        });
    }
    connectedCallback() {
        //Override browser scroll restoration default behaviour
        if ("scrollRestoration" in history)
            history.scrollRestoration = "manual";
        // required for scroll restoration case when the component loads before DOM content is completed (slow internet bandwidth /low device specs)
        window.addEventListener('DOMContentLoaded', this.boundScrollToLastViewedProduct);
        salla.event.on('salla-filters::changed', filters => this.setFilters(filters));
    }
    disconnectedCallback() {
        window.removeEventListener('DOMContentLoaded', this.boundScrollToLastViewedProduct);
        window.removeEventListener('scroll', this.boundHandleScroll);
    }
    /**
     * Set parsed filters data from URI
     * @param filters
     */
    async setFilters(filters) {
        if (!filters || JSON.stringify(filters) === JSON.stringify(this.parsedFilters)) {
            return;
        }
        window.scrollTo({ top: 0, behavior: 'smooth' });
        // Create a deep copy of the filters object to avoid mutating the original object
        this.parsedFilters = JSON.parse(JSON.stringify(filters));
        this.filtersSnapshot = this.parsedFilters;
        if (this.currentPage?.slug == "product.index" && this.parsedFilters && this.parsedFilters.category_id) {
            this.currentCategoryIdFilter = [this.parsedFilters.category_id];
        }
        return this.reload();
    }
    /**
     * Reload the list of products (entire content of the component).
     */
    async reload() {
        !this.autoload && this.loadMoreWrapper && (this.loadMoreWrapper.style.display = 'none');
        this.hasInfiniteScroll && salla.infiniteScroll.destroy(this.infiniteScroll);
        await this.buildNextPageUrl();
        // TODO: this is problematic in testing, for the time being it's been resolved like this
        this.wrapper.innerHTML = '';
        if (this.hasInfiniteScroll) {
            this.init();
        }
        else {
            this.getInitialData();
        }
        // Special case for the placeholder loader
        if (this.showPlaceholder) {
            this.showPlaceholder = false;
            this.placeholderLoader = document.createElement('div');
            this.placeholderLoader.classList.add('s-products-list-loading-wrapper');
            this.placeholderLoader.style.display = 'inherit';
            this.placeholderLoader.innerHTML = `<span class="s-button-loader s-button-loader-center s-infinite-scroll-btn-loader"></span>`;
            this.host.insertAdjacentElement('afterend', this.placeholderLoader);
        }
    }
    isFilterable() {
        return salla.config.get('store.settings.product.filters') && this.filtersResults;
    }
    isSourceWithoutValue() {
        return ['offers', 'latest', 'sales', 'wishlist', 'top-rated', 'reorder'].includes(this.getSource());
    }
    animateItems() {
        anime({
            targets: 'salla-products-list salla-product-card',
            opacity: [0, 1],
            duration: 1200,
            translateY: [20, 0],
            delay: function (_el, i) {
                return i * 100;
            },
        });
    }
    async initBaseNextPageUrl(source) {
        this.nextPage = salla.url.api(`products?source=${source}`);
        if (this.includes?.length) {
            this.nextPage += `&includes[]=${this.includes.join('&includes[]=')}`;
        }
        if (this.limit) {
            this.nextPage += `&per_page=${this.limit > 32 ? 32 : this.limit}`;
        }
        if (this.sortBy) {
            this.nextPage += `&sort=${this.sortBy}`;
        }
        if (salla.config.get('theme.mode') === 'preview') {
            this.nextPage += `&use_username_url=1`;
        }
        this.nextPage += '&filterable=1';
        for (const [key, value] of Object.entries(this.parsedFilters || {})) {
            if (["string", "number"].includes(typeof value)) {
                // @ts-ignore
                this.nextPage += `&filters[${encodeURIComponent(key)}]=${encodeURIComponent(value)}`;
            }
            else if (Array.isArray(value)) {
                value.forEach(item => this.nextPage += `&filters[${encodeURIComponent(key)}][]=${encodeURIComponent(item)}`);
            }
            else if (typeof value === 'object') {
                for (const [k, v] of Object.entries(value)) {
                    this.nextPage += `&filters[${encodeURIComponent(key)}][${encodeURIComponent(k)}]=${encodeURIComponent(v)}`;
                }
            }
        }
        this.nextPage = await this.applyBeforeBuildListUrl(this.nextPage);
    }
    async buildNextPageUrl() {
        let source = this.getSource();
        if (source === 'json') {
            return;
        }
        const snapshot = sessionStorage.getItem(this.infiniteScrollStateKey);
        if (snapshot) {
            const { nextPage } = JSON.parse(snapshot);
            if (!nextPage) {
                return;
            }
        }
        await this.initBaseNextPageUrl(source);
        if (this.isSourceWithoutValue()) {
            return;
        }
        if (['search', 'related', 'landing-page'].includes(source)) {
            this.nextPage += `&source_value=${this.getSourceValue()}`;
            return;
        }
        try {
            this.nextPage += `&source_value[]=${this.getSourceValue().join('&source_value[]=')}`;
        }
        catch (e) {
            salla.logger.warn(`source-value prop should be array of ids ex source-value="[1,2,3]" for the source [${source}]`);
            this.sourceValueIsValid = false;
        }
    }
    loading(isLoading = true, isBtn = false) {
        if (!isLoading) {
            if (!this.autoload) {
                this.btnLoader && (this.btnLoader.style.display = 'none');
            }
            this.loader && (this.loader.style.display = 'none');
        }
        else {
            let currentLoader = isBtn && !this.autoload ? this.btnLoader : this.loader;
            currentLoader && (currentLoader.style.display = 'inherit');
        }
    }
    getItemHTML(product) {
        //as a request they don't want to let the user to open the product details
        //todo:: find a better way to handle this request
        this.getSource() === 'landing-page' && (product.url = '');
        const customComponentTag = this.hasCustomComponent ? this.productCardComponent : 'salla-product-card';
        const productCard = document.createElement(customComponentTag);
        productCard.product = product;
        // Apply compact layout if requested - set both property and attribute for compatibility
        if (this.compactCards) {
            productCard.compact = true;
            productCard.toggleAttribute('compact', true);
        }
        this.applyLandingPageStyles(productCard);
        this.applyHorizontalCardStyles(productCard);
        // Attach click event listener to save the current products snapshot
        productCard.addEventListener('click', (event) => {
            const target = event.target;
            // Check if the clicked element is an anchor or inside an anchor
            const anchor = target.closest('a');
            if (anchor) {
                Helper.saveProductSource(this.getSource());
                if (this.hasInfiniteScroll) {
                    this.takeStateSnapshot();
                    sessionStorage.setItem(this.lastViewedProductKey, product.id);
                }
            }
        });
        return productCard;
    }
    applyLandingPageStyles(productCard) {
        if (this.getSource() === 'landing-page' && !this.hasCustomComponent) {
            productCard.toggleAttribute('hide-add-btn', true);
            productCard.classList.add('s-product-card-fit-height');
        }
    }
    applyHorizontalCardStyles(productCard) {
        if (!this.horizontalCards) {
            return;
        }
        productCard.toggleAttribute('horizontal', true);
        if (!this.hasCustomComponent) {
            productCard.toggleAttribute('shadow-on-hover', true);
        }
    }
    waitForResizing(element) {
        let timeout = null;
        return new Promise((resolve) => {
            const resizeObserver = new ResizeObserver(() => {
                clearTimeout(timeout);
                timeout = setTimeout(() => {
                    resizeObserver.disconnect();
                    resolve(null);
                }, 160); // Adjust delay as needed for your layout
            });
            resizeObserver.observe(element); // Watch the body or a specific container
        });
    }
    waitForLayoutStable(element) {
        let timeout = null;
        return new Promise((resolve) => {
            const observer = new MutationObserver(() => {
                clearTimeout(timeout);
                timeout = setTimeout(() => {
                    observer.disconnect();
                    resolve(null);
                }, 160); // Adjust delay as needed for your layout
            });
            // Observe changes to the entire body, including child nodes and attributes.
            observer.observe(element, {
                childList: true,
                subtree: true,
                attributes: true,
            });
        });
    }
    async waitForStableLayout(element) {
        // Wait for DOM mutations and layout shifts to stabilize
        return await Promise.allSettled([
            this.waitForResizing(element),
            this.waitForLayoutStable(element)
        ]);
    }
    scrollToLastViewedProduct() {
        const lastViewedProductId = sessionStorage.getItem(this.lastViewedProductKey);
        if (!lastViewedProductId || salla.url.is_page('product.single'))
            return;
        this.isElementLoaded(`[id*="${lastViewedProductId}"]`)
            .then(() => {
            const productCard = this.wrapper?.querySelector(`[id*="${lastViewedProductId}"]`);
            if (!productCard)
                return;
            const scrollToPosition = () => {
                const scrollToProductCard = () => {
                    // calculations is located here for last second changes in the ui (ie. sticky header height)
                    productCard.scrollIntoView({ block: 'start', behavior: 'instant' });
                    const headerSelector = matchMedia('(max-width: 1024px)').matches ? "header .inner" : "ul.main-menu";
                    const headerHeight = window?.header_is_sticky ? (document.querySelector(headerSelector)?.getBoundingClientRect()?.height ?? 56) : 0;
                    const cardsListRowGap = parseInt(getComputedStyle(this.wrapper).rowGap) ?? 16;
                    const productCardOffset = headerHeight + cardsListRowGap;
                    scrollBy({ top: productCardOffset * -1, behavior: 'instant' });
                };
                //start scrolling to the product card position
                requestAnimationFrame(scrollToProductCard);
                const productImages = productCard.querySelectorAll('img.lazy');
                productImages.forEach((image) => {
                    const productImage = image;
                    const handleImageEvent = () => {
                        requestAnimationFrame(scrollToProductCard);
                        productImage.onload = productImage.onerror = null;
                    };
                    productImage.onload = handleImageEvent;
                    productImage.onerror = handleImageEvent;
                });
                // remove snapshot of  product cards items in session storage after scroll restoration completiion
                this.removeScrollRestorationSession();
                return void 0;
            };
            this.waitForStableLayout(this.host).then(() => requestAnimationFrame(scrollToPosition));
        });
    }
    isElementLoaded(selector) {
        return new Promise((resolve => {
            const interval = setInterval(() => {
                if (document.querySelector(selector)) {
                    clearInterval(interval);
                    return resolve(document.querySelector(selector));
                }
            }, 50);
        }));
    }
    ;
    takeStateSnapshot() {
        const currentPageData = [];
        // Classic for-loop for performance
        for (let i = 0; i < this.wrapper.children.length; i++) {
            const child = this.wrapper.children?.[i];
            currentPageData.push(child.product);
        }
        const scrollState = {
            pageIndex: Math.max((this.infiniteScroll?.pageIndex ?? 1) - 1, 1),
            nextPage: this.nextPage,
            currentPageData
        };
        if (this.isFilterable()) {
            const existingFilters = sessionStorage.getItem(this.filtersKey);
            sessionStorage.setItem(this.filtersKey, existingFilters || JSON.stringify(this.filtersSnapshot));
        }
        sessionStorage.setItem(this.infiniteScrollStateKey, JSON.stringify(scrollState));
        // Determine and store the correct prevCategoryId
        let prevCategoryId = salla.config.get('page.id'); // Number or null
        const currentPage = Object.keys(this.specialPagesWithoutIds).find(page => salla.url.is_page(page));
        if (currentPage) {
            prevCategoryId = this.specialPagesWithoutIds[currentPage];
        }
        else if (prevCategoryId !== null) {
            prevCategoryId = String(prevCategoryId); // Ensure it's stored as a string
        }
        sessionStorage.setItem(this.prevCategoryIdKey, prevCategoryId);
    }
    removeScrollRestorationSession() {
        if (!this.isFilterable()) {
            sessionStorage.removeItem(this.filtersKey);
        }
        sessionStorage.removeItem(this.infiniteScrollStateKey);
        sessionStorage.removeItem(this.lastViewedProductKey);
        sessionStorage.removeItem(this.prevCategoryIdKey);
    }
    loadStoredScrollState() {
        const storedState = sessionStorage.getItem(this.infiniteScrollStateKey);
        const filetrsState = sessionStorage.getItem(this.filtersKey);
        /*
         * Remove the scroll restoration session under the following conditions:
         * 1. The user has navigated to a different category page:
         *    - If the previously stored category ID is different from the current one.
         *    - This includes "latest", "offers", "sales" and "search" pages.
         *
         * 2. The user is on the homepage, but infinite scroll is disabled.
         *
         * 3. The user is on a product page.
         */
        const prevCategory = sessionStorage.getItem(this.prevCategoryIdKey); // Get previously stored category
        const currentPageSlug = this.specialPagesWithoutIds[salla.config.get('page.slug')] || null;
        const currentPageId = salla.config.get('page.id'); // Number or null
        const isHomepage = salla.url.is_page('index');
        const isProductPage = salla.url.is_page('product.single');
        const isDifferentCategory = currentPageSlug
            ? prevCategory !== currentPageSlug // Compare slugs for special pages
            : prevCategory !== String(currentPageId); // Compare IDs otherwise
        if ((prevCategory && isDifferentCategory && !isHomepage && !isProductPage)
            ||
                (isHomepage && !this.hasInfiniteScroll)) {
            this.removeScrollRestorationSession();
            this.switchToNormalBehavior = true;
            return false;
        }
        // Keep the snapshot in product page that contains salla-products-list but without scroll restoration
        if (isProductPage) {
            this.switchToNormalBehavior = true;
            return false;
        }
        if (storedState) {
            try {
                const { pageIndex, nextPage, currentPageData } = JSON.parse(storedState);
                if (filetrsState !== 'undefined') {
                    const filters = JSON.parse(filetrsState);
                    salla.event.emit('filters::fetched', { filters });
                }
                this.page = pageIndex;
                this.nextPage = nextPage;
                // more performant on larger set of data
                for (let i = 0; i < currentPageData.length; i++) {
                    this.wrapper.append(this.getItemHTML(currentPageData?.[i]));
                }
                return true;
            }
            catch (error) {
                console.error('Failed to load stored scroll state:', error);
                this.removeScrollRestorationSession();
            }
        }
        return false;
    }
    getSource() {
        return Helper.getProductsSource(this.source);
    }
    /**
     * Partial product strips (mega menu, offers drawer, related products, JSON payload, …)
     * must not set `response.title` — only the main catalog grid should update the listing title.
     */
    isAuxiliaryProductsListSource() {
        return ['json', 'selected', 'related', 'landing-page', 'recently'].includes(this.getSource());
    }
    getSourceValue() {
        return this.currentCategoryIdFilter ? this.currentCategoryIdFilter : Helper.getProductsSourceValue(this.source, this.sourceValue);
    }
    logFetchError(errorPhase, error, fetchPath, http, response) {
        try {
            const body = response && typeof response === 'object' ? response : null;
            const sourceValue = this.getSourceValue();
            salla.analytics?.log('salla::products.list.fetch.error', {
                errorPhase,
                source: this.getSource(),
                sourceValue: typeof sourceValue === 'string' && sourceValue.length > 200
                    ? `${sourceValue.slice(0, 200)}…`
                    : Array.isArray(sourceValue) ? { length: sourceValue.length } : sourceValue,
                pageIndex: this.infiniteScroll?.pageIndex,
                fetchPath,
                httpStatus: http?.status,
                httpStatusText: http?.statusText,
                responseUrl: http?.url,
                ...(body && {
                    dataCount: Array.isArray(body.data) ? body.data.length : undefined,
                    responseKeys: Object.keys(body).slice(0, 15),
                    filtersCount: Array.isArray(body.filters) ? body.filters.length : undefined,
                }),
                errorMessage: error instanceof Error ? error.message : String(error),
                ...(errorPhase === 'processing' && error instanceof Error && error.stack
                    ? { errorStack: error.stack.slice(0, 500) }
                    : {}),
            });
        }
        catch { /* analytics */ }
    }
    appendDataLayer(data) {
        if (typeof dataLayer !== 'object' || !Array.isArray(dataLayer)) {
            //todo:: check if we should define it here
            return;
        }
        dataLayer.push({
            "event": "impressions",
            "ecommerce": {
                "currencyCode": salla.config.currency().code,
                // "event_id":"", // todo
                "impressions": data.map((product, index) => {
                    return {
                        "id": product.id,
                        "name": product.name,
                        "price": product.price,
                        "brand": product.brand?.name || '',
                        "quantity": product.quantity,
                        // "variant": "",
                        "categories": [
                            {
                                "name": product.category?.name || salla.config.get('page.title'),
                                "id": salla.config.get('page.id')
                            }
                        ],
                        "category": product.category?.name || salla.config.get('page.title'),
                        'position': index + 1
                    };
                })
            }
        });
    }
    initiateInfiniteScroll() {
        if (!this.hasInfiniteScroll) {
            return;
        }
        const shouldApplyManualLoad = this.autoload && this.includes && this.includes.length > 0;
        this.infiniteScroll = salla.infiniteScroll.initiate(this.wrapper, this.wrapper, {
            path: () => this.nextPage,
            history: false,
            nextPage: this.nextPage,
            scrollThreshold: shouldApplyManualLoad ? false : this.autoload ? 100 : false,
            loadOnScroll: shouldApplyManualLoad ? false : this.autoload
        }, true);
        this.infiniteScroll.pageIndex = this.page;
        this.infiniteScroll?.on('request', () => {
            this.loading(true, this.autoload ? false : true);
        });
        // Manual scroll listener
        shouldApplyManualLoad && window.addEventListener('scroll', this.boundHandleScroll);
        // infinite-scroll v4: (parsedBody, requestPath, fetchResponse)
        this.infiniteScroll?.on('load', async (response, path, httpResponse) => {
            if (this.isProcessing)
                return;
            this.isProcessing = true;
            try {
                if (!response.data?.length && this.infiniteScroll.pageIndex === 2) {
                    this.showPlaceholder = true;
                    salla.infiniteScroll.destroy(this.infiniteScroll);
                    this.loading(false);
                    this.placeholderLoader && this.placeholderLoader.remove();
                    return;
                }
                if (this.includes) {
                    await this.injectAndProcessData(response);
                }
                const items = await this.handleResponse(response);
                this.infiniteScroll.appendItems(items);
                if (this.infiniteScroll.pageIndex === 2) {
                    if (!this.autoload && this.nextPage) {
                        this.loadMoreWrapper.style.display = 'block';
                    }
                    this.animateItems();
                }
            }
            catch (error) {
                console.error('Error during load:', error);
                const res = httpResponse;
                this.logFetchError('processing', error, typeof path === 'string' ? path : undefined, res, response);
            }
            finally {
                this.isProcessing = false;
            }
        });
        this.infiniteScroll?.on('error', (error, path, httpResponse) => {
            const res = httpResponse;
            this.status.querySelector('.s-infinite-scroll-error').classList.remove('s-hidden');
            this.placeholderLoader && this.placeholderLoader.remove();
            this.loading(false);
            this.isProcessing = false;
            this.logFetchError('fetch', error, path, res);
        });
        salla.onReady(() => {
            const snapshot = sessionStorage.getItem(this.infiniteScrollStateKey);
            if (!snapshot) {
                this.manualLoadNextPage();
            }
        });
    }
    handleScroll() {
        if (this.isProcessing)
            return;
        if (this.scrollTimeout)
            return;
        this.scrollTimeout = setTimeout(() => {
            this.scrollTimeout = null;
            // Defer layout reads to next frame to avoid forced reflow (Lighthouse/PageSpeed)
            requestAnimationFrame(() => {
                const wrapperRect = this.wrapper.getBoundingClientRect();
                const windowHeight = window.innerHeight;
                const distanceToBottom = wrapperRect.bottom - windowHeight;
                // Trigger only when the wrapper bottom is within 200px of the viewport bottom
                if (distanceToBottom <= 200 && !this.isProcessing && this.nextPage) {
                    // Reached near the bottom of the wrapper, loading next page...
                    this.manualLoadNextPage();
                }
            });
        }, 200);
    }
    manualLoadNextPage() {
        if (this.nextPage) {
            this.infiniteScroll.loadNextPage();
        }
    }
    async applyBeforeBuildListUrl(url) {
        try {
            const ctx = { component: this, url };
            await salla.hooks.call('salla-products-list', 'beforeBuildListUrl', ctx);
            return ctx.url;
        }
        catch (e) {
            salla.logger.warn('beforeBuildListUrl hook failed', e?.message);
            return url;
        }
    }
    async resolveNextPageFromCursor(response) {
        if (!response.cursor) {
            return this.nextPage;
        }
        return response.cursor.next
            ? await this.applyBeforeBuildListUrl(response.cursor.next)
            : response.cursor.next;
    }
    async injectAndProcessData(response) {
        try {
            await Helper.injectExtraFieldsToResponse(response);
        }
        catch (error) {
            console.error('Error injecting data:', error);
        }
    }
    getInitialData() {
        this.loading();
        return salla.api.withoutNotifier(() => salla.product.api.fetch({
            source: Helper.getApiSource(this.getSource()),
            source_value: this.getSourceValue(),
            limit: this.limit
        }).then(async (response) => {
            if (!response.data.length) {
                this.showPlaceholder = true;
                this.placeholderLoader && this.placeholderLoader.remove();
                this.loading(false);
                return;
            }
            await Helper.injectExtraFieldsToResponse(response);
            //this.firstPageResponse will be null only in the first page, after that it will be assinged,
            //for the first page we need to inject the dom after the load, see @method componentDidLoad
            if (!this.firstPageResponse) {
                this.firstPageResponse = response;
                this.nextPage = await this.resolveNextPageFromCursor(response);
                return;
            }
            (await this.handleResponse(response)).forEach(card => this.wrapper.append(card));
        }));
    }
    /**
     * Load the next products page.
     */
    async loadMore() {
        this.infiniteScroll?.loadNextPage();
    }
    componentWillLoad() {
        return salla.onReady()
            .then(async () => {
            this.hasCustomComponent = !!customElements.get(this.productCardComponent);
            this.sourceValueIsValid = !!(this.getSourceValue() || this.isSourceWithoutValue());
            this.hasInfiniteScroll = !this.isAuxiliaryProductsListSource() && !this.limit;
            let searchParams = new URLSearchParams(window.location.search);
            try {
                this.sortBy = this.sortBy || searchParams.get('sort') || searchParams.get('by');
                this.parsedFilters = Helper.extractFiltersFromUrl(searchParams);
                if (this.parsedFilters && this.parsedFilters.category_id) {
                    this.currentCategoryIdFilter = [this.parsedFilters.category_id];
                }
            }
            catch (e) {
                salla.logger.warn('failed to get filters from url', e.message);
            }
            await this.buildNextPageUrl();
            this.isReady = true;
            const snapshot = sessionStorage.getItem(this.infiniteScrollStateKey);
            if (!!snapshot) {
                return;
            }
            if (!this.sourceValueIsValid) {
                salla.logger.warn(`source-value prop is required for source [${this.getSource()}]`);
                return;
            }
            if (this.hasInfiniteScroll) {
                return;
            }
            // Handle json source
            if (this.getSource() === 'json') {
                if (!this.getSourceValue().length) {
                    this.showPlaceholder = true;
                    return;
                }
                //todo:: avoid using timeout, just assigne the data to the this.firstPageResponse and it should work fine, because it will be rendered in componentDidLoad
                setTimeout(() => {
                    let productsList = this.getSourceValue();
                    productsList.map(product => this.wrapper.append(this.getItemHTML(product)));
                });
                return;
            }
            // Handle selected source
            if (this.getSource() === 'selected' && !this.getSourceValue().length) {
                this.showPlaceholder = true;
                return;
            }
            return this.getInitialData();
        });
    }
    async componentDidLoad() {
        this.hasInfiniteScroll && this.init();
        await Salla.hooks.registerComponent('salla-products-list', this);
        if (this.loadStoredScrollState()) {
            if (this.autoload) {
                !this.nextPage && this.loading(false);
            }
            else if (!this.nextPage) {
                this.loadMoreWrapper && (this.loadMoreWrapper.style.display = 'none');
                this.status.querySelector('.s-infinite-scroll-last').classList.remove('s-hidden');
            }
            else {
                this.loadMoreWrapper && (this.loadMoreWrapper.style.display = 'block');
            }
            this.scrollToLastViewedProduct();
        }
        else if (!this.firstPageResponse && this.switchToNormalBehavior) {
            this.getInitialData()
                .then(async () => {
                if (this.firstPageResponse) {
                    (await this.handleResponse(this.firstPageResponse, false)).forEach(card => {
                        this.wrapper.append(card);
                    });
                    setTimeout(() => {
                        if (!this.autoload && this.nextPage && this.infiniteScroll.pageIndex == 1) {
                            const loadMoreWrapper = this.host.querySelector('.s-infinite-scroll-wrapper');
                            loadMoreWrapper && (loadMoreWrapper.style.display = 'block');
                        }
                    });
                }
                else {
                    console.error("No response received after getInitialData.");
                }
            })
                .catch(error => {
                console.error("Error during initial data fetch:", error);
            });
        }
        else {
            this.firstPageResponse && (await this.handleResponse(this.firstPageResponse, false)).forEach(card => this.wrapper.append(card));
        }
    }
    canRender() {
        return this.sourceValueIsValid && this.isReady;
    }
    render() {
        if (!this.canRender()) {
            return '';
        }
        if (this.showPlaceholder) {
            return h("div", { class: "s-products-list-placeholder" }, h("span", { innerHTML: ShoppingBag }), h("p", null, this.placeholderText));
        }
        return (h(Host, { class: "s-products-list" }, h("div", { class: {
                "s-products-list-wrapper": true,
                's-products-list-horizontal-cards': this.horizontalCards && !this.filtersResults,
                's-products-list-vertical-cards': !this.horizontalCards && !this.rowCards && !this.filtersResults,
                's-products-list-row-cards': this.rowCards,
                's-products-list-compact-cards': this.compactCards,
                's-products-list-filters-results': this.filtersResults,
            }, ref: wrapper => this.wrapper = wrapper }), h("div", { class: "s-infinite-scroll-status", ref: status => this.status = status }, h("p", { class: "s-infinite-scroll-last infinite-scroll-last s-hidden" }, this.endOfText), h("p", { class: "s-infinite-scroll-error infinite-scroll-error s-hidden" }, this.failedLoadMore)), this.autoload && h("div", { class: "s-products-list-loading-wrapper", style: { "display": "none" }, ref: loader => this.loader = loader }, h("span", { class: "s-button-loader s-button-loader-center s-infinite-scroll-btn-loader" })), this.hasInfiniteScroll && this.nextPage && !this.autoload ? (h("div", { class: "s-infinite-scroll-wrapper", style: { "display": "none" }, ref: loadMoreWrapper => this.loadMoreWrapper = loadMoreWrapper }, h("button", { onClick: () => this.loadMore(), class: "s-infinite-scroll-btn s-button-btn s-button-primary" }, h("span", { class: "s-button-text s-infinite-scroll-btn-text" }, this.loadMoreText ? this.loadMoreText : salla.lang.get('common.elements.load_more')), h("span", { class: "s-button-loader s-button-loader-center s-infinite-scroll-btn-loader", ref: btnLoader => this.btnLoader = btnLoader, style: { "display": "none" } })))) : ""));
    }
    init() {
        this.initiateInfiniteScroll();
        this.loading();
    }
    async handleResponse(response, shouldBuildNextPage = true) {
        //todo:: check why it reach here undfined one time🤔
        if (!response) {
            return [];
        }
        let source = this.getSource();
        if (response.cursor?.current === 1) {
            if (!this.isAuxiliaryProductsListSource()) {
                let title = Helper.getPageTitleForSource(source);
                try {
                    if (this.getSource() === 'search') {
                        title = salla.lang.get('common.elements.search_about', { 'word': this.getSourceValue() });
                    }
                    else if (!title) {
                        let catId = this.parsedFilters.category_id || this.getSourceValue()[0];
                        // get the first filter that its key is category_id, then get the value when filter.value.*.key==catId
                        title = response.filters?.find(filter => filter.key === 'category_id')?.values?.find(cat => cat.key === catId)?.value ?? '';
                        this.filtersSnapshot = response.filters;
                    }
                    title += (title ? ' - ' : '') + salla.lang.choice('blocks.header.products_count', response.data?.length);
                    if (response.data.length === 15) {
                        title = title.replace(response.data.length, salla.lang.get('common.elements.more_than') + ' ' + response.data.length);
                    }
                    response.title = title;
                }
                catch (e) {
                    salla.logger.error('Error::falid to handle response', e);
                }
            }
            //inject the SEO schema
            //handleResponse works only on the infinite-scroll and infinite scroll works only on the categories pages and main latest-page ..etc, so no need to add condition
            Helper.generateProductSchema(response.data);
        }
        this.appendDataLayer(response.data);
        response.nextPage = this.nextPage;
        response.source = this.getSource();
        response.sourceValue = this.getSourceValue();
        salla.event.emit('salla-products-list::products.fetched', response);
        this.productsFetched.emit(response);
        //💡 when source is related, cursor will not be existed
        if (response.filters && this.isFilterable()) {
            this.filtersResults = true;
            this.filtersSnapshot = JSON.parse(JSON.stringify(response.filters));
            salla.event.emit('filters::fetched', { filters: response.filters });
        }
        else if (this.isFilterable()) {
            salla.event.emit('filters::hidden');
        }
        //because: this.nextPage is state we don't need to touch it after the build, to avoid the re-rendering
        if (shouldBuildNextPage) {
            this.nextPage = await this.resolveNextPageFromCursor(response);
        }
        this.loading(false);
        this.placeholderLoader && this.placeholderLoader.remove();
        if (this.hasInfiniteScroll && !this.nextPage) {
            this.infiniteScroll.option({ scrollThreshold: false, loadOnScroll: false });
            this.status.querySelector('.s-infinite-scroll-last').classList.remove('s-hidden');
        }
        const productCardsView = [];
        for (let i = 0; i < response.data.length; i++) {
            productCardsView.push(this.getItemHTML(response.data?.[i]));
        }
        return productCardsView;
    }
    static get is() { return "salla-products-list"; }
    static get originalStyleUrls() {
        return {
            "$": ["salla-products-list.scss"]
        };
    }
    static get styleUrls() {
        return {
            "$": ["salla-products-list.css"]
        };
    }
    static get properties() {
        return {
            "source": {
                "type": "string",
                "attribute": "source",
                "mutable": true,
                "complexType": {
                    "original": "'categories' | 'latest' | 'related' | 'brands' | 'json' | 'search' | 'tags' | 'selected' | 'offers' | 'landing-page' | 'sales' | 'wishlist' | 'top-rated' | 'reorder' | 'recently'",
                    "resolved": "\"brands\" | \"categories\" | \"json\" | \"landing-page\" | \"latest\" | \"offers\" | \"recently\" | \"related\" | \"reorder\" | \"sales\" | \"search\" | \"selected\" | \"tags\" | \"top-rated\" | \"wishlist\"",
                    "references": {}
                },
                "required": false,
                "optional": false,
                "docs": {
                    "tags": [{
                            "name": "type",
                            "text": "{string}"
                        }],
                    "text": "The source of the products list"
                },
                "getter": false,
                "setter": false,
                "reflect": true
            },
            "sourceValue": {
                "type": "any",
                "attribute": "source-value",
                "mutable": true,
                "complexType": {
                    "original": "any",
                    "resolved": "any",
                    "references": {}
                },
                "required": false,
                "optional": false,
                "docs": {
                    "tags": [{
                            "name": "type",
                            "text": "{string}"
                        }],
                    "text": "The source value, cloud be different values as following:\n- array of ids when `source` in ['categories', 'brands', 'tags', 'selected']\n- keyword when `source` = 'search'\n- products payload when `source` = 'json'\n- product_id when `source` = 'related'"
                },
                "getter": false,
                "setter": false,
                "reflect": false
            },
            "limit": {
                "type": "number",
                "attribute": "limit",
                "mutable": true,
                "complexType": {
                    "original": "number",
                    "resolved": "number",
                    "references": {}
                },
                "required": false,
                "optional": false,
                "docs": {
                    "tags": [],
                    "text": "Limit for number of products in the list."
                },
                "getter": false,
                "setter": false,
                "reflect": false
            },
            "sortBy": {
                "type": "string",
                "attribute": "sort-by",
                "mutable": true,
                "complexType": {
                    "original": "string | 'ourSuggest' | 'bestSell' | 'topRated' | 'priceFromTopToLow' | 'priceFromLowToTop'",
                    "resolved": "string",
                    "references": {}
                },
                "required": false,
                "optional": true,
                "docs": {
                    "tags": [],
                    "text": "Sorting the list of products"
                },
                "getter": false,
                "setter": false,
                "reflect": false
            },
            "filtersResults": {
                "type": "boolean",
                "attribute": "filters-results",
                "mutable": true,
                "complexType": {
                    "original": "boolean",
                    "resolved": "boolean",
                    "references": {}
                },
                "required": false,
                "optional": false,
                "docs": {
                    "tags": [],
                    "text": "should listen to filters events `salla-filters::changed` and re-render"
                },
                "getter": false,
                "setter": false,
                "reflect": true
            },
            "horizontalCards": {
                "type": "boolean",
                "attribute": "horizontal-cards",
                "mutable": false,
                "complexType": {
                    "original": "boolean",
                    "resolved": "boolean",
                    "references": {}
                },
                "required": false,
                "optional": false,
                "docs": {
                    "tags": [],
                    "text": "Horizontal cards"
                },
                "getter": false,
                "setter": false,
                "reflect": true
            },
            "rowCards": {
                "type": "boolean",
                "attribute": "row-cards",
                "mutable": false,
                "complexType": {
                    "original": "boolean",
                    "resolved": "boolean",
                    "references": {}
                },
                "required": false,
                "optional": false,
                "docs": {
                    "tags": [],
                    "text": "Row cards"
                },
                "getter": false,
                "setter": false,
                "reflect": true
            },
            "compactCards": {
                "type": "boolean",
                "attribute": "compact-cards",
                "mutable": false,
                "complexType": {
                    "original": "boolean",
                    "resolved": "boolean",
                    "references": {}
                },
                "required": false,
                "optional": false,
                "docs": {
                    "tags": [],
                    "text": "Compact cards for drawer/list layouts"
                },
                "getter": false,
                "setter": false,
                "reflect": true
            },
            "autoload": {
                "type": "boolean",
                "attribute": "autoload",
                "mutable": true,
                "complexType": {
                    "original": "boolean",
                    "resolved": "boolean",
                    "references": {}
                },
                "required": false,
                "optional": false,
                "docs": {
                    "tags": [],
                    "text": "Autoload next page when scroll"
                },
                "getter": false,
                "setter": false,
                "reflect": false,
                "defaultValue": "false"
            },
            "loadMoreText": {
                "type": "string",
                "attribute": "load-more-text",
                "mutable": false,
                "complexType": {
                    "original": "string",
                    "resolved": "string",
                    "references": {}
                },
                "required": false,
                "optional": false,
                "docs": {
                    "tags": [],
                    "text": "Load more text"
                },
                "getter": false,
                "setter": false,
                "reflect": false
            },
            "productCardComponent": {
                "type": "string",
                "attribute": "product-card-component",
                "mutable": false,
                "complexType": {
                    "original": "string",
                    "resolved": "string",
                    "references": {}
                },
                "required": false,
                "optional": false,
                "docs": {
                    "tags": [{
                            "name": "example",
                            "text": "<salla-products-list product-card-component=\"my-custom-card-style1\" ...\n<salla-products-list product-card-component=\"my-custom-card-style2\" ..."
                        }],
                    "text": "Custom Card Component for the Salla Products List.\n\nThis component allows you to customize the appearance of individual product cards within a Salla Products List."
                },
                "getter": false,
                "setter": false,
                "reflect": false,
                "defaultValue": "'custom-salla-product-card'"
            },
            "includes": {
                "type": "unknown",
                "attribute": "includes",
                "mutable": true,
                "complexType": {
                    "original": "string[]",
                    "resolved": "string[]",
                    "references": {}
                },
                "required": false,
                "optional": false,
                "docs": {
                    "tags": [],
                    "text": "Specifies additional data to be included in the product fetch response.\nThe value can be an array of strings or a JSON string.\n\nExample:\n- As an array: includes=[\"options\"]\n- As a JSON string: includes='[\"options\", \"metadata\", 'images']'\n\nThe array or JSON string can include:\n- \"options\": Include product options in the response.\n- \"images\": Include product images in the response."
                },
                "getter": false,
                "setter": false
            }
        };
    }
    static get states() {
        return {
            "page": {},
            "nextPage": {},
            "hasInfiniteScroll": {},
            "hasCustomComponent": {},
            "sourceValueIsValid": {},
            "placeholderText": {},
            "endOfText": {},
            "failedLoadMore": {},
            "currentPage": {},
            "currentCategoryIdFilter": {},
            "isReady": {},
            "showPlaceholder": {},
            "switchToNormalBehavior": {},
            "parsedFilters": {},
            "filtersSnapshot": {}
        };
    }
    static get events() {
        return [{
                "method": "productsFetched",
                "name": "productsFetched",
                "bubbles": true,
                "cancelable": true,
                "composed": true,
                "docs": {
                    "tags": [],
                    "text": "Custom event fired when the the products fetched."
                },
                "complexType": {
                    "original": "any",
                    "resolved": "any",
                    "references": {}
                }
            }];
    }
    static get methods() {
        return {
            "setFilters": {
                "complexType": {
                    "signature": "(filters: any) => Promise<void>",
                    "parameters": [{
                            "name": "filters",
                            "type": "any",
                            "docs": ""
                        }],
                    "references": {
                        "Promise": {
                            "location": "global",
                            "id": "global::Promise"
                        }
                    },
                    "return": "Promise<void>"
                },
                "docs": {
                    "text": "Set parsed filters data from URI",
                    "tags": [{
                            "name": "param",
                            "text": "filters"
                        }]
                }
            },
            "reload": {
                "complexType": {
                    "signature": "() => Promise<void>",
                    "parameters": [],
                    "references": {
                        "Promise": {
                            "location": "global",
                            "id": "global::Promise"
                        }
                    },
                    "return": "Promise<void>"
                },
                "docs": {
                    "text": "Reload the list of products (entire content of the component).",
                    "tags": []
                }
            },
            "loadMore": {
                "complexType": {
                    "signature": "() => Promise<void>",
                    "parameters": [],
                    "references": {
                        "Promise": {
                            "location": "global",
                            "id": "global::Promise"
                        }
                    },
                    "return": "Promise<void>"
                },
                "docs": {
                    "text": "Load the next products page.",
                    "tags": []
                }
            }
        };
    }
    static get elementRef() { return "host"; }
}