(async () => { if (!document.querySelector(".ex-rewards")) return; document.querySelector(".new-rewards-row").classList.remove("hide"); const pageContent = document.querySelector(".page-content"); const oldRewards = pageContent.querySelector(":scope > .rewards"); oldRewards.classList.add("rewards-native", "hide"); const newRewards = document.querySelector(".new-rewards-section"); const newRewardItemsList = newRewards.querySelector(".new-reward-items-list"); const rewardItemsTitle = newRewards.querySelector(".reward-items-title span"); const filterSection = newRewards.querySelector(".rewards-filter"); const rewardItems = document.querySelectorAll("#rewardItemsList .reward-item"); const allItems = []; const allItemsFiltering = []; // Apply User name newRewards.querySelector(".points-info__title span").textContent = EvoXLayer().user.name; // ===== HELPERS ===== // const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const fetchWithRetry = async (path, retries = 3, delay = 1000) => { for (let attempt = 0; attempt < retries; attempt++) { const response = await fetch(`${window.location.origin}${path}`, { method: "GET", headers: { Accept: "text/html" }, credentials: "include", }); if (response.status === 429) { console.warn(`Rate limited on ${path}, retrying in ${delay * (attempt + 1)}ms…`); await sleep(delay * (attempt + 1)); continue; } if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return response; } throw new Error(`Failed after ${retries} retries: ${path}`); }; // ===== CLASSES ===== // // ******************* // class VirtualProductFilter { constructor(allItems, config = {}) { this.allItems = allItems; this.title = config.title; this.container = config.container; this.itemsPerPage = config.itemsPerPage || 50; this.buffer = config.buffer || 10; this.title.textContent = `${this.allItems.length} ${this.allItems.length === 1 ? "reward" : "rewards"} found`; this.products = this.extractProductData(allItems); this.buildIndices(); this.filteredProducts = [...this.products]; this.renderedItems = new Set(); this.currentPage = 0; this.setupVirtualContainer(); this.setupIntersectionObserver(); this.createLoadMoreButton(); } createLoadMoreButton() { this.loadMoreBtn = document.createElement("button"); this.loadMoreBtn.classList = "btn btn-primary btn-load-more"; this.loadMoreBtn.innerHTML = "Load More"; this.loadMoreBtn.style.cssText = "display: none; margin: 20px auto; padding: 12px 30px;"; this.loadMoreBtn.addEventListener("click", () => { this.loadMoreItems(); }); this.container.appendChild(this.loadMoreBtn); this.updateLoadMoreButton(); } updateLoadMoreButton() { const hasMore = this.renderedItems.size < this.filteredProducts.length; if (hasMore) { this.loadMoreBtn.style.display = "block"; const remaining = this.filteredProducts.length - this.renderedItems.size; this.loadMoreBtn.innerHTML = `Load More (${remaining} remaining)`; } else { this.loadMoreBtn.style.display = "none"; } } extractProductData(elements) { return elements.map((el, index) => ({ id: index, element: el, title: el.querySelector(".product-title")?.textContent.trim() || "", sku: el.querySelector(".product-details-sku")?.textContent.replace("Product Code", "").trim(), category: el.querySelector(".product-details-category a")?.textContent.trim() || "", brand: el.querySelector(".product-details-brand img")?.alt || "", price: Number((el.querySelector(".product-price")?.textContent || "").trim().replace(/,/g, "")) || 0, disabled: el.querySelector("button")?.hasAttribute("disabled") || false, })); } buildIndices() { this.indices = { byCategory: new Map(), byPrice: [...this.products].sort((a, b) => a.price - b.price), }; Object.keys(rewardsCategoryFilterOptions).forEach((category) => { this.indices.byCategory.set(category, []); }); this.indices.byCategory.set("Other", []); this.products.forEach((product) => { const categoryName = product.category || ""; const sku = product.sku || ""; let matchedGroup = "Other"; for (let [group, regex] of Object.entries(rewardsCategoryFilterOptions)) { if (regex.test(categoryName) || regex.test(sku)) { matchedGroup = group; break; } } this.indices.byCategory.get(matchedGroup).push(product); }); } setupVirtualContainer() { this.container.replaceChildren(); this.spacer = document.createElement("div"); this.spacer.style.height = "0px"; this.container.append(this.spacer); this.viewport = document.createElement("div"); this.viewport.classList = "row items-row"; this.container.append(this.viewport); } setupIntersectionObserver() { const sentinel = document.createElement("div"); sentinel.style.height = "1px"; sentinel.dataset.sentinel = "true"; this.container.appendChild(sentinel); this.observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) this.loadMoreItems(); }); }, { rootMargin: "200px" }, ); this.observer.observe(sentinel); } filter({ category, minPrice, maxPrice, search } = {}) { let result = this.products; if (category !== undefined && category !== null && category !== "all") { result = this.indices.byCategory.get(category.toLowerCase()) || []; } if (minPrice !== undefined || maxPrice !== undefined) { const min = minPrice || 0; const max = maxPrice || Infinity; result = result.filter((p) => p.price >= min && p.price <= max); } if (search) { const term = search.toLowerCase(); result = result.filter((p) => p.title.toLowerCase().includes(term) || p.sku.toLowerCase().includes(term)); } this.filteredProducts = result; this.currentPage = 0; this.renderedItems.clear(); this.viewport.replaceChildren(); this.title.textContent = `${this.filteredProducts.length} ${this.filteredProducts.length === 1 ? "reward" : "rewards"} found`; this.renderBatch(0, this.itemsPerPage); this.updateLoadMoreButton(); } search(term) { return this.filter({ search: term }); } reset() { this.filteredProducts = [...this.products]; this.currentPage = 0; this.renderedItems.clear(); this.viewport.replaceChildren(); this.renderBatch(0, this.itemsPerPage); this.updateLoadMoreButton(); return this.products.length; } renderBatch(start, count) { if (!this.filteredProducts.length) { this.viewport.replaceChildren(); const rewardItem = document.createElement("div"); rewardItem.classList = "col-sm-12 reward-item reward-item-placeholder"; rewardItem.innerHTML = `

No rewards found

Try adjusting your filters to see more options

`; this.viewport.appendChild(rewardItem); this.updateLoadMoreButton(); return; } this.viewport.querySelectorAll(".reward-item-placeholder").forEach((e) => e.remove()); const end = Math.min(start + count, this.filteredProducts.length); const fragment = document.createDocumentFragment(); const clones = []; for (let i = start; i < end; i++) { if (this.renderedItems.has(i)) continue; const product = this.filteredProducts[i]; const clone = product.element.cloneNode(true); fragment.appendChild(clone); clones.push(clone); this.renderedItems.add(i); } this.viewport.appendChild(fragment); clones.forEach((clone) => setTimeout(() => clone.classList.add("reward-item-new"), 10)); this.updateLoadMoreButton(); } loadMoreItems() { const nextStart = this.renderedItems.size; if (nextStart < this.filteredProducts.length) { this.renderBatch(nextStart, this.itemsPerPage); this.updateLoadMoreButton(); } } } // *********************** // // ===== END CLASSES ===== // // ===== FUNCTIONS ===== // // ********************* // const getOrCreateProductObject = (scriptElement) => { const scriptContent = scriptElement.textContent; const objNameMatch = scriptContent.match(/var\s+(obj_\d+)/); if (!objNameMatch) return null; const objName = objNameMatch[1]; if (window[objName] && typeof window[objName] === "object") return window[objName]; try { const script = document.createElement("script"); script.textContent = scriptContent; document.head.appendChild(script); document.head.removeChild(script); return window[objName]; } catch (error) { return null; } }; const getResponseFromServer = async (path) => { try { const response = await fetch(`${window.location.origin}${path}`, { method: "GET", headers: { Accept: "text/html" }, credentials: "include", }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return [await response.text(), response.headers.get("content-type")]; } catch (error) { console.error(`Error fetching ${path}:`, error); return null; } }; // Extract numbered page links (excludes active, next, prev) const getPaginationLinks = (doc = document) => { const lis = doc.querySelectorAll("#rewardItemsList .pagination > li:not(.active):not(.next-page):not(.prev-page)"); const uniqueHrefs = new Set(); Array.from(lis).forEach((li) => { const href = li.querySelector("a")?.getAttribute("href"); if (href) uniqueHrefs.add(href); }); return Array.from(uniqueHrefs); }; // Returns the "next >" href if present, else null const getNextPageLink = (doc = document) => { return doc.querySelector("#rewardItemsList .pagination > li.next-page a")?.getAttribute("href") || null; }; // Fetch one rewards page, collect items, return parsed doc const fetchRewardsPage = async (path) => { const response = await fetchWithRetry(path); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); doc.querySelectorAll(".reward-item").forEach((el) => { allItems.push(el); const script = el.querySelector("script"); if (script) getOrCreateProductObject(script); }); return doc; }; // Crawl all paginated pages — parallel within each batch, sequential between batches const fetchAllRewardsPages = async () => { const initialLinks = getPaginationLinks(document); if (!initialLinks.length) return; const visitedPaths = new Set(initialLinks); // Fetch first visible batch (pages 2–5) in parallel const docs = await Promise.all(initialLinks.map((link) => fetchRewardsPage(link))); await sleep(300); let lastDoc = docs[docs.length - 1] || document; while (true) { const nextLink = getNextPageLink(lastDoc); if (!nextLink || visitedPaths.has(nextLink)) break; // Fetch the "next >" pivot page to reveal the next batch visitedPaths.add(nextLink); lastDoc = await fetchRewardsPage(nextLink); await sleep(300); // Fetch remaining visible pages from that batch in parallel const batchLinks = getPaginationLinks(lastDoc).filter((l) => !visitedPaths.has(l)); batchLinks.forEach((l) => visitedPaths.add(l)); if (batchLinks.length > 0) { const batchDocs = await Promise.all(batchLinks.map((link) => fetchRewardsPage(link))); await sleep(300); lastDoc = batchDocs[batchDocs.length - 1]; } } }; const createCategoryFilterTabs = () => { const categoryNames = ["all"]; categoryNames.push(...Object.keys(rewardsCategoryFilterOptions)); const filterWrapper = filterSection.querySelector(".category-tab-wrapper"); filterWrapper.addEventListener("click", (e) => { if (e.target.tagName === "BUTTON") { addRemoveActive(e.target); filterProducts(); } }); categoryNames.forEach((e, index) => { const newDiv = document.createElement("div"); newDiv.classList = `tab-element category-tab ${index === 0 ? "active" : ""}`; newDiv.innerHTML = ``; filterWrapper.append(newDiv); }); }; const addRemoveActive = (element) => { const isDelete = element.classList.contains("delete-filter"); const tabWrapper = element.closest(".tab-wrapper"); const tabElement = element.closest(".tab-element"); const activeList = (tabElement && tabWrapper && tabWrapper.querySelectorAll(":scope > .active")) || (isDelete && tabWrapper && tabWrapper.querySelectorAll(":scope > .active")) || []; activeList.forEach((e) => e.classList.remove("active")); tabElement && tabElement.classList.add("active"); }; const filterProducts = () => { const category = filterSection.querySelector(".category-tab-wrapper .active button")?.dataset.category || null; const minPrice = Number(filterSection.querySelector(".points-tab__inputs .points-from")?.value) || 0; const maxPrice = Number(filterSection.querySelector(".points-tab__inputs .points-to")?.value) || Infinity; filter.filter({ category, minPrice, maxPrice }); }; const getPointsHistory = async () => { const pointsHistory = await getResponseFromServer("/api/rewardsactivity?per_page=100"); const historyBody = document.querySelector(".history-wrapper .history-body"); historyBody.replaceChildren(); historyBody.classList.remove("loading"); const historyFooter = document.querySelector(".history-wrapper .history-footer"); historyFooter.replaceChildren(); let pointsData = null; if (pointsHistory?.[1]?.includes("application/json")) { try { pointsData = JSON.parse(pointsHistory[0]); } catch (error) { console.error("Failed to parse JSON:", error); } } if (!pointsData?.data?.length) { const historyLine = document.createElement("div"); historyLine.classList = "history-line history-line__placeholder"; historyLine.innerHTML = `

No rewards history found

You will see you history after your first order

`; historyBody.append(historyLine); historyFooter.classList.add("hide"); return; } pointsData.data.forEach((element, index) => { if (index > 20) return; const historyLine = document.createElement("div"); historyLine.classList = "history-line"; const points = Number.parseInt(element.points_balance.replaceAll(",", "")) - Number.parseInt(element.points_deducted.replaceAll(",", "")); historyLine.innerHTML = `
Order number: ${element.order_prefix_id}
${element.created_at}
${points > 0 ? "+" : ""}${new Intl.NumberFormat("en-US").format(points)} ${Math.abs(points) === 1 ? "pt" : "pts"}
${element.status}
`; historyBody.append(historyLine); }); historyFooter.innerHTML = `View All`; }; const animateCounter = (element, target, duration = 2000) => { const start = 0; let current = start; const startTime = Date.now(); function update() { const elapsed = Date.now() - startTime; const progress = Math.min(elapsed / duration, 1); const eased = 1 - Math.pow(1 - progress, 3); current = Math.floor(start + (target - start) * eased); element.textContent = current.toLocaleString(); if (progress < 1) requestAnimationFrame(update); else element.classList.remove("animating"); } element.classList.add("animating"); requestAnimationFrame(update); }; const createCircles = () => { const evoData = EvoXLayer(); const current = evoData.user.rewards.points; const pending = evoData.user.rewards.points_pending; const total = pending < 0 ? current : current + pending; const curCircle = document.getElementById("current-points-circle"); const pendCircle = document.getElementById("pending-points-circle"); const r = 565.48 * (pending / (current + pending)); curCircle.style.setProperty("--ring-offset", r); pendCircle.style.setProperty("--ring-offset", 0); animateCounter(document.querySelector(".points__current .points__text span"), current); animateCounter(document.querySelector(".points__pending .points__text span"), pending); animateCounter(document.querySelector(".text-current"), current); animateCounter(document.querySelector(".text-total"), total); }; const createRangeSlider = () => { const $range = document.querySelector(".points-tab-wrapper .points-tab__slider"); const $inputFrom = document.querySelectorAll(".points-tab-wrapper .points-from"); const $inputTo = document.querySelectorAll(".points-tab-wrapper .points-to"); let instance; const min = 0; const max = 10000; let from = 0; let to = 0; if ($range && typeof jQuery !== "undefined" && typeof jQuery.fn.ionRangeSlider !== "undefined") { jQuery($range).ionRangeSlider({ skin: "round", type: "double", min, max, from: min, to: max, step: 50, prettify_enabled: true, prettify_separator: ",", max_postfix: "+", onStart: updateInputs, onChange: updateInputs, onFinish: function (data) { updateInputs(data); updateFilter(data); }, onUpdate: updateFilter, }); instance = jQuery($range).data("ionRangeSlider"); } function updateInputs(data) { from = data.from; to = data.to; $inputFrom.forEach((input) => (input.value = from)); $inputTo.forEach((input) => (input.value = to)); } function updateFilter(data) { const category = filterSection.querySelector(".category-tab-wrapper .active button").dataset.category; const minPrice = data.from; const maxPrice = data.to === data.max ? 99999 : data.to; filter.filter({ category, minPrice, maxPrice }); } $inputFrom.forEach((inputFrom) => { inputFrom.addEventListener("change", function () { let val = this.value; if (val < min) val = min; else if (val > to) val = to; instance.update({ from: val }); this.value = val; }); }); $inputTo.forEach((inputTo) => { inputTo.addEventListener("change", function () { let val = this.value; if (val < from) val = from; else if (val > max) val = max; instance.update({ to: val }); this.value = val; }); }); }; // ************************* // // ===== END FUNCTIONS ===== // // ===== INIT ===== // createCircles(); getPointsHistory(); createRangeSlider(); createCategoryFilterTabs(); // Show loader while fetching pages const loader = document.createElement("div"); loader.className = "rewards-loader"; loader.textContent = "Loading rewards…"; newRewardItemsList.appendChild(loader); // Collect items already on page 1 allItems.push(...rewardItems); // Crawl all remaining pages await fetchAllRewardsPages(); // Remove loader loader.remove(); // Build filtering reference array allItems.forEach((el) => { allItemsFiltering.push({ el, title: el.querySelector(".product-title")?.textContent.trim() || "", sku: el.querySelector(".product-details-sku")?.textContent.replace("Product Code", "").trim(), category: el.querySelector(".product-details-category a")?.textContent.trim() || "", brand: el.querySelector(".product-details-brand img")?.alt || "", price: Number(el.querySelector(".product-price")?.textContent.trim() || 0), disabled: el.querySelector("button")?.hasAttribute("disabled") || false, }); }); // Reset container and init filter newRewardItemsList.replaceChildren(); const filter = new VirtualProductFilter(allItems, { title: rewardItemsTitle, container: newRewardItemsList, itemsPerPage: 8, }); filter.reset(); // Scroll to rewards request handler document.querySelectorAll('a[href="#rewards-request"]').forEach((link) => { link.addEventListener("click", function (e) { e.preventDefault(); if (filter.observer) filter.observer.disconnect(); const scrollToFooter = () => { const footer = document.getElementById("rewards-request"); if (!footer) return; window.scrollTo({ top: footer.getBoundingClientRect().top + window.pageYOffset - 120, behavior: "smooth", }); let scrollTimeout; const handleScroll = () => { clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { window.removeEventListener("scroll", handleScroll); if (filter.observer) { const sentinel = filter.container.querySelector('[data-sentinel="true"]'); if (sentinel) filter.observer.observe(sentinel); } }, 150); }; window.addEventListener("scroll", handleScroll); handleScroll(); }; scrollToFooter(); }); }); })();