/**
* Client-side buyability — mirrors PHP product_availability_utils (4-month feed staleness).
* Cards always link to the on-site product page; unavailable state is product-detail only.
*/
const STALE_FEED_MONTHS = 4;
const MS_PER_MONTH = 1000 * 60 * 60 * 24 * 30.44;
function parseFeedSeenDate(product) {
const raw = String(product?.last_feed_seen || '').trim();
if (!raw) return null;
const ts = Date.parse(raw);
return Number.isFinite(ts) ? ts : null;
}
function hasPurchasableOffers(product) {
const offers = product?.offers || [];
if (offers.some(o => Number(o?.price) > 0 && (o?.aff_url || o?.url))) {
return true;
}
return (product?.variants || []).some(v => (v?.offers || []).some(o => Number(o?.price) > 0 && (o?.aff_url || o?.url)));
}
function isStaleInFeed(product, months = STALE_FEED_MONTHS) {
const seenTs = parseFeedSeenDate(product);
if (seenTs === null) {
return !hasPurchasableOffers(product);
}
return (Date.now() - seenTs) / MS_PER_MONTH >= months;
}
function isProductBuyable(product) {
if (product?.unavailable) return false;
if (isStaleInFeed(product)) return false;
return hasPurchasableOffers(product);
}
function renderBuyBtnHtml(product, pageUrl) {
const url = pageUrl || (typeof getProductPageUrl === 'function' ? getProductPageUrl(product) : '#');
if (!url || url === '#') {
return 'Show Me';
}
return `Show Me`;
}
function applyBuyBtnToCard($card, product, pageUrl) {
const url = pageUrl || (typeof getProductPageUrl === 'function' ? getProductPageUrl(product) : '#');
const $btn = $card.find('.buy-btn, .buy-btn-unavailable');
if (!url || url === '#') {
if ($btn.length) {
$btn.replaceWith('Show Me');
}
return;
}
if ($btn.is('span')) {
$btn.replaceWith(`Show Me`);
} else if ($btn.length) {
$btn.attr('href', url).text('Show Me').removeClass('buy-btn-unavailable').removeAttr('aria-disabled target rel');
} else {
$card.find('.actions').after(`Show Me`);
}
}
'use strict';
const PRODUCTS_PER_PAGE = 20;
let allProducts = [];
let originalProducts = [];
let currentPage = 1;
let totalPages = 1;
let isLoading = false;
let filtersDirty = false;
let listingClientMode = false;
const PRODUCT_REVIEWS_LOOKUP = [];
// DOM Elements
const $filterBtnLabel = $('#floatingFilterBtn .filter-btn-label');
const $loading = $('#loadingState');
const $error = $('#errorState');
const $hero = $('#heroSection');
const $productsGrid = $('#productsGrid');
const $noResults = $('#noResults');
const $pagination = $('#pagination');
const $loadMoreBtn = $('#loadMoreBtn');
const $resultsCounter = $('#resultsCounter');
// LOCAL STORAGE — prefixed with "uklips_" to avoid conflict with old domain
let compareList = JSON.parse(localStorage.getItem('uklips_compareList')) || [];
let favorites = JSON.parse(localStorage.getItem('uklips_favorites')) || [];
let watchedVideos = JSON.parse(localStorage.getItem('uklips_watchedhair-dyeVideos')) || [];
// Brand helpers for filter dropdown
function escapeHtmlAttr(str) {
return String(str)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(//g, '>');
}
function productSafeBrand(product) {
const safe = typeof product?.safe_brand === 'string' ? product.safe_brand.trim() : '';
if (safe) return safe;
const display = typeof product?.brand === 'string' ? product.brand.trim() : '';
if (!display) return '';
return display.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
function productBrandDisplay(product) {
return typeof product?.brand === 'string' ? product.brand.trim() : '';
}
// Match PHP listing_filter_ranked_products — rank > 0, not unavailable, brand + image.
function isLiveProduct(p) {
if (!p || (p.rank ?? 0) <= 0) return false;
if (p.unavailable) return false;
const brand = typeof p.brand === 'string' ? p.brand.trim() : '';
if (!brand) return false;
const filename = typeof p.filename === 'string' ? p.filename.trim() : '';
const imageUrl = p.image_url != null ? String(p.image_url).trim() : '';
return filename !== '' || imageUrl !== '';
}
// ===============================================
// 1. UTILITY: SHORT NAME
// ===============================================
function getShortName(fullName) {
return fullName.split(/[,-\.]/)[0].trim();
}
function getProductThumb(p) {
if (p.image_thumb_local) return p.image_thumb_local;
if (p.images_success && p.filename && p.safe_brand) {
return `/hair-dye/${p.safe_brand}/i/${p.filename}-card.webp`;
}
if (p.image_thumb_jpg_local) return p.image_thumb_jpg_local;
if (p.image_url_local) {
const local = String(p.image_url_local);
if (local.includes('/i/') && local.endsWith('-1.jpg')) {
return local.replace(/-1\.jpg$/, '-card.webp');
}
return local;
}
if (p.image_url && String(p.image_url).trim()) return p.image_url.trim();
if (p.filename && p.safe_brand) {
return `/hair-dye/${p.safe_brand}/i/${p.filename}-card.webp`;
}
return '/images/lip-stain.jpg';
}
function getProductDetailImage(p, seq = 1) {
if (p.image_url_webp && seq === 1) return p.image_url_webp;
if (p.images_success && p.filename && p.safe_brand) {
return `/hair-dye/${p.safe_brand}/i/${p.filename}-${seq}.webp`;
}
if (p.image_url_local && seq === 1) return p.image_url_local;
if (p.filename && p.safe_brand) {
return `/hair-dye/${p.safe_brand}/i/${p.filename}-${seq}.jpg`;
}
return getProductThumb(p);
}
function getProductSwatchUrl(p, shade) {
if (shade?.image_url && String(shade.image_url).startsWith('/')) return shade.image_url;
if (p.images_success && p.filename && p.safe_brand) {
return `/hair-dye/${p.safe_brand}/i/${p.filename}-swatch.jpg`;
}
if (shade?.image_url) return shade.image_url;
return getProductThumb(p);
}
function getProductImageUrls(p) {
if (p.images_success && p.filename && p.safe_brand) {
const count = Number(p.imagecnt) || 1;
const urls = [];
for (let i = 1; i <= count; i++) {
urls.push(`/hair-dye/${p.safe_brand}/i/${p.filename}-${i}.webp`);
}
return urls;
}
if (Array.isArray(p.image_urls) && p.image_urls.length) return p.image_urls;
if (p.image_url_webp) return [p.image_url_webp];
if (p.image_url_local) return [p.image_url_local];
if (p.image_url && String(p.image_url).trim()) return [p.image_url.trim()];
if (!p.filename) return ['/images/lip-stain.jpg'];
const base = p.safe_brand
? `/hair-dye/${p.safe_brand}/i/${p.filename}`
: `/hair-dye/_img/${p.filename}`;
const count = Number(p.imagecnt) || 1;
const urls = [];
for (let i = 1; i <= count; i++) urls.push(`${base}-${i}.jpg`);
return urls;
}
function getActiveVariant(product, index = 0) {
if (product?.is_group && Array.isArray(product.variants) && product.variants.length) {
return product.variants[index] || product.variants[0];
}
if (Array.isArray(product?.variants) && product.variants[index]) {
return product.variants[index];
}
return product;
}
function getColourCarouselSlides(product) {
if (Array.isArray(product?.shades) && product.shades.length > 1) {
return product.shades.map((shade, i) => {
const variant = getActiveVariant(product, shade.variant_index ?? i);
return {
image_url: (shade.image_url_local || getProductThumb(variant)),
swatch_url: getProductSwatchUrl(variant, shade),
name: shade.name || '',
variant_index: shade.variant_index ?? i
};
});
}
return getProductImageUrls(product).map((url) => ({
image_url: url,
swatch_url: url,
name: product.shade || '',
variant_index: 0
}));
}
function getDisplayName(product) {
return product.display_name || product.name || '';
}
function getProductImageAlt(product, variant, extra = '') {
const brand = (variant?.brand || product.brand || '').trim();
const name = getDisplayName(variant || product);
const base = brand ? `${brand} ${name}` : name;
const suffix = (extra || '').trim();
if (suffix && !base.toLowerCase().includes(suffix.toLowerCase())) {
return `${base} – ${suffix}`;
}
return base;
}
function getProductPageSlug(product) {
if (product.page_slug) return product.page_slug;
if (product.filename) return String(product.filename).toLowerCase();
return '';
}
function getAffiliateGoUrl(product, variantIndex = 0, source = 'card') {
const slug = getProductPageSlug(product);
if (!slug) return '#';
const params = new URLSearchParams({
c: 'hair-dye',
p: slug,
src: source
});
if (variantIndex > 0) params.set('s', String(variantIndex));
return `/go/?${params.toString()}`;
}
function getProductPageUrl(product) {
const slug = getProductPageSlug(product);
if (!slug) return '#';
return `/hair-dye/p/${slug}`;
}
function renderAuthorBadgeMedia(rev, size) {
const photo = String(rev.authorPhoto || '').replace(/"/g, '"');
const video = String(rev.authorVideo || '').replace(/"/g, '"');
if (!video) {
return `
`;
}
return ``
+ ``
+ ``
+ ``;
}
function renderProductReviewBadge(product) {
const slug = getProductPageSlug(product);
if (!slug) return '';
const rev = PRODUCT_REVIEWS_LOOKUP[slug];
if (!rev || !rev.reviewUrl) return '';
const name = String(rev.authorName || 'UK Lips').replace(/"/g, '"');
const url = String(rev.reviewUrl).replace(/"/g, '"');
return ``
+ renderAuthorBadgeMedia(rev, 36)
+ `Review`
+ ``;
}
function getCategoryPageUrl() {
return `/hair-dye/`;
}
function getBrandPageUrl(safeBrand) {
if (!safeBrand) return '';
return `/hair-dye/b/${safeBrand}/`;
}
function renderCardMetaHtml(lip) {
const catLink = `Hair Dye`;
const brand = (lip.brand || '').trim();
const safeBrand = (lip.safe_brand || '').trim();
if (!brand) {
return `
${catLink}
`;
}
const brandEsc = brand.replace(/${brandEsc}`
: brandEsc;
return `${catLink} · ${brandLink}
`;
}
function renderShadesHtml(lip) {
const shades = lip.shades || [];
if (!shades.length) {
const legacy = (lip.shades || []).filter(s => s.color);
if (!legacy.length) {
return '—
';
}
}
const maxDisplay = 8;
const visible = shades.slice(0, maxDisplay);
const hasMore = shades.length > maxDisplay;
const swatches = visible.map((s, i) => {
const idx = s.variant_index ?? i;
const variant = getActiveVariant(lip, idx);
const label = (s.name || `Shade ${i + 1}`).replace(/"/g, '"');
const swatchUrl = getProductSwatchUrl(variant, s);
const style = s.color
? `background:${s.color}`
: `background-image:url("${swatchUrl}")`;
return ``;
}).join('');
const selectedName = shades[0]?.name || lip.shade || '';
return `
${swatches}${hasMore ? `+${shades.length - maxDisplay}` : ''}
${selectedName}
`;
}
function parseCompareDataFromCard($card) {
const id = +$card.data('id');
const name = ($card.find('.product-name-link').text() || $card.find('.product-name').text() || '').trim();
const priceText = ($card.find('.price span').first().text() || '').replace(/[£,\s]/g, '');
const price = parseFloat(priceText) || 0;
const thumb = $card.find('.product-thumb, .listing-pick-img img').first().attr('src') || '/images/lip-stain.jpg';
const pageUrl = $card.find('.product-name-link, .product-img-link, .listing-pick-img-link').first().attr('href') || '#';
const ratingText = ($card.find('.rating-value, .listing-pick-rating').first().text() || '').replace(/[^\d.]/g, '');
const rating = parseFloat(ratingText) || 0;
const reviewsText = ($card.find('.review-count').first().text() || '').replace(/[^\d]/g, '');
const reviews = parseInt(reviewsText, 10) || 0;
const brand = ($card.find('.product-card-brand').text() || '').trim();
return { id, name, brand, price, rating, reviews, shades: $card.find('.shade-swatch').length, rank: null, thumb, pageUrl };
}
function findCompareProduct(id) {
const fromList = allProducts.find(x => x.id === id);
if (fromList) return getProductCompareData(fromList);
const $card = $(`.product-card[data-id="${id}"], .listing-pick-card[data-id="${id}"]`).first();
return $card.length ? parseCompareDataFromCard($card) : null;
}
function getProductCompareData(product) {
const variant = getActiveVariant(product, 0);
return {
id: product.id,
name: getDisplayName(product),
brand: (product.brand || variant.brand || '').trim(),
price: Number(variant.price ?? variant.min_total_price ?? product.price ?? 0),
rating: Number(product.rating ?? variant.rating ?? 0),
reviews: Number(product.reviews ?? variant.reviews ?? 0),
shades: (product.shades || []).length,
rank: product.rank ?? null,
thumb: getProductThumb(variant),
pageUrl: getProductPageUrl(product)
};
}
function pruneCompareList() {
const validIds = new Set();
$('.product-card[data-id], .listing-pick-card[data-id]').each(function() {
validIds.add(+$(this).data('id'));
});
allProducts.forEach(p => validIds.add(p.id));
const before = compareList.length;
compareList = compareList.filter(id => validIds.has(id));
if (compareList.length !== before) saveLists();
}
function syncCompareButtons() {
$('.compare-btn').each(function() {
const id = +$(this).data('id');
const isComp = compareList.includes(id);
$(this)
.toggleClass('added', isComp)
.attr('aria-pressed', isComp ? 'true' : 'false')
.attr('aria-label', isComp ? 'Remove from comparison' : 'Add to comparison')
.attr('title', isComp ? 'Remove from comparison' : 'Compare');
});
}
function showCompareToast(message) {
let $toast = $('#compareToast');
if (!$toast.length) {
$toast = $('');
$('body').append($toast);
}
$toast.text(message).addClass('show');
clearTimeout(showCompareToast._timer);
showCompareToast._timer = setTimeout(() => $toast.removeClass('show'), 2800);
}
function toggleCompare(id) {
const idx = compareList.indexOf(id);
if (idx > -1) {
compareList.splice(idx, 1);
} else {
if (compareList.length >= 4) {
showCompareToast('You can compare up to 4 products');
return false;
}
compareList.push(id);
showCompareToast('Added to comparison');
}
saveLists();
syncCompareButtons();
updateComparisonBar();
return true;
}
function applyVariantToCard($card, product, variantIndex) {
if (window.CardCarousel) {
CardCarousel.applyVariantToCard($card[0], product, variantIndex);
return;
}
const variant = getActiveVariant(product, variantIndex);
const shade = product.shades?.find(s => (s.variant_index ?? 0) === variantIndex) || product.shades?.[variantIndex];
$card.data('active-variant', variantIndex);
$card.find('.product-thumb').attr('src', getProductThumb(variant));
$card.find('.product-thumb').attr('alt', getProductImageAlt(product, variant, shade?.name || variant.shade || ''));
$card.find('.price').text(`£${Number(variant.price || 0).toFixed(2)}`);
applyBuyBtnToCard($card, product, getProductPageUrl(product));
$card.find('.selected-shade-name').text(shade?.name || variant.shade || product.shade || '');
$card.find('.shade-swatch').removeClass('active');
$card.find(`.shade-swatch[data-variant-index="${variantIndex}"]`).addClass('active');
}
function isSSRListing() {
return document.querySelector('#mainContent[data-ssr="1"]') !== null;
}
function hasSSRGrid() {
return $('#productsGrid .product-card').length > 0;
}
function attachProductCardHandlers() {
$('.product-card .shade-swatch').off('click').on('click', function(e) {
e.stopPropagation();
e.preventDefault();
const $swatch = $(this);
const $card = $swatch.closest('.product-card');
const productId = $card.data('id');
let product = allProducts.find(p => p.id == productId);
const variantIndex = Number($swatch.data('variant-index')) || 0;
if (product) {
applyVariantToCard($card, product, variantIndex);
return;
}
const swatchStyle = $swatch.attr('style') || '';
const bgMatch = swatchStyle.match(/url\((['"]?)(.*?)\1\)/);
if (bgMatch && bgMatch[2]) {
$card.find('.product-thumb').attr('src', bgMatch[2]);
}
$card.data('active-variant', variantIndex);
$card.find('.shade-swatch').removeClass('active');
$swatch.addClass('active');
});
$('.compare-btn').off('click').on('click', function(e) {
e.preventDefault();
e.stopPropagation();
toggleCompare(+$(this).data('id'));
});
$('.fav-btn').off('click').on('click', function() {
const id = +$(this).data('id');
const idx = favorites.indexOf(id);
if (idx > -1) favorites.splice(idx, 1);
else favorites.push(id);
$(this).toggleClass('active');
saveLists();
});
if (window.UK_LIPS_initAuthorBadgeVideos) {
window.UK_LIPS_initAuthorBadgeVideos(document);
}
}
// ===============================================
// 2. INITIAL LOAD
// ===============================================
async function load_products() {
if (!isSSRListing()) {
$loading.hide();
$error.show().text('Failed to load products. Please try again.');
return;
}
const urlParams = new URLSearchParams(window.location.search);
const page = Math.max(1, parseInt(urlParams.get('page')) || 1);
initApp(page);
}
// ===============================================
// 3. INIT APP
// ===============================================
function initApp(startPage = 1) {
currentPage = startPage;
pruneCompareList();
if (window.CardCarousel) {
CardCarousel.configure({ products: allProducts, categorySlug: 'hair-dye' });
CardCarousel.init();
}
const $sortSelect = $('#sortSelect');
const urlParams = new URLSearchParams(window.location.search);
const urlSort = urlParams.get('sort');
const validSorts = ['rank','price-low','price-high','name','rating-high','reviews-high'];
const defaultSort = validSorts.includes(urlSort) ? urlSort : 'rank';
if ($sortSelect.length) $sortSelect.val(defaultSort);
if (!isSSRListing()) {
populateBrandFilter();
renderBrandButtons();
}
if (filter_by_brand && filter_by_brand.trim() !== '') {
$('#brandFilter').val(filter_by_brand.trim());
}
if (isSSRListing() && !listingClientMode) {
const $main = $('#mainContent');
totalPages = parseInt($main.data('listing-total-pages') || String(totalPages), 10);
currentPage = parseInt($main.data('listing-page') || String(startPage), 10);
attachProductCardHandlers();
syncCompareButtons();
observeCards();
$loading.hide();
$hero.show();
renderVideoButtons();
updateComparisonBar();
if (!$('.listing-top-picks .listing-pick-card').length) renderTopProducts();
$('#pagination').hide();
$('#filterPanel').removeClass('active');
$('#filterBackdrop').removeClass('active').attr('aria-hidden', 'true');
$('#floatingFilterBtn').removeClass('active').attr('aria-expanded', 'false');
resetFilterButtonLabel();
updateFilterButtonLabel();
return;
}
applyFilters();
$loading.hide();
$hero.show();
displayPage(currentPage);
renderTopProducts();
renderVideoButtons();
updateComparisonBar();
if (!listingClientMode) updateURLAndSEO(currentPage);
if (!listingClientMode && !hasSSRGrid()) populateTop3InSchema();
$('#filterPanel').removeClass('active');
$('#filterBackdrop').removeClass('active').attr('aria-hidden', 'true');
$('#floatingFilterBtn').removeClass('active').attr('aria-expanded', 'false');
resetFilterButtonLabel();
updateFilterButtonLabel();
}
// ===============================================
// 3.5 BRAND FILTER + BUTTONS
// ===============================================
function populateBrandFilter() {
const brandMap = new Map();
allProducts.forEach(product => {
if (!isLiveProduct(product)) return;
const safeBrand = productSafeBrand(product);
if (!safeBrand) return;
const displayName = productBrandDisplay(product) || safeBrand;
if (!brandMap.has(safeBrand)) {
brandMap.set(safeBrand, displayName);
return;
}
const existing = brandMap.get(safeBrand);
if (displayName.length > existing.length) brandMap.set(safeBrand, displayName);
});
const $brandSelect = $('#brandFilter').empty();
$brandSelect.append('');
Array.from(brandMap.entries())
.sort((a, b) => a[1].localeCompare(b[1]))
.forEach(([safeBrand, displayName]) => {
$brandSelect.append(
``
);
});
}
function renderBrandButtons() {
const $grid = $('#brandGrid').empty();
// Use a Set to keep unique safe_brand values
const safeBrands = new Set();
allProducts.forEach(product => {
if (product.safe_brand && typeof product.safe_brand === 'string' && product.safe_brand.trim() !== '') {
safeBrands.add(product.safe_brand.trim());
}
});
const sortedBrands = Array.from(safeBrands).sort((a, b) => a.localeCompare(b));
if (sortedBrands.length === 0) {
$grid.append('No brands found.
');
return;
}
sortedBrands.forEach(safeBrandSlug => {
const href = `/hair-dye/b/` + safeBrandSlug;
// Optional: still show the nice brand name (not the slug) on the button
// Find a product with this safe_brand to get the display name
const sampleProduct = allProducts.find(p =>
p.safe_brand && p.safe_brand.trim() === safeBrandSlug
);
const displayName = sampleProduct ? (sampleProduct.brand || safeBrandSlug) : safeBrandSlug;
const $btn = $(`${displayName}`);
$grid.append($btn);
});
}
// ===============================================
// 4. DISPLAY PAGE
// ===============================================
function displayPage(page = 1, append = false) {
const start = (page - 1) * PRODUCTS_PER_PAGE;
const end = Math.min(start + PRODUCTS_PER_PAGE, allProducts.length);
const pageProducts = allProducts.slice(start, end);
if (!append) {
$productsGrid.empty();
currentPage = page;
}
renderLipsticks(pageProducts, append);
const shown = Math.min(end, allProducts.length);
$resultsCounter.text(`Showing ${start + 1}–${shown} of ${allProducts.length} hair dye`);
if (end < allProducts.length) {
$pagination.show();
$loadMoreBtn.prop('disabled', false).html('Load More ');
} else {
$pagination.hide();
}
if (!append) updateURLAndSEO(page);
isLoading = false;
}
// ===============================================
// 5. UPDATE URL + SEO
// ===============================================
function updateURLAndSEO(page) {
if (isSSRListing()) return;
const sort = $('#sortSelect').val();
const currentPath = window.location.pathname;
const basePath = currentPath.replace(/\/$/, '');
const params = new URLSearchParams();
if (page > 1) params.set('page', page);
if (sort && sort !== 'rank') params.set('sort', sort);
const queryString = params.toString();
const newUrl = queryString ? `${basePath}?${queryString}` : basePath || '/';
history.replaceState({ page, sort }, '', newUrl);
$('link[rel="next"], link[rel="prev"]').remove();
if (page > 1) {
const prevParams = new URLSearchParams();
const prevPage = page - 1;
if (prevPage > 1) prevParams.set('page', prevPage);
if (sort && sort !== 'rank') prevParams.set('sort', sort);
const prevQuery = prevParams.toString();
const prevUrl = prevQuery ? `${basePath}?${prevQuery}` : basePath || '/';
$('head').append(``);
}
if (page < totalPages) {
const nextParams = new URLSearchParams();
nextParams.set('page', page + 1);
if (sort && sort !== 'rank') nextParams.set('sort', sort);
const nextUrl = `${basePath}?${nextParams.toString()}`;
$('head').append(``);
}
}
// ===============================================
// 6. LOAD MORE
// ===============================================
$(document).on('click', '#loadMoreBtn', function() {
if (isLoading) return;
isLoading = true;
$loadMoreBtn.prop('disabled', true).html('Loading...');
const nextPage = currentPage + 1;
setTimeout(() => {
displayPage(nextPage, true);
currentPage = nextPage;
updateURLAndSEO(currentPage);
$loadMoreBtn.prop('disabled', false).html('Load More ');
isLoading = false;
}, 300);
});
// ===============================================
// 7. RATING FUNCTION
// ===============================================
function renderRating(lip) {
const rating = Number(lip.rating) || 0;
const reviews = Number(lip.reviews) || 0;
if (rating <= 0 && reviews <= 0) return '';
const title = lip.rating_source === 'amazon'
? 'Amazon customer rating'
: lip.rating_source === 'feed'
? 'Merchant feed rating'
: lip.rating_source === 'brand'
? 'Based on brand reputation score'
: '';
const full = Math.floor(rating);
const half = rating % 1 >= 0.5 ? 1 : 0;
const empty = 5 - full - half;
const ratingLabel = rating > 0 ? `${rating.toFixed(1)}` : '';
const reviewLabel = reviews > 0
? `(${reviews.toLocaleString()})`
: '';
return `
${''.repeat(full)}
${half ? '' : ''}
${''.repeat(empty)}
${ratingLabel}
${reviewLabel}
`;
}
function formatReviewCount(reviews) {
const count = Number(reviews) || 0;
if (count <= 0) return '';
return count >= 1000 ? `${(count / 1000).toFixed(1)}k reviews` : `${count.toLocaleString()} reviews`;
}
function renderTopPickCard(lip, rank) {
const active = getActiveVariant(lip, 0);
const displayName = getDisplayName(lip);
const thumb = getProductThumb(active);
const pageUrl = getProductPageUrl(lip);
const price = Number(active.price || lip.price || 0).toFixed(2);
const rating = Number(lip.rating) || 0;
const brand = (lip.brand || '').trim();
const safeBrand = (lip.safe_brand || '').trim();
const brandEsc = brand.replace(/${brandEsc}`
: brandEsc;
const ratingHtml = rating > 0
? ` ${rating.toFixed(1)}`
: '';
const titleHtml = pageUrl !== '#'
? `${displayName.replace(/`
: displayName.replace(/ 1 ? colourSlides.map((slide, i) => `
`).join('') : '';
const carouselHtml = carouselSlides
? ``
: '';
return `
#${rank}
Hair Dye
${titleHtml}
${brand ? `${brandHtml}
` : ''}
£${price}
${ratingHtml}
`;
}
// ===============================================
// 8. TOP 3 PICKS
// ===============================================
function renderTopProducts() {
const $section = $('.listing-top-picks');
const $container = $('#topProductsContainer');
const $count = $('#totalProductsCount');
if (allProducts.length < 4) {
$section.hide();
return;
}
$section.show();
if (!$container.length) return;
const top3 = allProducts.slice(0, 3);
$container.empty();
if (top3.length === 0) {
$container.append('No products found.
');
if ($count.length) $count.text('0');
return;
}
top3.forEach((lip, index) => {
$container.append(renderTopPickCard(lip, index + 1));
});
if ($count.length) $count.text(allProducts.length);
if (window.CardCarousel) CardCarousel.observeCards();
}
// ===============================================
// 9. RENDER MAIN GRID
// ===============================================
function renderLipsticks(data, append = false) {
if (!append && (!data || data.length === 0)) {
$productsGrid.empty();
$noResults.show();
return;
}
if (!append) $noResults.hide();
data.forEach((lip, index) => {
const isComp = compareList.includes(lip.id);
const isFav = favorites.includes(lip.id);
const activeVariant = getActiveVariant(lip, 0);
const thumb = getProductThumb(activeVariant);
const pageUrl = getProductPageUrl(lip);
const colourSlides = getColourCarouselSlides(lip);
const imgAlt = getProductImageAlt(lip, activeVariant).replace(/"/g, '"');
const imgLinkOpen = pageUrl !== '#'
? ``
: '';
const imgLinkClose = pageUrl !== '#' ? '' : '
';
const carouselSlides = colourSlides.map((slide, i) => `
${slide.name ? `
${slide.name}
` : ''}
Loading...
`).join('');
const card = `
${(currentPage - 1) * PRODUCTS_PER_PAGE + index + 1}
${imgLinkOpen}
${imgLinkClose}
${renderProductReviewBadge(lip)}
${renderCardMetaHtml(lip)}
£${Number(activeVariant.price || lip.price || 0).toFixed(2)}
${renderRating(lip)}
${renderShadesHtml(lip)}
${renderBuyBtnHtml(lip, pageUrl)}
`;
$productsGrid.append(card);
});
attachProductCardHandlers();
syncCompareButtons();
}
// ===============================================
// 10. VIDEO BUTTONS
// ===============================================
function renderVideoButtons() {
const $container = $('#videoScrollContainer').empty();
const videos = allProducts.filter(p => p.videoURL);
$('#videoPreviewSection').toggle(videos.length > 0);
videos.forEach(p => {
const isWatched = watchedVideos.includes(p.id);
const thumb = getProductThumb(p);
$container.append(`
`);
});
$('.video-btn').off('click keypress').on('click keypress', function(e) {
if (e.type === 'keypress' && e.key !== 'Enter') return;
const id = +$(this).data('id');
const p = allProducts.find(x => x.id === id);
if (p) openVideoModal(p);
});
}
// ===============================================
// 11. COMPARISON TABLE
// ===============================================
function renderComparisonTable() {
const $container = $('#compareTableContainer').empty();
if (compareList.length === 0) {
$container.html('No products selected. Add items using the compare button on product cards.
');
return;
}
const products = compareList
.map(id => allProducts.find(x => x.id === id))
.filter(Boolean)
.map(getProductCompareData);
if (products.length === 0) {
$container.html('Selected products are no longer available on this page.
');
return;
}
const lowestPrice = Math.min(...products.map(p => p.price).filter(n => n > 0));
const specRows = [
{ key: 'price', label: 'Price', render: p => `£${p.price.toFixed(2)}` },
{ key: 'brand', label: 'Brand', render: p => p.brand || '—' },
{ key: 'rating', label: 'Rating', render: p => p.rating > 0 ? ` ${p.rating.toFixed(1)}` : '—' },
{ key: 'reviews', label: 'Reviews', render: p => p.reviews > 0 ? p.reviews.toLocaleString() : '—' },
{ key: 'shades', label: 'Shades', render: p => p.shades > 0 ? p.shades : '—' },
{ key: 'rank', label: 'Rank', render: p => p.rank ? `#${p.rank}` : '—' }
];
let html = '';
$container.html(html);
$container.find('.compare-remove-col').on('click', function() {
toggleCompare(+$(this).data('id'));
renderComparisonTable();
if (compareList.length === 0) {
$('#comparisonModal').removeClass('active');
$('body').css('overflow', '');
}
});
}
function applyFilters() {
if (isSSRListing()) {
listingClientMode = true;
if (window.ListingAjax) {
ListingAjax.fetchAndRender(1).then(function() {
filtersDirty = false;
updateFilterButtonLabel();
});
}
return;
}
allProducts = [...originalProducts];
let filtered = allProducts.filter(isLiveProduct);
const search = $('#searchInput').val().toLowerCase();
if (search) {
filtered = filtered.filter(l =>
l.name.toLowerCase().includes(search) ||
(l.brand && l.brand.toLowerCase().includes(search))
);
}
const popular = $('#popularFilter').val();
if (popular) filtered = filtered.filter(l => l.reviews > 10000);
const selectedBrand = $('#brandFilter').val();
if (selectedBrand) {
filtered = filtered.filter(p => productSafeBrand(p) === selectedBrand);
}
const rating = $('#ratingFilter').val();
if (rating) filtered = filtered.filter(l => l.rating >= +rating);
const min = parseFloat($('#minPrice').val()) || 0;
const max = parseFloat($('#maxPrice').val()) || Infinity;
filtered = filtered.filter(l => l.price >= min && l.price <= max);
const sort = $('#sortSelect').val();
if (sort === 'price-low') filtered.sort((a,b) => a.price - b.price);
else if (sort === 'price-high') filtered.sort((a,b) => b.price - a.price);
else if (sort === 'name') filtered.sort((a,b) => a.name.localeCompare(b.name));
else if (sort === 'rating-high') filtered.sort((a,b) => b.rating - a.rating);
else if (sort === 'reviews-high') filtered.sort((a,b) => b.reviews - a.reviews);
else if (sort === 'rank') filtered.sort((a,b) => a.rank - b.rank);
allProducts = filtered;
totalPages = Math.ceil(allProducts.length / PRODUCTS_PER_PAGE);
listingClientMode = true;
$('.ssr-pagination').hide();
displayPage(1);
renderTopProducts();
updateFilterButtonLabel();
populateTop3InSchema();
}
function saveLists() {
localStorage.setItem('uklips_compareList', JSON.stringify(compareList));
localStorage.setItem('uklips_favorites', JSON.stringify(favorites));
}
function saveWatchedVideos() {
localStorage.setItem('uklips_watchedhair-dyeVideos', JSON.stringify(watchedVideos));
}
function updateComparisonBar() {
const $items = $('#comparisonItems').empty();
const count = compareList.length;
$('#compareCount').text(count);
const $bar = $('#comparisonBar');
if (count > 0) {
$bar.addClass('show');
compareList.forEach(id => {
const data = findCompareProduct(id);
if (!data) return;
const shortName = data.name.length > 28 ? `${data.name.slice(0, 28)}…` : data.name;
$items.append(`
${shortName.replace(/
`);
});
} else {
$bar.removeClass('show');
}
$('.remove-item').off('click').on('click', function(e) {
e.stopPropagation();
toggleCompare(+$(this).data('id'));
});
}
function openVideoModal(p) {
$('#modalVideo source').attr('src', p.videoURL);
$('#modalVideo')[0].load();
$('#videoModal').addClass('active');
$('body').css('overflow', 'hidden');
if (!watchedVideos.includes(p.id)) {
watchedVideos.push(p.id);
saveWatchedVideos();
$(`.video-btn[data-id="${p.id}"]`).addClass('watched');
}
$('#modalVideo')[0].play();
}
function updateFilterButtonLabel() {
const isMobile = window.innerWidth <= 767;
const labelText = filtersDirty && isMobile ? 'Apply' : 'Filters';
$filterBtnLabel.text(labelText);
}
function openFilterPanel() {
$('#filterPanel').addClass('active');
$('#filterBackdrop').addClass('active').attr('aria-hidden', 'false');
$('#floatingFilterBtn').addClass('active').attr('aria-expanded', 'true');
if (window.innerWidth <= 767) $('body').css('overflow', 'hidden');
}
function closeFilterPanel(apply = false) {
$('#filterPanel').removeClass('active');
$('#filterBackdrop').removeClass('active').attr('aria-hidden', 'true');
$('#floatingFilterBtn').removeClass('active').attr('aria-expanded', 'false');
$('body').css('overflow', '');
if (apply) {
applyFilters();
resetFilterButtonLabel();
scrollToProductsGrid();
}
}
function scrollToProductsGrid() {
const top = $productsGrid.offset().top - 80;
$('html, body').animate({ scrollTop: top }, 600);
}
function applyFiltersAndClose() {
closeFilterPanel(true);
}
function markFiltersDirty() {
if (!filtersDirty) {
filtersDirty = true;
updateFilterButtonLabel();
}
}
function resetFilterButtonLabel() {
filtersDirty = false;
updateFilterButtonLabel();
}
$(window).on('load resize', updateFilterButtonLabel);
function observeCards() {
if (window.CardCarousel) CardCarousel.observeCards();
}
let originalDisplayPage = displayPage;
displayPage = function (...args) {
originalDisplayPage.apply(this, args);
setTimeout(observeCards, 100);
};
let currentModalProduct = null;
let currentModalIndex = 0;
function openImageModal(product) {
currentModalProduct = product;
currentModalIndex = 0;
const variantIndex = Number(product._modalVariantIndex) || 0;
const activeProduct = getActiveVariant(product, variantIndex);
let imgUrls = [];
let shadeNames = [];
if (Array.isArray(product.shades) && product.shades.length > 1) {
product.shades.forEach((shade, i) => {
const v = getActiveVariant(product, shade.variant_index ?? i);
imgUrls.push(shade.image_url_local || getProductThumb(v));
shadeNames.push(shade.name || '');
});
} else {
imgUrls = getProductImageUrls(activeProduct);
shadeNames = imgUrls.map(() => activeProduct.shade || product.shade || '');
}
const totalImages = imgUrls.length;
const $img = $('#modalImage');
const $prevBtn = $('.modal-prev');
const $nextBtn = $('.modal-next');
function loadIndex(idx) {
currentModalIndex = idx;
const url = imgUrls[idx];
$img.attr({ src: url, alt: `${getDisplayName(product)} – ${shadeNames[idx] || `image ${idx + 1}`}` });
$prevBtn.toggleClass('hidden', idx === 0);
$nextBtn.toggleClass('hidden', idx === totalImages - 1);
if (product.shades?.[idx]) {
$('#modalTitle').text(getDisplayName(product));
if ($('#modalShadeName').length) {
$('#modalShadeName').text(shadeNames[idx] || '');
}
const v = getActiveVariant(product, product.shades[idx].variant_index ?? idx);
$('.modal-price').text(Number(v.price || 0).toFixed(2));
$('.modal-buy-btn').attr('href', getAffiliateGoUrl(product, product.shades[idx].variant_index ?? idx, 'modal'));
} else {
$('#modalTitle').text(getDisplayName(product));
if ($('#modalShadeName').length) $('#modalShadeName').text('');
$('.modal-price').text(Number(activeProduct.price || 0).toFixed(2));
$('.modal-buy-btn').attr('href', getAffiliateGoUrl(activeProduct, 0, 'modal'));
}
$('#modalShades .modal-shade-swatch').removeClass('active').eq(idx).addClass('active');
}
loadIndex(variantIndex < totalImages ? variantIndex : 0);
const $shades = $('#modalShades').empty();
if (product.shades?.length) {
product.shades.forEach((s, i) => {
const variant = getActiveVariant(product, s.variant_index ?? i);
const swatchUrl = getProductSwatchUrl(variant, s);
const style = s.color
? `background:${s.color}`
: `background-image:url("${swatchUrl}")`;
const activeClass = i === (variantIndex < totalImages ? variantIndex : 0) ? 'active' : '';
$shades.append(
``
);
});
}
$('#modalShades .modal-shade-swatch').off('click').on('click', function(e) {
e.stopPropagation();
const idx = Number($(this).data('index'));
loadIndex(idx);
$('#modalShades .modal-shade-swatch').removeClass('active');
$(this).addClass('active');
});
$prevBtn.off('click').on('click', e => {
e.stopPropagation();
if (currentModalIndex > 0) loadIndex(currentModalIndex - 1);
});
$nextBtn.off('click').on('click', e => {
e.stopPropagation();
if (currentModalIndex < totalImages - 1) loadIndex(currentModalIndex + 1);
});
$(document).off('keydown.modalNav').on('keydown.modalNav', e => {
if (!$('#imageModal').hasClass('active')) return;
if (e.key === 'ArrowLeft') $prevBtn.click();
if (e.key === 'ArrowRight') $nextBtn.click();
});
let touchStartX = 0;
let touchEndX = 0;
const minSwipe = 50;
$('.modal-image-wrapper')
.off('touchstart touchmove touchend')
.on('touchstart', e => { touchStartX = e.originalEvent.touches[0].clientX; })
.on('touchmove', e => {
const deltaX = Math.abs(e.originalEvent.touches[0].clientX - touchStartX);
const deltaY = Math.abs(e.originalEvent.touches[0].clientY - e.originalEvent.touches[0].clientY);
if (deltaX > 10 && deltaY < 30) e.preventDefault();
})
.on('touchend', e => {
touchEndX = e.originalEvent.changedTouches[0].clientX;
const diff = touchStartX - touchEndX;
if (Math.abs(diff) >= minSwipe) {
if (diff > 0 && currentModalIndex < totalImages - 1) $nextBtn.click();
else if (diff < 0 && currentModalIndex > 0) $prevBtn.click();
}
});
$('#modalShades .modal-shade-swatch.clickable').off('click').on('click', function (e) {
e.stopPropagation();
const url = $(this).data('url');
if (url && url !== '#') window.open(url, '_blank', 'noopener');
});
$('#imageModal').addClass('active');
$('body').css('overflow', 'hidden');
}
function schemaImageUrl(p) {
const cat = 'hair-dye';
if (p.images_success && p.filename && p.safe_brand) {
return `https://uk-lips.co.uk/${cat}/${p.safe_brand}/i/${p.filename}-1.jpg`;
}
return 'https://uk-lips.co.uk/images/lip-stain.jpg';
}
// ===============================================
// POPULATE SCHEMA — first page of visible products
// ===============================================
function schemaOfferPrice(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return null;
return n.toFixed(2);
}
function populateTop3InSchema() {
if (document.querySelector('[data-ssr="1"]')) return;
const pageSize = Math.min(PRODUCTS_PER_PAGE, allProducts.length);
const schemaProducts = allProducts.filter(p => p.rank > 0).slice(0, pageSize);
if (schemaProducts.length === 0) return;
const itemListElement = schemaProducts.map((p, index) => {
const shortName = getShortName(p.name);
const cleanName = p.name.trim();
const imageUrl = schemaImageUrl(p);
const affUrl = p.aff_url && p.aff_url.trim() !== '' ? p.aff_url.trim() : p.url || '#';
const description = p.description || `Long-lasting ${shortName.toLowerCase()} with high ratings and multiple shades`;
const schemaPrice = schemaOfferPrice(p.price);
const offer = {
"@type": "Offer",
"priceCurrency": "GBP",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition",
"url": affUrl,
"shippingDetails": {
"@type": "OfferShippingDetails",
"shippingRate": {
"@type": "MonetaryAmount",
"value": "0.00",
"currency": "GBP"
},
"shippingDestination": {
"@type": "DefinedRegion",
"addressCountry": "GB"
},
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": { "@type": "QuantitativeValue", "minValue": 0, "maxValue": 2, "unitCode": "DAY" },
"transitTime": { "@type": "QuantitativeValue", "minValue": 2, "maxValue": 7, "unitCode": "DAY" }
}
},
"hasMerchantReturnPolicy": {
"@type": "MerchantReturnPolicy",
"applicableCountry": "GB",
"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
"merchantReturnDays": 30,
"returnMethod": "https://schema.org/ReturnByMail",
"returnFees": "https://schema.org/FreeReturn"
}
};
if (schemaPrice) offer.price = schemaPrice;
return {
"@type": "ListItem",
"position": index + 1,
"item": {
"@type": "Product",
"name": cleanName,
"image": imageUrl,
"description": description,
"offers": offer
}
};
});
// Find your existing CollectionPage schema script
const $schemaScript = $('script[type="application/ld+json"]').filter(function () {
return this.textContent.includes('"mainEntity"') && this.textContent.includes('"CollectionPage"');
});
if ($schemaScript.length === 0) return;
try {
const schema = JSON.parse($schemaScript.text());
schema.mainEntity.itemListElement = itemListElement;
$schemaScript.text(JSON.stringify(schema, null, 2));
} catch (e) {
console.warn("Failed to update schema:", e);
}
}
// ===============================================
// DOCUMENT READY – ALL EVENT LISTENERS
// ===============================================
$(function() {
load_products();
if (window.UKLipsTheme) {
window.UKLipsTheme.restoreTheme();
} else {
$('#darkToggle').on('click', function() {
const isDark = $('html').attr('data-theme') === 'dark';
const next = isDark ? 'light' : 'dark';
$('html').attr('data-theme', next);
try { localStorage.setItem('theme', next); } catch (e) {}
$(this).html(isDark ? '' : '');
});
try {
const saved = localStorage.getItem('theme');
if (saved === 'dark' || saved === 'light') {
$('html').attr('data-theme', saved);
$('#darkToggle').html(saved === 'dark' ? '' : '');
}
} catch (e) {}
}
$('#viewAllBtn').on('click', () => {
$('html,body').animate({ scrollTop: $productsGrid.offset().top - 100 }, 600);
});
$('#floatingFilterBtn').on('click', function() {
const $panel = $('#filterPanel');
const isActive = $panel.hasClass('active');
if (isActive) {
closeFilterPanel(false);
return;
}
openFilterPanel();
});
$('#filterBackdrop').on('click', () => closeFilterPanel(false));
$('#closeFiltersBtn').on('click', () => closeFilterPanel(false));
$('#applyFiltersBtn, #applyFiltersMobile').on('click', applyFiltersAndClose);
$('#searchInput, #popularFilter, #brandFilter, #ratingFilter, #minPrice, #maxPrice, #sortSelect')
.on('input change', markFiltersDirty);
$('#clearFilters, #clearFiltersMobile').on('click', () => {
$('#searchInput, #minPrice, #maxPrice').val('');
$('#popularFilter, #ratingFilter, #brandFilter, #sortSelect').val('');
const $sortSelect = $('#sortSelect');
if ($sortSelect.length) $sortSelect.val('rank');
applyFilters();
resetFilterButtonLabel();
closeFilterPanel(false);
scrollToProductsGrid();
});
$('#viewComparison').on('click', () => {
if (compareList.length < 2) {
showCompareToast('Add at least 2 products to compare');
return;
}
renderComparisonTable();
$('#comparisonModal').addClass('active');
$('body').css('overflow', 'hidden');
});
$('#clearComparison').on('click', () => {
compareList = [];
saveLists();
syncCompareButtons();
updateComparisonBar();
showCompareToast('Comparison cleared');
});
$(document).on('click', '.close-modal', function(e) {
e.stopPropagation();
$('#imageModal, #comparisonModal, #videoModal').removeClass('active');
$('body').css('overflow', '');
$('#modalVideo')[0].pause();
$(document).off('keydown.modalNav');
$('.modal-image-wrapper').off('touchstart touchmove touchend');
});
$(document).on('keydown', e => {
if (e.key === 'Escape') {
if ($('#filterPanel').hasClass('active')) {
closeFilterPanel(false);
return;
}
$('#imageModal, #comparisonModal, #videoModal').removeClass('active');
$('body').css('overflow', '');
$('#modalVideo')[0].pause();
}
});
$(document).on('click', '#imageModal, #comparisonModal, #videoModal', function(e) {
if (e.target === this) {
$(this).removeClass('active');
$('body').css('overflow', '');
$('#modalVideo')[0].pause();
}
});
});