Initial commit: sito NexStudio (Astro) con Cloudflare, CMS cookie e contenuti legali
- Home: hero, prodotti, servizi, stack, FAQ, CTA con form contatti inline (glossy) e particelle - Header nero, menu con scroll-spy e alone su voce attiva, CTA Chattiamo (pre-chat) - Pagine: privacy, cookie, GDPR, terms, about, codice etico, modello organizzativo, dove siamo, news - Cookie consent first-party, chat gate, stampe-friendly su SubpageLayout - Footer: Sezioni con Contattaci, Azienda con Dove siamo senza link per ora - .gitignore: aggiunto .wrangler/ per stato locale Cloudflare Made-with: Cursor
This commit is contained in:
215
src/scripts/nx-chat-gate-modal.ts
Normal file
215
src/scripts/nx-chat-gate-modal.ts
Normal file
@ -0,0 +1,215 @@
|
||||
interface ChatGateCfg {
|
||||
entryUrl: string;
|
||||
openInNewTab: boolean;
|
||||
actionUrl: string;
|
||||
mailto: string;
|
||||
notifySubject: string;
|
||||
successRedirect: string;
|
||||
fieldName: string;
|
||||
fieldEmail: string;
|
||||
openChatAfterSubmit: boolean;
|
||||
}
|
||||
|
||||
const OVERLAY_ID = 'nx-chat-gate-overlay';
|
||||
const PANEL_SEL = '[data-nx-chat-gate-panel]';
|
||||
|
||||
function readCfg(): ChatGateCfg {
|
||||
const el = document.getElementById('nx-chat-gate-model');
|
||||
const raw = el?.textContent?.trim();
|
||||
if (!raw) {
|
||||
return {
|
||||
entryUrl: '/#contatti',
|
||||
openInNewTab: false,
|
||||
actionUrl: '',
|
||||
mailto: '',
|
||||
notifySubject: '',
|
||||
successRedirect: '',
|
||||
fieldName: 'name',
|
||||
fieldEmail: 'email',
|
||||
openChatAfterSubmit: false,
|
||||
};
|
||||
}
|
||||
return JSON.parse(raw) as ChatGateCfg;
|
||||
}
|
||||
|
||||
function overlayEl(): HTMLElement | null {
|
||||
return document.getElementById(OVERLAY_ID);
|
||||
}
|
||||
|
||||
function isOpen(): boolean {
|
||||
const el = overlayEl();
|
||||
return Boolean(el && !el.hasAttribute('hidden'));
|
||||
}
|
||||
|
||||
function showFormStep(): void {
|
||||
document.getElementById('nx-chat-gate-step-form')?.classList.remove('hidden');
|
||||
document.getElementById('nx-chat-gate-step-success')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showSuccessStep(): void {
|
||||
document.getElementById('nx-chat-gate-step-form')?.classList.add('hidden');
|
||||
document.getElementById('nx-chat-gate-step-success')?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closePanel(): void {
|
||||
const el = overlayEl();
|
||||
if (!el) return;
|
||||
el.setAttribute('hidden', '');
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
const f = document.getElementById('nx-chat-gate-form') as HTMLFormElement | null;
|
||||
f?.reset();
|
||||
const err = document.getElementById('nx-chat-gate-form-error');
|
||||
if (err) {
|
||||
err.textContent = '';
|
||||
err.classList.add('hidden');
|
||||
}
|
||||
showFormStep();
|
||||
}
|
||||
|
||||
function openChat(): void {
|
||||
const cfg = readCfg();
|
||||
const url = cfg.entryUrl;
|
||||
if (url.startsWith('http')) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
window.location.assign(url);
|
||||
}
|
||||
}
|
||||
|
||||
function openPanel(): void {
|
||||
const el = overlayEl();
|
||||
if (!el) return;
|
||||
el.removeAttribute('hidden');
|
||||
el.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('overflow-hidden');
|
||||
showFormStep();
|
||||
requestAnimationFrame(() => {
|
||||
el.querySelector<HTMLElement>('#nx-cg-email')?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function eventTargetElement(e: Event): Element | null {
|
||||
const t = e.target;
|
||||
if (t instanceof Element) return t;
|
||||
if (t instanceof Text) return t.parentElement;
|
||||
return null;
|
||||
}
|
||||
|
||||
function bind(): void {
|
||||
const root = overlayEl();
|
||||
const form = document.getElementById('nx-chat-gate-form') as HTMLFormElement | null;
|
||||
if (!root || !form) return;
|
||||
|
||||
document.querySelectorAll<HTMLElement>('[data-nx-chat-open]').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
openPanel();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll<HTMLButtonElement>('[data-nx-chat-gate-close]').forEach((b) => {
|
||||
b.addEventListener('click', () => closePanel());
|
||||
});
|
||||
|
||||
document.getElementById('nx-chat-gate-open-chat')?.addEventListener('click', () => {
|
||||
openChat();
|
||||
closePanel();
|
||||
});
|
||||
|
||||
const onDocClickCapture = (e: MouseEvent) => {
|
||||
if (!isOpen()) return;
|
||||
const el = eventTargetElement(e);
|
||||
if (!el || !root.contains(el)) return;
|
||||
if (el.closest('[data-nx-chat-gate-close]')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
if (!el.closest(PANEL_SEL)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closePanel();
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', onDocClickCapture, true);
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || !isOpen()) return;
|
||||
e.preventDefault();
|
||||
closePanel();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
const cfg = readCfg();
|
||||
const err = document.getElementById('nx-chat-gate-form-error');
|
||||
if (err) err.classList.add('hidden');
|
||||
|
||||
if (cfg.actionUrl) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
try {
|
||||
const res = await fetch(cfg.actionUrl, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: fd,
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
(data && typeof data.error === 'string' && data.error) ||
|
||||
'Invio non riuscito. Riprova tra poco.';
|
||||
if (err) {
|
||||
err.textContent = msg;
|
||||
err.classList.remove('hidden');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cfg.openChatAfterSubmit) {
|
||||
openChat();
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
showSuccessStep();
|
||||
document.getElementById('nx-chat-gate-open-chat')?.focus();
|
||||
} catch {
|
||||
if (err) {
|
||||
err.textContent = 'Errore di rete. Controlla la connessione e riprova.';
|
||||
err.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
const name = String(fd.get(cfg.fieldName) ?? '').trim();
|
||||
const email = String(fd.get(cfg.fieldEmail) ?? '').trim();
|
||||
if (cfg.mailto) {
|
||||
const subject = encodeURIComponent(cfg.notifySubject || 'Accesso chat');
|
||||
const body = encodeURIComponent(
|
||||
`Richiesta accesso alla chat.\n\nNome: ${name || '—'}\nEmail: ${email}\n`,
|
||||
);
|
||||
window.location.href = `mailto:${cfg.mailto}?subject=${subject}&body=${body}`;
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
if (err) {
|
||||
err.textContent =
|
||||
'Configura `chat.prechat.actionUrl` (Formspree) o `chat.prechat.mailto` in `src/data/home/chat.ts`.';
|
||||
err.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
window.addEventListener('pageshow', (e) => {
|
||||
if (e.persisted) closePanel();
|
||||
});
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bind, { once: true });
|
||||
} else {
|
||||
bind();
|
||||
}
|
||||
}
|
||||
75
src/scripts/nx-contact-form.ts
Normal file
75
src/scripts/nx-contact-form.ts
Normal file
@ -0,0 +1,75 @@
|
||||
/** Modulo contatti in pagina (#contatti): mailto o POST Formspree. */
|
||||
interface ContactFormCfg {
|
||||
actionUrl: string;
|
||||
mailto: string;
|
||||
notifySubject: string;
|
||||
successRedirect: string;
|
||||
fieldName: string;
|
||||
fieldCompany: string;
|
||||
fieldCountry: string;
|
||||
fieldEmail: string;
|
||||
fieldMessage: string;
|
||||
}
|
||||
|
||||
function readCfg(): ContactFormCfg {
|
||||
const el = document.getElementById('nx-contact-model');
|
||||
const raw = el?.textContent?.trim();
|
||||
if (!raw) {
|
||||
return {
|
||||
actionUrl: '',
|
||||
mailto: '',
|
||||
notifySubject: '',
|
||||
successRedirect: '',
|
||||
fieldName: 'name',
|
||||
fieldCompany: 'company',
|
||||
fieldCountry: 'country',
|
||||
fieldEmail: 'email',
|
||||
fieldMessage: 'message',
|
||||
};
|
||||
}
|
||||
return JSON.parse(raw) as ContactFormCfg;
|
||||
}
|
||||
|
||||
function bind(): void {
|
||||
const form = document.getElementById('nx-contact-form') as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
const cfg = readCfg();
|
||||
const err = document.getElementById('nx-contact-form-error');
|
||||
if (err) err.classList.add('hidden');
|
||||
|
||||
if (cfg.actionUrl) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
const name = String(fd.get(cfg.fieldName) ?? '').trim();
|
||||
const company = String(fd.get(cfg.fieldCompany) ?? '').trim();
|
||||
const email = String(fd.get(cfg.fieldEmail) ?? '').trim();
|
||||
const country = String(fd.get(cfg.fieldCountry) ?? '').trim();
|
||||
const message = String(fd.get(cfg.fieldMessage) ?? '').trim();
|
||||
if (cfg.mailto) {
|
||||
const subject = encodeURIComponent(cfg.notifySubject || 'Contatto sito');
|
||||
const companyLine = company ? `\nAzienda: ${company}` : '';
|
||||
const body = encodeURIComponent(
|
||||
`Nome: ${name}${companyLine}\nEmail: ${email}\nPaese: ${country}\n\n${message}`,
|
||||
);
|
||||
window.location.href = `mailto:${cfg.mailto}?subject=${subject}&body=${body}`;
|
||||
return;
|
||||
}
|
||||
if (err) {
|
||||
err.textContent =
|
||||
'Configura `contactForm.actionUrl` (Formspree) o `contactForm.mailto` in `src/data/home/cta.ts`.';
|
||||
err.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bind, { once: true });
|
||||
} else {
|
||||
bind();
|
||||
}
|
||||
}
|
||||
279
src/scripts/nx-cookie-consent.ts
Normal file
279
src/scripts/nx-cookie-consent.ts
Normal file
@ -0,0 +1,279 @@
|
||||
type CategoryId = 'necessary' | 'preferences' | 'analytics' | 'marketing';
|
||||
|
||||
interface CcConfig {
|
||||
storageKey: string;
|
||||
policyVersion: string;
|
||||
categories: readonly { id: CategoryId; required: boolean }[];
|
||||
}
|
||||
|
||||
interface ConsentPayload {
|
||||
policyVersion: string;
|
||||
updatedAt: string;
|
||||
categories: Record<CategoryId, boolean>;
|
||||
}
|
||||
|
||||
const EVENT_NAME = 'nexstudio:cookie-consent';
|
||||
const PANEL_ID = 'nx-cc-dialog';
|
||||
|
||||
/** Chrome può usare come `target` un nodo `#text` dentro al `<button>` */
|
||||
function eventTargetElement(e: Event): Element | null {
|
||||
const t = e.target;
|
||||
if (t instanceof Element) return t;
|
||||
if (t instanceof Text) return t.parentElement;
|
||||
return null;
|
||||
}
|
||||
|
||||
function panelRoot(): HTMLElement | null {
|
||||
return document.getElementById(PANEL_ID);
|
||||
}
|
||||
|
||||
function isPanelOpen(): boolean {
|
||||
const el = panelRoot();
|
||||
return Boolean(el && !el.hasAttribute('hidden'));
|
||||
}
|
||||
|
||||
function closeConsentPanel(): void {
|
||||
const el = panelRoot();
|
||||
if (!el) return;
|
||||
el.setAttribute('hidden', '');
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
}
|
||||
|
||||
function openConsentPanel(): void {
|
||||
const el = panelRoot();
|
||||
if (!el) return;
|
||||
el.removeAttribute('hidden');
|
||||
el.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('overflow-hidden');
|
||||
}
|
||||
|
||||
function defaultCategories(cfg: CcConfig): Record<CategoryId, boolean> {
|
||||
const out = {} as Record<CategoryId, boolean>;
|
||||
for (const c of cfg.categories) {
|
||||
out[c.id] = c.required ? true : false;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parsePayload(raw: string | null, cfg: CcConfig): ConsentPayload | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const v = JSON.parse(raw) as Partial<ConsentPayload>;
|
||||
if (!v || typeof v !== 'object') return null;
|
||||
if (v.policyVersion !== cfg.policyVersion) return null;
|
||||
if (typeof v.updatedAt !== 'string') return null;
|
||||
if (!v.categories || typeof v.categories !== 'object') return null;
|
||||
const cats = { ...defaultCategories(cfg) };
|
||||
for (const c of cfg.categories) {
|
||||
if (typeof v.categories[c.id] === 'boolean') {
|
||||
cats[c.id] = c.required ? true : Boolean(v.categories[c.id]);
|
||||
}
|
||||
}
|
||||
return { policyVersion: cfg.policyVersion, updatedAt: v.updatedAt, categories: cats };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readConfig(): CcConfig {
|
||||
const el = document.getElementById('nx-cc-model');
|
||||
const raw = el?.textContent?.trim();
|
||||
if (!raw) {
|
||||
return {
|
||||
storageKey: 'nexstudio.cookieConsent',
|
||||
policyVersion: '1.0',
|
||||
categories: [
|
||||
{ id: 'necessary', required: true },
|
||||
{ id: 'preferences', required: false },
|
||||
{ id: 'analytics', required: false },
|
||||
{ id: 'marketing', required: false },
|
||||
],
|
||||
};
|
||||
}
|
||||
return JSON.parse(raw) as CcConfig;
|
||||
}
|
||||
|
||||
function save(cfg: CcConfig, categories: Record<CategoryId, boolean>): ConsentPayload {
|
||||
const payload: ConsentPayload = {
|
||||
policyVersion: cfg.policyVersion,
|
||||
updatedAt: new Date().toISOString(),
|
||||
categories: { ...categories },
|
||||
};
|
||||
for (const c of cfg.categories) {
|
||||
if (c.required) payload.categories[c.id] = true;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(cfg.storageKey, JSON.stringify(payload));
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: payload }));
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getStored(cfg: CcConfig): ConsentPayload | null {
|
||||
try {
|
||||
return parsePayload(localStorage.getItem(cfg.storageKey), cfg);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let nxCcDocCapture: ((e: MouseEvent) => void) | undefined;
|
||||
|
||||
function bind(): void {
|
||||
const cfg = readConfig();
|
||||
const banner = document.getElementById('nx-cc-banner');
|
||||
const root = panelRoot();
|
||||
if (!banner || !root) return;
|
||||
|
||||
closeConsentPanel();
|
||||
|
||||
function readForm(): Record<CategoryId, boolean> {
|
||||
const d = panelRoot();
|
||||
const base = defaultCategories(cfg);
|
||||
base.preferences = Boolean(d?.querySelector<HTMLInputElement>('[data-nx-cc-cat="preferences"]')?.checked);
|
||||
base.analytics = Boolean(d?.querySelector<HTMLInputElement>('[data-nx-cc-cat="analytics"]')?.checked);
|
||||
base.marketing = Boolean(d?.querySelector<HTMLInputElement>('[data-nx-cc-cat="marketing"]')?.checked);
|
||||
return base;
|
||||
}
|
||||
|
||||
function writeForm(cats: Record<CategoryId, boolean>) {
|
||||
const d = panelRoot();
|
||||
const p = d?.querySelector<HTMLInputElement>('[data-nx-cc-cat="preferences"]');
|
||||
const a = d?.querySelector<HTMLInputElement>('[data-nx-cc-cat="analytics"]');
|
||||
const m = d?.querySelector<HTMLInputElement>('[data-nx-cc-cat="marketing"]');
|
||||
if (p) p.checked = cats.preferences;
|
||||
if (a) a.checked = cats.analytics;
|
||||
if (m) m.checked = cats.marketing;
|
||||
}
|
||||
|
||||
function hideBanner() {
|
||||
banner.setAttribute('hidden', '');
|
||||
banner.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
function showBanner() {
|
||||
banner.removeAttribute('hidden');
|
||||
banner.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
function applyFromStorage() {
|
||||
const stored = getStored(cfg);
|
||||
if (stored) {
|
||||
writeForm(stored.categories);
|
||||
hideBanner();
|
||||
window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: stored }));
|
||||
} else {
|
||||
writeForm(defaultCategories(cfg));
|
||||
showBanner();
|
||||
}
|
||||
closeConsentPanel();
|
||||
}
|
||||
|
||||
function persistAndClose(cats: Record<CategoryId, boolean>) {
|
||||
save(cfg, cats);
|
||||
hideBanner();
|
||||
closeConsentPanel();
|
||||
}
|
||||
|
||||
banner.querySelector('[data-nx-cc-reject]')?.addEventListener('click', () => {
|
||||
persistAndClose(defaultCategories(cfg));
|
||||
});
|
||||
|
||||
banner.querySelector('[data-nx-cc-accept]')?.addEventListener('click', () => {
|
||||
const cats = defaultCategories(cfg);
|
||||
for (const c of cfg.categories) {
|
||||
if (!c.required) cats[c.id] = true;
|
||||
}
|
||||
persistAndClose(cats);
|
||||
});
|
||||
|
||||
banner.querySelector('[data-nx-cc-customize]')?.addEventListener('click', () => {
|
||||
const stored = getStored(cfg);
|
||||
writeForm(stored?.categories ?? defaultCategories(cfg));
|
||||
openConsentPanel();
|
||||
});
|
||||
|
||||
const onDocClickCapture = (e: MouseEvent) => {
|
||||
if (!isPanelOpen()) return;
|
||||
const d = panelRoot();
|
||||
if (!d) return;
|
||||
const el = eventTargetElement(e);
|
||||
if (!el || !d.contains(el)) return;
|
||||
|
||||
if (el.closest('[data-nx-cc-save]')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
persistAndClose(readForm());
|
||||
return;
|
||||
}
|
||||
if (el.closest('[data-nx-cc-cancel]')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeConsentPanel();
|
||||
return;
|
||||
}
|
||||
if (!el.closest('[data-nx-cc-panel]')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeConsentPanel();
|
||||
}
|
||||
};
|
||||
|
||||
if (nxCcDocCapture) {
|
||||
document.removeEventListener('click', nxCcDocCapture, true);
|
||||
}
|
||||
nxCcDocCapture = onDocClickCapture;
|
||||
document.addEventListener('click', nxCcDocCapture, true);
|
||||
|
||||
document.querySelectorAll<HTMLElement>('[data-nx-cc-open]').forEach((el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const stored = getStored(cfg);
|
||||
writeForm(stored?.categories ?? defaultCategories(cfg));
|
||||
openConsentPanel();
|
||||
});
|
||||
});
|
||||
|
||||
applyFromStorage();
|
||||
|
||||
(window as unknown as { NexStudioCookieConsent?: unknown }).NexStudioCookieConsent = {
|
||||
getConsent: () => getStored(cfg),
|
||||
openPreferences: () => {
|
||||
const stored = getStored(cfg);
|
||||
writeForm(stored?.categories ?? defaultCategories(cfg));
|
||||
openConsentPanel();
|
||||
},
|
||||
acceptAll: () => {
|
||||
const cats = defaultCategories(cfg);
|
||||
for (const c of cfg.categories) {
|
||||
if (!c.required) cats[c.id] = true;
|
||||
}
|
||||
persistAndClose(cats);
|
||||
},
|
||||
rejectOptional: () => {
|
||||
persistAndClose(defaultCategories(cfg));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
window.addEventListener('pageshow', (e) => {
|
||||
if (e.persisted) closeConsentPanel();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (!isPanelOpen()) return;
|
||||
e.preventDefault();
|
||||
closeConsentPanel();
|
||||
});
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bind, { once: true });
|
||||
} else {
|
||||
bind();
|
||||
}
|
||||
}
|
||||
56
src/scripts/nx-nav-spy.ts
Normal file
56
src/scripts/nx-nav-spy.ts
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Home: evidenzia in bianco il link del menu che corrisponde alla sezione in vista.
|
||||
*/
|
||||
const SECTION_IDS = ['prodotti', 'servizi', 'stack', 'faq', 'contatti'] as const;
|
||||
const HEADER_OFFSET = 88;
|
||||
|
||||
function getActiveSectionId(): string | null {
|
||||
const y = window.scrollY + HEADER_OFFSET;
|
||||
let active: string | null = null;
|
||||
for (const id of SECTION_IDS) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) continue;
|
||||
if (el.offsetTop <= y) active = id;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function applyActive(): void {
|
||||
const active = getActiveSectionId();
|
||||
document.querySelectorAll<HTMLAnchorElement>('a[data-section]').forEach((a) => {
|
||||
const id = a.dataset.section;
|
||||
const on = Boolean(id && id === active);
|
||||
a.dataset.navActive = on ? 'true' : 'false';
|
||||
if (on) a.setAttribute('aria-current', 'true');
|
||||
else a.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
let scheduled = false;
|
||||
function onScroll(): void {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false;
|
||||
applyActive();
|
||||
});
|
||||
}
|
||||
|
||||
function bind(): void {
|
||||
const path = window.location.pathname;
|
||||
if (path !== '/' && path !== '/index.html') return;
|
||||
|
||||
applyActive();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onScroll, { passive: true });
|
||||
window.addEventListener('hashchange', applyActive);
|
||||
window.addEventListener('load', applyActive);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bind, { once: true });
|
||||
} else {
|
||||
bind();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user