556 lines
17 KiB
JavaScript
556 lines
17 KiB
JavaScript
/**
|
|
* PortfolioJournal Main JavaScript
|
|
* Handles dark mode, navigation, and dynamic content
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
function trackEvent(eventName, props) {
|
|
const params = Object.assign({}, props || {});
|
|
if (CONFIG.ANALYTICS_DEBUG) {
|
|
params.debug_mode = true; // Highlight in GA4 DebugView
|
|
try { console.debug('[analytics] event', eventName, params); } catch (_) {}
|
|
}
|
|
// GA4 (gtag) event
|
|
if (typeof window.gtag === 'function') {
|
|
try { window.gtag('event', eventName, params); } catch (_) {}
|
|
}
|
|
// Plausible (if present)
|
|
if (typeof window.plausible === 'function') {
|
|
try { window.plausible(eventName, { props: params }); } catch (_) {}
|
|
}
|
|
}
|
|
|
|
// Dark Mode Management
|
|
const DarkMode = {
|
|
STORAGE_KEY: 'pj-theme',
|
|
|
|
init() {
|
|
const saved = localStorage.getItem(this.STORAGE_KEY);
|
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
|
|
if (saved === 'dark' || (!saved && prefersDark)) {
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
} else {
|
|
document.documentElement.setAttribute('data-theme', 'light');
|
|
}
|
|
|
|
// Listen for system preference changes
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
if (!localStorage.getItem(this.STORAGE_KEY)) {
|
|
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
|
}
|
|
});
|
|
},
|
|
|
|
toggle() {
|
|
const current = document.documentElement.getAttribute('data-theme');
|
|
const next = current === 'dark' ? 'light' : 'dark';
|
|
document.documentElement.setAttribute('data-theme', next);
|
|
localStorage.setItem(this.STORAGE_KEY, next);
|
|
this.updateToggleButton();
|
|
},
|
|
|
|
updateToggleButton() {
|
|
const btn = document.querySelector('.theme-toggle');
|
|
if (btn) {
|
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
|
btn.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode');
|
|
btn.innerHTML = isDark ? this.getSunIcon() : this.getMoonIcon();
|
|
}
|
|
},
|
|
|
|
getSunIcon() {
|
|
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>`;
|
|
},
|
|
|
|
getMoonIcon() {
|
|
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`;
|
|
}
|
|
};
|
|
|
|
// Google Analytics (GA4) Loader — gated by cookie consent
|
|
const AnalyticsLoader = {
|
|
loaded: false,
|
|
init() {
|
|
if (!CONFIG.ENABLE_ANALYTICS) return;
|
|
try {
|
|
const consent = localStorage.getItem('pj-cookies-accepted');
|
|
if (consent === 'true') {
|
|
this.load();
|
|
}
|
|
} catch (err) {
|
|
// ignore storage errors
|
|
}
|
|
},
|
|
load() {
|
|
if (this.loaded) return;
|
|
if (!CONFIG.ANALYTICS_ID) return;
|
|
this.loaded = true;
|
|
|
|
// Define dataLayer and gtag shim before loading script
|
|
window.dataLayer = window.dataLayer || [];
|
|
function gtag(){ window.dataLayer.push(arguments); }
|
|
window.gtag = gtag;
|
|
|
|
gtag('js', new Date());
|
|
// Consent granted for analytics after explicit acceptance
|
|
gtag('consent', 'update', {
|
|
ad_storage: 'denied',
|
|
analytics_storage: 'granted',
|
|
functionality_storage: 'granted',
|
|
security_storage: 'granted'
|
|
});
|
|
gtag('config', CONFIG.ANALYTICS_ID, {
|
|
anonymize_ip: true,
|
|
send_page_view: false
|
|
});
|
|
|
|
const s = document.createElement('script');
|
|
s.async = true;
|
|
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(CONFIG.ANALYTICS_ID);
|
|
document.head.appendChild(s);
|
|
// Fire a page_view once available
|
|
if (document.readyState === 'complete') {
|
|
this.pageView();
|
|
} else {
|
|
window.addEventListener('load', () => this.pageView(), { once: true });
|
|
}
|
|
},
|
|
pageView() {
|
|
if (!this.loaded || typeof window.gtag !== 'function') return;
|
|
const params = {
|
|
page_title: document.title,
|
|
page_location: window.location.href,
|
|
page_path: window.location.pathname
|
|
};
|
|
if (CONFIG.ANALYTICS_DEBUG) params.debug_mode = true;
|
|
try {
|
|
window.gtag('event', 'page_view', params);
|
|
if (CONFIG.ANALYTICS_DEBUG) console.debug('[analytics] page_view', params);
|
|
} catch (_) {}
|
|
}
|
|
};
|
|
|
|
// Navigation Management
|
|
const Navigation = {
|
|
init() {
|
|
this.setActiveLink();
|
|
this.setupMobileMenu();
|
|
this.setupKeyboardNav();
|
|
},
|
|
|
|
setActiveLink() {
|
|
const currentPath = window.location.pathname;
|
|
const links = document.querySelectorAll('.nav-link');
|
|
|
|
links.forEach(link => {
|
|
const href = link.getAttribute('href');
|
|
const isActive = currentPath.endsWith(href) ||
|
|
(href === 'index.html' && (currentPath === '/' || currentPath.endsWith('/')));
|
|
|
|
if (isActive) {
|
|
link.classList.add('active');
|
|
link.setAttribute('aria-current', 'page');
|
|
}
|
|
});
|
|
},
|
|
|
|
setupMobileMenu() {
|
|
const toggle = document.querySelector('.mobile-menu-toggle');
|
|
const nav = document.querySelector('.nav-links');
|
|
|
|
if (toggle && nav) {
|
|
toggle.addEventListener('click', () => {
|
|
const isOpen = nav.classList.toggle('open');
|
|
toggle.setAttribute('aria-expanded', isOpen);
|
|
toggle.innerHTML = isOpen ? this.getCloseIcon() : this.getMenuIcon();
|
|
});
|
|
|
|
// Close menu on link click
|
|
nav.querySelectorAll('a').forEach(link => {
|
|
link.addEventListener('click', () => {
|
|
nav.classList.remove('open');
|
|
toggle.setAttribute('aria-expanded', 'false');
|
|
toggle.innerHTML = this.getMenuIcon();
|
|
});
|
|
});
|
|
}
|
|
},
|
|
|
|
setupKeyboardNav() {
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
const nav = document.querySelector('.nav-links');
|
|
const toggle = document.querySelector('.mobile-menu-toggle');
|
|
if (nav && nav.classList.contains('open')) {
|
|
nav.classList.remove('open');
|
|
toggle.setAttribute('aria-expanded', 'false');
|
|
toggle.innerHTML = this.getMenuIcon();
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
getMenuIcon() {
|
|
return `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>`;
|
|
},
|
|
|
|
getCloseIcon() {
|
|
return `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>`;
|
|
}
|
|
};
|
|
|
|
// Dynamic Content
|
|
const Content = {
|
|
init() {
|
|
this.insertYear();
|
|
this.insertConfig();
|
|
this.insertEffectiveDate();
|
|
},
|
|
|
|
insertYear() {
|
|
document.querySelectorAll('[data-year]').forEach(el => {
|
|
el.textContent = CONFIG.COPYRIGHT_YEAR;
|
|
});
|
|
},
|
|
|
|
insertConfig() {
|
|
document.querySelectorAll('[data-config]').forEach(el => {
|
|
const key = el.getAttribute('data-config');
|
|
if (CONFIG[key]) {
|
|
if (el.tagName === 'A') {
|
|
el.href = CONFIG[key];
|
|
} else {
|
|
el.textContent = CONFIG[key];
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
insertEffectiveDate() {
|
|
document.querySelectorAll('[data-effective-date]').forEach(el => {
|
|
const dateValue = CONFIG.EFFECTIVE_DATE === 'today' ? new Date() : new Date(CONFIG.EFFECTIVE_DATE);
|
|
const date = Number.isNaN(dateValue.getTime()) ? new Date() : dateValue;
|
|
el.textContent = date.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
const ImageOptimization = {
|
|
init() {
|
|
document.querySelectorAll('img').forEach(img => {
|
|
if (img.closest('.hero')) return;
|
|
if (!img.hasAttribute('loading')) {
|
|
img.setAttribute('loading', 'lazy');
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// Smooth Scroll
|
|
const SmoothScroll = {
|
|
init() {
|
|
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
|
anchor.addEventListener('click', (e) => {
|
|
const targetId = anchor.getAttribute('href');
|
|
if (targetId === '#') return;
|
|
|
|
const target = document.querySelector(targetId);
|
|
if (target) {
|
|
e.preventDefault();
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
target.focus({ preventScroll: true });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
// A/B Test: Hero Variants
|
|
const HeroABTest = {
|
|
STORAGE_KEY: 'pj-hero-variant',
|
|
|
|
init() {
|
|
if (!document.querySelector('.hero-variant-a-only, .hero-variant-b-only')) {
|
|
return;
|
|
}
|
|
let variant = 'a';
|
|
try {
|
|
variant = localStorage.getItem(this.STORAGE_KEY);
|
|
if (!variant) {
|
|
variant = Math.random() < 0.5 ? 'a' : 'b';
|
|
localStorage.setItem(this.STORAGE_KEY, variant);
|
|
}
|
|
} catch (err) {
|
|
variant = 'a';
|
|
}
|
|
|
|
document.body.classList.remove('hero-variant-a', 'hero-variant-b');
|
|
document.body.classList.add(`hero-variant-${variant}`);
|
|
trackEvent('AB Test', { variant });
|
|
}
|
|
};
|
|
|
|
// Analytics Events
|
|
const Analytics = {
|
|
init() {
|
|
this.trackAppStoreClicks();
|
|
this.trackScrollDepth();
|
|
this.trackFooterLinks();
|
|
},
|
|
|
|
trackAppStoreClicks() {
|
|
document.querySelectorAll('a[href*="apps.apple.com"]').forEach(link => {
|
|
link.addEventListener('click', () => {
|
|
let campaign = 'unknown';
|
|
try {
|
|
const url = new URL(link.href);
|
|
campaign = url.searchParams.get('ct') || 'unknown';
|
|
} catch (err) {
|
|
campaign = 'unknown';
|
|
}
|
|
trackEvent('AppStore Click', { location: campaign });
|
|
});
|
|
});
|
|
},
|
|
|
|
trackScrollDepth() {
|
|
const scrollDepths = [25, 50, 75, 100];
|
|
const tracked = new Set();
|
|
|
|
window.addEventListener('scroll', () => {
|
|
const scrollPercent = ((window.scrollY + window.innerHeight) / document.body.offsetHeight) * 100;
|
|
|
|
scrollDepths.forEach(depth => {
|
|
if (scrollPercent >= depth && !tracked.has(depth)) {
|
|
tracked.add(depth);
|
|
trackEvent('Deep Scroll', { depth: `${depth}%` });
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
trackFooterLinks() {
|
|
document.querySelectorAll('footer a').forEach(link => {
|
|
link.addEventListener('click', () => {
|
|
const destination = link.textContent.trim() || link.getAttribute('href');
|
|
trackEvent('Footer Link Click', { destination });
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
const ExitIntent = {
|
|
STORAGE_KEY: 'pj-exit-intent-shown',
|
|
|
|
init() {
|
|
this.modal = document.getElementById('exit-intent-modal');
|
|
if (!this.modal) return;
|
|
|
|
const closeBtn = this.modal.querySelector('[data-exit-close]');
|
|
if (closeBtn) {
|
|
closeBtn.addEventListener('click', () => this.close());
|
|
}
|
|
|
|
this.modal.addEventListener('click', (event) => {
|
|
if (event.target === this.modal) {
|
|
this.close();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mouseleave', (event) => {
|
|
if (event.clientY < 0) {
|
|
this.show();
|
|
}
|
|
});
|
|
},
|
|
|
|
show() {
|
|
try {
|
|
if (localStorage.getItem(this.STORAGE_KEY)) return;
|
|
} catch (err) {
|
|
return;
|
|
}
|
|
this.modal.classList.add('is-visible');
|
|
this.modal.setAttribute('aria-hidden', 'false');
|
|
try {
|
|
localStorage.setItem(this.STORAGE_KEY, 'true');
|
|
} catch (err) {
|
|
// ignore storage errors
|
|
}
|
|
trackEvent('Exit Intent', { action: 'shown' });
|
|
},
|
|
|
|
close() {
|
|
this.modal.classList.remove('is-visible');
|
|
this.modal.setAttribute('aria-hidden', 'true');
|
|
trackEvent('Exit Intent', { action: 'closed' });
|
|
}
|
|
};
|
|
|
|
const StickyCTA = {
|
|
init() {
|
|
this.el = document.getElementById('sticky-mobile-cta');
|
|
if (!this.el) return;
|
|
window.addEventListener('scroll', () => this.update());
|
|
this.update();
|
|
},
|
|
|
|
update() {
|
|
const scrollPercent = ((window.scrollY + window.innerHeight) / document.body.offsetHeight) * 100;
|
|
const isMobile = window.innerWidth <= 768;
|
|
const shouldShow = isMobile && scrollPercent > 50;
|
|
|
|
this.el.style.display = shouldShow ? 'block' : 'none';
|
|
this.el.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
|
|
}
|
|
};
|
|
|
|
const CTAActions = {
|
|
init() {
|
|
document.querySelectorAll('[data-cta="learn-more"]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const target = document.querySelector('.features-section');
|
|
if (target) {
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
trackEvent('CTA Click', { action: 'learn_more' });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
// Cookie Notice (only if analytics enabled)
|
|
const CookieNotice = {
|
|
STORAGE_KEY: 'pj-cookies-accepted',
|
|
|
|
init() {
|
|
if (!CONFIG.ENABLE_ANALYTICS) return;
|
|
if (localStorage.getItem(this.STORAGE_KEY)) return;
|
|
|
|
this.show();
|
|
},
|
|
|
|
show() {
|
|
const notice = document.createElement('div');
|
|
notice.className = 'cookie-notice';
|
|
notice.innerHTML = `
|
|
<div class="cookie-notice-content">
|
|
<p>We use analytics to improve your experience. No personal data is shared with third parties.</p>
|
|
<div class="cookie-notice-actions">
|
|
<button class="btn btn-small btn-secondary" onclick="CookieNotice.decline()">Decline</button>
|
|
<button class="btn btn-small btn-primary" onclick="CookieNotice.accept()">Accept</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(notice);
|
|
},
|
|
|
|
accept() {
|
|
localStorage.setItem(this.STORAGE_KEY, 'true');
|
|
this.hide();
|
|
// Initialize GA4 after consent
|
|
if (typeof AnalyticsLoader !== 'undefined') {
|
|
AnalyticsLoader.load();
|
|
}
|
|
},
|
|
|
|
decline() {
|
|
localStorage.setItem(this.STORAGE_KEY, 'false');
|
|
this.hide();
|
|
},
|
|
|
|
hide() {
|
|
const notice = document.querySelector('.cookie-notice');
|
|
if (notice) notice.remove();
|
|
}
|
|
};
|
|
|
|
// FAQ Accordion
|
|
const FAQ = {
|
|
init() {
|
|
document.querySelectorAll('.faq-question').forEach(question => {
|
|
question.addEventListener('click', () => {
|
|
trackEvent('FAQ Click', { question: question.textContent.trim() });
|
|
const item = question.parentElement;
|
|
const isOpen = item.classList.contains('open');
|
|
|
|
// Close all others
|
|
document.querySelectorAll('.faq-item.open').forEach(openItem => {
|
|
openItem.classList.remove('open');
|
|
openItem.querySelector('.faq-question').setAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
// Toggle current
|
|
if (!isOpen) {
|
|
item.classList.add('open');
|
|
question.setAttribute('aria-expanded', 'true');
|
|
}
|
|
});
|
|
|
|
// Keyboard support
|
|
question.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
question.click();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
// Initialize everything on DOM ready
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Initialize analytics (if enabled and consented previously)
|
|
if (typeof AnalyticsLoader !== 'undefined') {
|
|
AnalyticsLoader.init();
|
|
}
|
|
// Track SPA-style navigation changes (pushState/replaceState/popstate)
|
|
(function setupSPAViews() {
|
|
const origPush = history.pushState;
|
|
const origReplace = history.replaceState;
|
|
function handleNav(){
|
|
// Minimal debounce
|
|
clearTimeout(handleNav._tid);
|
|
handleNav._tid = setTimeout(() => {
|
|
if (typeof AnalyticsLoader !== 'undefined') AnalyticsLoader.pageView();
|
|
}, 0);
|
|
}
|
|
history.pushState = function() { const r = origPush.apply(this, arguments); handleNav(); return r; };
|
|
history.replaceState = function() { const r = origReplace.apply(this, arguments); handleNav(); return r; };
|
|
window.addEventListener('popstate', handleNav);
|
|
})();
|
|
DarkMode.init();
|
|
DarkMode.updateToggleButton();
|
|
Navigation.init();
|
|
Content.init();
|
|
ImageOptimization.init();
|
|
SmoothScroll.init();
|
|
HeroABTest.init();
|
|
Analytics.init();
|
|
FAQ.init();
|
|
CookieNotice.init();
|
|
ExitIntent.init();
|
|
StickyCTA.init();
|
|
CTAActions.init();
|
|
|
|
// Setup theme toggle button
|
|
const themeToggle = document.querySelector('.theme-toggle');
|
|
if (themeToggle) {
|
|
themeToggle.addEventListener('click', () => DarkMode.toggle());
|
|
}
|
|
});
|
|
|
|
// Expose necessary functions globally
|
|
window.DarkMode = DarkMode;
|
|
window.CookieNotice = CookieNotice;
|
|
window.trackEvent = trackEvent;
|
|
window.AnalyticsLoader = AnalyticsLoader;
|
|
})();
|