ICX, a prominent player in the automotive industry, grappled with operational inefficiencies caused by their on-premise contact system, resulting in frequent glitches and hampering their growth potential. In search of a more suitable solution, they turned to MaxContact, whose flexible cloud-based system, strong support, and transparent collaboration proved instrumental in streamlining ICX’s operations. This shift allowed them to offer omnichannel solutions, enhance productivity, and better manage high-profile clients, ultimately enabling ICX to deliver superior customer experiences, efficiently scale their resources, and drive sustainable growth.
ICX works with some of the biggest brands in the automotive sector to deliver comprehensive customer loyalty, retention, and sales services, supporting their clients through all stages of the customer journey, from acquisition to aftersales. Customers include major manufacturers like Nissan, Suzuki, Stellantis Group, and large dealerships.
The company works as an extension to in-house teams, proactively contacting customers with the right messages at the right time. By doing so, ICX helps clients drive loyalty and retention, improve customer experiences, and increase sales to boost revenue.
The business has been operating for more than a decade. We talked to managing director Steven Sykes and technology director Scott Brown about the company’s search for more efficient communications.
(function () {
const READ_WPM = 220; // average words per minute
const $readTime = document.getElementById('read-time');
const $progress = document.getElementById('progress-bar');
// Get all rich text elements from case_studies-sections div
function getAllRichTexts() {
const caseStudySection = document.querySelector('.case_studies-sections');
if (!caseStudySection) return [];
return Array.from(caseStudySection.querySelectorAll('.text-rich-text'));
}
const $articles = getAllRichTexts();
if (!$articles.length) return;
// --- Reading time ---
function computeReadingTime() {
let totalText = '';
// Collect text from all rich text elements
$articles.forEach($article => {
const text = ($article.innerText || $article.textContent || '').trim();
totalText += text + ' ';
});
const words = totalText.length ? totalText.split(/\s+/).length : 0;
const minutes = Math.max(1, Math.ceil(words / READ_WPM));
if ($readTime) $readTime.textContent = `${minutes} MIN READ`;
}
// --- Scroll progress ---
let startY = 0;
let endY = 0;
function clamp01(x) { return Math.max(0, Math.min(1, x)); }
function measure() {
// Calculate bounds based on all articles
let minTop = Infinity;
let maxBottom = -Infinity;
$articles.forEach($article => {
const rect = $article.getBoundingClientRect();
const scrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
const docTopToArticleTop = rect.top + scrollY;
const articleHeight = $article.offsetHeight;
minTop = Math.min(minTop, docTopToArticleTop);
maxBottom = Math.max(maxBottom, docTopToArticleTop + articleHeight);
});
const viewportH = window.innerHeight || document.documentElement.clientHeight;
startY = minTop - 16;
endY = maxBottom - viewportH + 16;
if (endY < startY) endY = startY + 1;
}
function onScroll() {
if (!$progress) return;
const scrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
const t = clamp01((scrollY - startY) / (endY - startY));
$progress.style.width = (t * 100).toFixed(2) + '%';
}
function watchImages() {
$articles.forEach($article => {
const images = $article.querySelectorAll('img');
images.forEach(img => {
if (!img.complete) {
img.addEventListener('load', () => { measure(); onScroll(); }, { once: true });
img.addEventListener('error', () => { measure(); onScroll(); }, { once: true });
}
});
});
}
computeReadingTime();
measure();
watchImages();
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', () => { measure(); onScroll(); });
// Watch for changes in all articles
const mo = new MutationObserver(() => { computeReadingTime(); measure(); onScroll(); });
$articles.forEach($article => {
mo.observe($article, { childList: true, subtree: true, characterData: true });
});
})();
(() => {
const rich = document.querySelector('#rich-text');
const toc = document.querySelector('#toc');
if (!rich || !toc) return;
// Only H2s inside the Rich Text
const headings = [...rich.querySelectorAll('h2')];
if (!headings.length) { toc.style.display = 'none'; return; }
// Slugify + ensure unique IDs (handles accents like šđčćž)
const slugCounts = {};
const slugify = (str) => {
const base = (str || '')
.trim()
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // remove diacritics
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
const n = (slugCounts[base] = (slugCounts[base] || 0) + 1);
return n > 1 ? `${base}-${n}` : base || `section-${n}`;
};
// Build anchors directly inside #toc
toc.innerHTML = '';
headings.forEach((h, idx) => {
if (!h.id) h.id = slugify(h.textContent || `section-${idx+1}`);
const a = document.createElement('a');
a.href = `#${h.id}`;
a.className = 'content_link';
a.dataset.target = h.id;
a.setAttribute('aria-label', h.textContent || `Section ${idx+1}`);
const p = document.createElement('p');
p.className = 'text-size-small';
p.textContent = h.textContent || `Section ${idx+1}`;
a.appendChild(p);
toc.appendChild(a);
});
// Offset for fixed navs - with extra spacing for visibility
const getOffset = () => {
const nav = document.querySelector('.navbar, .w-nav, [data-nav]');
const navHeight = nav ? nav.getBoundingClientRect().height : 0;
// Add 30px buffer to ensure heading is clearly visible below fixed navbar
return navHeight + 30;
};
toc.addEventListener('click', (e) => {
const link = e.target.closest('a.content_link[href^="#"]');
if (!link) return;
e.preventDefault();
e.stopPropagation(); // Stop other event listeners
const id = link.getAttribute('href').slice(1);
const target = document.getElementById(id);
if (!target) return;
const targetTop = target.getBoundingClientRect().top + window.scrollY;
const finalY = targetTop - 150;
// Use only smooth scroll
window.scrollTo({ top: finalY, behavior: 'smooth' });
history.replaceState(null, '', `#${id}`);
});
})();
Steven had a decision to make when he joined the business three years ago. The ICX contact system was on-premise, inflexible, and unable to provide the kind of omnichannel experience the company’s clients increasingly sought.
Managing and maintaining in-house servers was a significant issue as they grew due to the increased demand on their internal resources. The pressure was compounded by the system’s frequent glitches, a feature set that didn’t live up to expectations and inadequate support from the provider.
Scott says: “In terms of the technology side, we had performance issues, records not being presented, screen pops not happening, even bugs in the script. Our development resource was being used up trying to solve the inefficiencies of the system.”
Steven adds: “With the system we had, we had to have one person whose full time role was to effectively run the agents and make sure they were in the right calling lists for the right clients. That was their entire job. That person is still in the business, but we’ve now removed that role.”
(function () {
const READ_WPM = 220; // average words per minute
const $readTime = document.getElementById('read-time');
const $progress = document.getElementById('progress-bar');
// Get all rich text elements from case_studies-sections div
function getAllRichTexts() {
const caseStudySection = document.querySelector('.case_studies-sections');
if (!caseStudySection) return [];
return Array.from(caseStudySection.querySelectorAll('.text-rich-text'));
}
const $articles = getAllRichTexts();
if (!$articles.length) return;
// --- Reading time ---
function computeReadingTime() {
let totalText = '';
// Collect text from all rich text elements
$articles.forEach($article => {
const text = ($article.innerText || $article.textContent || '').trim();
totalText += text + ' ';
});
const words = totalText.length ? totalText.split(/\s+/).length : 0;
const minutes = Math.max(1, Math.ceil(words / READ_WPM));
if ($readTime) $readTime.textContent = `${minutes} MIN READ`;
}
// --- Scroll progress ---
let startY = 0;
let endY = 0;
function clamp01(x) { return Math.max(0, Math.min(1, x)); }
function measure() {
// Calculate bounds based on all articles
let minTop = Infinity;
let maxBottom = -Infinity;
$articles.forEach($article => {
const rect = $article.getBoundingClientRect();
const scrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
const docTopToArticleTop = rect.top + scrollY;
const articleHeight = $article.offsetHeight;
minTop = Math.min(minTop, docTopToArticleTop);
maxBottom = Math.max(maxBottom, docTopToArticleTop + articleHeight);
});
const viewportH = window.innerHeight || document.documentElement.clientHeight;
startY = minTop - 16;
endY = maxBottom - viewportH + 16;
if (endY < startY) endY = startY + 1;
}
function onScroll() {
if (!$progress) return;
const scrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
const t = clamp01((scrollY - startY) / (endY - startY));
$progress.style.width = (t * 100).toFixed(2) + '%';
}
function watchImages() {
$articles.forEach($article => {
const images = $article.querySelectorAll('img');
images.forEach(img => {
if (!img.complete) {
img.addEventListener('load', () => { measure(); onScroll(); }, { once: true });
img.addEventListener('error', () => { measure(); onScroll(); }, { once: true });
}
});
});
}
computeReadingTime();
measure();
watchImages();
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', () => { measure(); onScroll(); });
// Watch for changes in all articles
const mo = new MutationObserver(() => { computeReadingTime(); measure(); onScroll(); });
$articles.forEach($article => {
mo.observe($article, { childList: true, subtree: true, characterData: true });
});
})();
(() => {
const rich = document.querySelector('#rich-text');
const toc = document.querySelector('#toc');
if (!rich || !toc) return;
// Only H2s inside the Rich Text
const headings = [...rich.querySelectorAll('h2')];
if (!headings.length) { toc.style.display = 'none'; return; }
// Slugify + ensure unique IDs (handles accents like šđčćž)
const slugCounts = {};
const slugify = (str) => {
const base = (str || '')
.trim()
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // remove diacritics
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
const n = (slugCounts[base] = (slugCounts[base] || 0) + 1);
return n > 1 ? `${base}-${n}` : base || `section-${n}`;
};
// Build anchors directly inside #toc
toc.innerHTML = '';
headings.forEach((h, idx) => {
if (!h.id) h.id = slugify(h.textContent || `section-${idx+1}`);
const a = document.createElement('a');
a.href = `#${h.id}`;
a.className = 'content_link';
a.dataset.target = h.id;
a.setAttribute('aria-label', h.textContent || `Section ${idx+1}`);
const p = document.createElement('p');
p.className = 'text-size-small';
p.textContent = h.textContent || `Section ${idx+1}`;
a.appendChild(p);
toc.appendChild(a);
});
// Offset for fixed navs - with extra spacing for visibility
const getOffset = () => {
const nav = document.querySelector('.navbar, .w-nav, [data-nav]');
const navHeight = nav ? nav.getBoundingClientRect().height : 0;
// Add 30px buffer to ensure heading is clearly visible below fixed navbar
return navHeight + 30;
};
toc.addEventListener('click', (e) => {
const link = e.target.closest('a.content_link[href^="#"]');
if (!link) return;
e.preventDefault();
e.stopPropagation(); // Stop other event listeners
const id = link.getAttribute('href').slice(1);
const target = document.getElementById(id);
if (!target) return;
const targetTop = target.getBoundingClientRect().top + window.scrollY;
const finalY = targetTop - 150;
// Use only smooth scroll
window.scrollTo({ top: finalY, behavior: 'smooth' });
history.replaceState(null, '', `#${id}`);
});
})();
Something had to change. The contact solution was affecting the company’s ability to take on new work, just as it was looking to grow.
After a referral, ICX evaluated MaxContact and two rival products. The company had a number of criteria that vendors had to meet. Fundamentally, ICX needed a flexible cloud-based system that would easily integrate with their system, and provide a future-proof feature set with top tier support.
They were seeking a supplier that would better fit their needs. “We really wanted somebody who would be transparent, and who wouldn’t just tell us what we wanted to hear,” says Steven. “The relationship had to be right because our business hinges on the quality of our communications.”
ICX went through a long proof of concept phase with the MaxContact solution, allowing the business to tick every box and ensure the system could do everything they needed it to. That hands-on testing proved both the functionality of the solution and the collaborative nature of the relationship between vendor and client.
(function () {
const READ_WPM = 220; // average words per minute
const $readTime = document.getElementById('read-time');
const $progress = document.getElementById('progress-bar');
// Get all rich text elements from case_studies-sections div
function getAllRichTexts() {
const caseStudySection = document.querySelector('.case_studies-sections');
if (!caseStudySection) return [];
return Array.from(caseStudySection.querySelectorAll('.text-rich-text'));
}
const $articles = getAllRichTexts();
if (!$articles.length) return;
// --- Reading time ---
function computeReadingTime() {
let totalText = '';
// Collect text from all rich text elements
$articles.forEach($article => {
const text = ($article.innerText || $article.textContent || '').trim();
totalText += text + ' ';
});
const words = totalText.length ? totalText.split(/\s+/).length : 0;
const minutes = Math.max(1, Math.ceil(words / READ_WPM));
if ($readTime) $readTime.textContent = `${minutes} MIN READ`;
}
// --- Scroll progress ---
let startY = 0;
let endY = 0;
function clamp01(x) { return Math.max(0, Math.min(1, x)); }
function measure() {
// Calculate bounds based on all articles
let minTop = Infinity;
let maxBottom = -Infinity;
$articles.forEach($article => {
const rect = $article.getBoundingClientRect();
const scrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
const docTopToArticleTop = rect.top + scrollY;
const articleHeight = $article.offsetHeight;
minTop = Math.min(minTop, docTopToArticleTop);
maxBottom = Math.max(maxBottom, docTopToArticleTop + articleHeight);
});
const viewportH = window.innerHeight || document.documentElement.clientHeight;
startY = minTop - 16;
endY = maxBottom - viewportH + 16;
if (endY < startY) endY = startY + 1;
}
function onScroll() {
if (!$progress) return;
const scrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
const t = clamp01((scrollY - startY) / (endY - startY));
$progress.style.width = (t * 100).toFixed(2) + '%';
}
function watchImages() {
$articles.forEach($article => {
const images = $article.querySelectorAll('img');
images.forEach(img => {
if (!img.complete) {
img.addEventListener('load', () => { measure(); onScroll(); }, { once: true });
img.addEventListener('error', () => { measure(); onScroll(); }, { once: true });
}
});
});
}
computeReadingTime();
measure();
watchImages();
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', () => { measure(); onScroll(); });
// Watch for changes in all articles
const mo = new MutationObserver(() => { computeReadingTime(); measure(); onScroll(); });
$articles.forEach($article => {
mo.observe($article, { childList: true, subtree: true, characterData: true });
});
})();
(() => {
const rich = document.querySelector('#rich-text');
const toc = document.querySelector('#toc');
if (!rich || !toc) return;
// Only H2s inside the Rich Text
const headings = [...rich.querySelectorAll('h2')];
if (!headings.length) { toc.style.display = 'none'; return; }
// Slugify + ensure unique IDs (handles accents like šđčćž)
const slugCounts = {};
const slugify = (str) => {
const base = (str || '')
.trim()
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // remove diacritics
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
const n = (slugCounts[base] = (slugCounts[base] || 0) + 1);
return n > 1 ? `${base}-${n}` : base || `section-${n}`;
};
// Build anchors directly inside #toc
toc.innerHTML = '';
headings.forEach((h, idx) => {
if (!h.id) h.id = slugify(h.textContent || `section-${idx+1}`);
const a = document.createElement('a');
a.href = `#${h.id}`;
a.className = 'content_link';
a.dataset.target = h.id;
a.setAttribute('aria-label', h.textContent || `Section ${idx+1}`);
const p = document.createElement('p');
p.className = 'text-size-small';
p.textContent = h.textContent || `Section ${idx+1}`;
a.appendChild(p);
toc.appendChild(a);
});
// Offset for fixed navs - with extra spacing for visibility
const getOffset = () => {
const nav = document.querySelector('.navbar, .w-nav, [data-nav]');
const navHeight = nav ? nav.getBoundingClientRect().height : 0;
// Add 30px buffer to ensure heading is clearly visible below fixed navbar
return navHeight + 30;
};
toc.addEventListener('click', (e) => {
const link = e.target.closest('a.content_link[href^="#"]');
if (!link) return;
e.preventDefault();
e.stopPropagation(); // Stop other event listeners
const id = link.getAttribute('href').slice(1);
const target = document.getElementById(id);
if (!target) return;
const targetTop = target.getBoundingClientRect().top + window.scrollY;
const finalY = targetTop - 150;
// Use only smooth scroll
window.scrollTo({ top: finalY, behavior: 'smooth' });
history.replaceState(null, '', `#${id}`);
});
})();
ICX migrated to the MaxContact solution around two and a half years ago, and in that time the system has simplified huge areas of their operation, from security and remote work to onboarding new business and real time performance reporting.
Steven continues: “It’s enabled us to offer a truly omnichannel solution to clients. We can now do outbound, inbound and live chat, all within one campaign.”
The scalability of the system is another major benefit. ICX can ramp up resources quickly as a campaign demands, with minimal setup time and easy workforce management. Things that should always have been straightforward – scripting is one example – now finally are.
All this feeds into improved performance. With the previous system, decision-maker contact rates were running at around seven or eight an hour. That’s now hitting 11 or 12, representing a productivity improvement of over 30%.
Finally, the support has been excellent from the outset. “You feel like MaxContact are actively listening to you and helping with any challenges you have,” says Scott.
ICX needed the right contact centre solution to serve a growing portfolio of high-profile clients. In MaxContact, the company found it.
(function () {
const READ_WPM = 220; // average words per minute
const $readTime = document.getElementById('read-time');
const $progress = document.getElementById('progress-bar');
// Get all rich text elements from case_studies-sections div
function getAllRichTexts() {
const caseStudySection = document.querySelector('.case_studies-sections');
if (!caseStudySection) return [];
return Array.from(caseStudySection.querySelectorAll('.text-rich-text'));
}
const $articles = getAllRichTexts();
if (!$articles.length) return;
// --- Reading time ---
function computeReadingTime() {
let totalText = '';
// Collect text from all rich text elements
$articles.forEach($article => {
const text = ($article.innerText || $article.textContent || '').trim();
totalText += text + ' ';
});
const words = totalText.length ? totalText.split(/\s+/).length : 0;
const minutes = Math.max(1, Math.ceil(words / READ_WPM));
if ($readTime) $readTime.textContent = `${minutes} MIN READ`;
}
// --- Scroll progress ---
let startY = 0;
let endY = 0;
function clamp01(x) { return Math.max(0, Math.min(1, x)); }
function measure() {
// Calculate bounds based on all articles
let minTop = Infinity;
let maxBottom = -Infinity;
$articles.forEach($article => {
const rect = $article.getBoundingClientRect();
const scrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
const docTopToArticleTop = rect.top + scrollY;
const articleHeight = $article.offsetHeight;
minTop = Math.min(minTop, docTopToArticleTop);
maxBottom = Math.max(maxBottom, docTopToArticleTop + articleHeight);
});
const viewportH = window.innerHeight || document.documentElement.clientHeight;
startY = minTop - 16;
endY = maxBottom - viewportH + 16;
if (endY < startY) endY = startY + 1;
}
function onScroll() {
if (!$progress) return;
const scrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
const t = clamp01((scrollY - startY) / (endY - startY));
$progress.style.width = (t * 100).toFixed(2) + '%';
}
function watchImages() {
$articles.forEach($article => {
const images = $article.querySelectorAll('img');
images.forEach(img => {
if (!img.complete) {
img.addEventListener('load', () => { measure(); onScroll(); }, { once: true });
img.addEventListener('error', () => { measure(); onScroll(); }, { once: true });
}
});
});
}
computeReadingTime();
measure();
watchImages();
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', () => { measure(); onScroll(); });
// Watch for changes in all articles
const mo = new MutationObserver(() => { computeReadingTime(); measure(); onScroll(); });
$articles.forEach($article => {
mo.observe($article, { childList: true, subtree: true, characterData: true });
});
})();
(() => {
const rich = document.querySelector('#rich-text');
const toc = document.querySelector('#toc');
if (!rich || !toc) return;
// Only H2s inside the Rich Text
const headings = [...rich.querySelectorAll('h2')];
if (!headings.length) { toc.style.display = 'none'; return; }
// Slugify + ensure unique IDs (handles accents like šđčćž)
const slugCounts = {};
const slugify = (str) => {
const base = (str || '')
.trim()
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // remove diacritics
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
const n = (slugCounts[base] = (slugCounts[base] || 0) + 1);
return n > 1 ? `${base}-${n}` : base || `section-${n}`;
};
// Build anchors directly inside #toc
toc.innerHTML = '';
headings.forEach((h, idx) => {
if (!h.id) h.id = slugify(h.textContent || `section-${idx+1}`);
const a = document.createElement('a');
a.href = `#${h.id}`;
a.className = 'content_link';
a.dataset.target = h.id;
a.setAttribute('aria-label', h.textContent || `Section ${idx+1}`);
const p = document.createElement('p');
p.className = 'text-size-small';
p.textContent = h.textContent || `Section ${idx+1}`;
a.appendChild(p);
toc.appendChild(a);
});
// Offset for fixed navs - with extra spacing for visibility
const getOffset = () => {
const nav = document.querySelector('.navbar, .w-nav, [data-nav]');
const navHeight = nav ? nav.getBoundingClientRect().height : 0;
// Add 30px buffer to ensure heading is clearly visible below fixed navbar
return navHeight + 30;
};
toc.addEventListener('click', (e) => {
const link = e.target.closest('a.content_link[href^="#"]');
if (!link) return;
e.preventDefault();
e.stopPropagation(); // Stop other event listeners
const id = link.getAttribute('href').slice(1);
const target = document.getElementById(id);
if (!target) return;
const targetTop = target.getBoundingClientRect().top + window.scrollY;
const finalY = targetTop - 150;
// Use only smooth scroll
window.scrollTo({ top: finalY, behavior: 'smooth' });
history.replaceState(null, '', `#${id}`);
});
})();
(function () {
const READ_WPM = 220; // average words per minute
const $readTime = document.getElementById('read-time');
const $progress = document.getElementById('progress-bar');
// Get all rich text elements from case_studies-sections div
function getAllRichTexts() {
const caseStudySection = document.querySelector('.case_studies-sections');
if (!caseStudySection) return [];
return Array.from(caseStudySection.querySelectorAll('.text-rich-text'));
}
const $articles = getAllRichTexts();
if (!$articles.length) return;
// --- Reading time ---
function computeReadingTime() {
let totalText = '';
// Collect text from all rich text elements
$articles.forEach($article => {
const text = ($article.innerText || $article.textContent || '').trim();
totalText += text + ' ';
});
const words = totalText.length ? totalText.split(/\s+/).length : 0;
const minutes = Math.max(1, Math.ceil(words / READ_WPM));
if ($readTime) $readTime.textContent = `${minutes} MIN READ`;
}
// --- Scroll progress ---
let startY = 0;
let endY = 0;
function clamp01(x) { return Math.max(0, Math.min(1, x)); }
function measure() {
// Calculate bounds based on all articles
let minTop = Infinity;
let maxBottom = -Infinity;
$articles.forEach($article => {
const rect = $article.getBoundingClientRect();
const scrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
const docTopToArticleTop = rect.top + scrollY;
const articleHeight = $article.offsetHeight;
minTop = Math.min(minTop, docTopToArticleTop);
maxBottom = Math.max(maxBottom, docTopToArticleTop + articleHeight);
});
const viewportH = window.innerHeight || document.documentElement.clientHeight;
startY = minTop - 16;
endY = maxBottom - viewportH + 16;
if (endY < startY) endY = startY + 1;
}
function onScroll() {
if (!$progress) return;
const scrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
const t = clamp01((scrollY - startY) / (endY - startY));
$progress.style.width = (t * 100).toFixed(2) + '%';
}
function watchImages() {
$articles.forEach($article => {
const images = $article.querySelectorAll('img');
images.forEach(img => {
if (!img.complete) {
img.addEventListener('load', () => { measure(); onScroll(); }, { once: true });
img.addEventListener('error', () => { measure(); onScroll(); }, { once: true });
}
});
});
}
computeReadingTime();
measure();
watchImages();
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', () => { measure(); onScroll(); });
// Watch for changes in all articles
const mo = new MutationObserver(() => { computeReadingTime(); measure(); onScroll(); });
$articles.forEach($article => {
mo.observe($article, { childList: true, subtree: true, characterData: true });
});
})();
(() => {
const rich = document.querySelector('#rich-text');
const toc = document.querySelector('#toc');
if (!rich || !toc) return;
// Only H2s inside the Rich Text
const headings = [...rich.querySelectorAll('h2')];
if (!headings.length) { toc.style.display = 'none'; return; }
// Slugify + ensure unique IDs (handles accents like šđčćž)
const slugCounts = {};
const slugify = (str) => {
const base = (str || '')
.trim()
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // remove diacritics
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
const n = (slugCounts[base] = (slugCounts[base] || 0) + 1);
return n > 1 ? `${base}-${n}` : base || `section-${n}`;
};
// Build anchors directly inside #toc
toc.innerHTML = '';
headings.forEach((h, idx) => {
if (!h.id) h.id = slugify(h.textContent || `section-${idx+1}`);
const a = document.createElement('a');
a.href = `#${h.id}`;
a.className = 'content_link';
a.dataset.target = h.id;
a.setAttribute('aria-label', h.textContent || `Section ${idx+1}`);
const p = document.createElement('p');
p.className = 'text-size-small';
p.textContent = h.textContent || `Section ${idx+1}`;
a.appendChild(p);
toc.appendChild(a);
});
// Offset for fixed navs - with extra spacing for visibility
const getOffset = () => {
const nav = document.querySelector('.navbar, .w-nav, [data-nav]');
const navHeight = nav ? nav.getBoundingClientRect().height : 0;
// Add 30px buffer to ensure heading is clearly visible below fixed navbar
return navHeight + 30;
};
toc.addEventListener('click', (e) => {
const link = e.target.closest('a.content_link[href^="#"]');
if (!link) return;
e.preventDefault();
e.stopPropagation(); // Stop other event listeners
const id = link.getAttribute('href').slice(1);
const target = document.getElementById(id);
if (!target) return;
const targetTop = target.getBoundingClientRect().top + window.scrollY;
const finalY = targetTop - 150;
// Use only smooth scroll
window.scrollTo({ top: finalY, behavior: 'smooth' });
history.replaceState(null, '', `#${id}`);
});
})();