diff --git a/api/visit-counter.php b/api/visit-counter.php new file mode 100644 index 0000000..574ef47 --- /dev/null +++ b/api/visit-counter.php @@ -0,0 +1,49 @@ + true, + 'counts' => visit_counter_forget($token), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + $tracked = visit_counter_track($token); + echo json_encode([ + 'success' => true, + 'token' => $tracked['token'], + 'counts' => $tracked['counts'], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + echo json_encode([ + 'success' => true, + 'counts' => visit_counter_snapshot(), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} catch (Throwable $exception) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => 'visit_counter_unavailable', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} diff --git a/assets/css/custom.css b/assets/css/custom.css index 28e199c..1346868 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -35,6 +35,10 @@ body { line-height: 1.65; } +body.cookie-consent-locked { + overflow: hidden; +} + a { color: var(--text); text-decoration-color: rgba(17, 24, 39, 0.35); @@ -312,6 +316,30 @@ a:focus-visible { .stat-grid span { color: var(--muted); +} + +.stat-grid--counter div:first-child { + background: linear-gradient(135deg, #111827 0%, #1f2937 100%); + border-color: transparent; +} + +.stat-grid--counter div:first-child strong, +.stat-grid--counter div:first-child span { + color: #fff; +} + +.stat-grid--counter strong { + font-size: clamp(1.2rem, 2vw, 1.45rem); +} + +.panel-note { + margin-top: 0.85rem; + font-size: 0.84rem; + color: var(--muted); +} + +.visit-counter-panel .panel-copy { + min-height: 3.5rem; font-size: 0.84rem; } @@ -642,12 +670,22 @@ a:focus-visible { background: #dff0d8; } +.cookie-overlay { + position: fixed; + inset: 0; + z-index: 1085; + background: rgba(17, 24, 39, 0.58); + backdrop-filter: blur(6px); +} + .cookie-banner { position: fixed; right: 1rem; bottom: 1rem; - z-index: 1045; + z-index: 1095; width: min(480px, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + overflow-y: auto; padding: 1rem; border-radius: 20px; border: 1px solid var(--line-strong); @@ -655,6 +693,15 @@ a:focus-visible { box-shadow: var(--shadow-md); } +.cookie-banner--modal { + top: 50%; + right: auto; + bottom: auto; + left: 50%; + width: min(520px, calc(100vw - 2rem)); + transform: translate(-50%, -50%); +} + .cookie-banner[hidden] { display: none !important; } @@ -819,6 +866,16 @@ a:focus-visible { bottom: 0.5rem; } + .cookie-banner.cookie-banner--modal { + top: 50%; + right: auto; + bottom: auto; + left: 50%; + width: calc(100vw - 1rem); + max-height: calc(100vh - 1rem); + transform: translate(-50%, -50%); + } + .cookie-floating { left: 0.5rem; bottom: 0.5rem; diff --git a/assets/js/main.js b/assets/js/main.js index fe96fb5..8cfa84c 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,13 +1,16 @@ document.addEventListener('DOMContentLoaded', () => { const COOKIE_NAME = 'ptcs_consent'; + const VISIT_COOKIE_NAME = 'ptcs_visit_id'; const COOKIE_MAX_AGE = 60 * 60 * 24 * 180; + const VISIT_REFRESH_MS = 30 * 1000; const STORAGE_KEYS = { - personalization: 'ptcs_ui_preferences', - audience: 'ptcs_local_audience' + personalization: 'ptcs_ui_preferences' }; const banner = document.getElementById('cookie-banner'); + const overlay = document.getElementById('cookie-overlay'); const reopenButton = document.getElementById('cookie-reopen'); + const floatingReminder = reopenButton ? reopenButton.closest('.cookie-floating') : null; const reopenLabel = document.getElementById('cookie-reopen-label'); const footerCookieSettings = document.getElementById('footer-cookie-settings'); const dismissScrollHint = document.getElementById('scroll-hint-dismiss'); @@ -16,7 +19,18 @@ document.addEventListener('DOMContentLoaded', () => { const personalizationControls = Array.from(document.querySelectorAll('[data-consent-control="personalization"]')); const audienceControls = Array.from(document.querySelectorAll('[data-consent-control="audience"]')); const consentActionButtons = Array.from(document.querySelectorAll('[data-cookie-action]')); - const trackedSections = Array.from(document.querySelectorAll('[data-track-section]')); + const isHomePage = document.body?.dataset.page === 'home'; + const bodyStartsLocked = document.body?.dataset.cookieLock === 'pending'; + + const visitCounterRoot = document.querySelector('[data-visit-counter-endpoint]'); + const visitCounterEndpoint = visitCounterRoot?.dataset.visitCounterEndpoint || '/api/visit-counter.php'; + const liveWindowMinutes = Number(visitCounterRoot?.dataset.liveWindowMinutes || 5); + const visitStatusNodes = Array.from(document.querySelectorAll('[data-visit-status]')); + const visitLiveNodes = Array.from(document.querySelectorAll('[data-visit-live]')); + const visitDailyNodes = Array.from(document.querySelectorAll('[data-visit-daily]')); + const visitTotalNodes = Array.from(document.querySelectorAll('[data-visit-total]')); + const visitUpdatedNodes = Array.from(document.querySelectorAll('[data-visit-updated]')); + const numberFormatter = new Intl.NumberFormat('fr-FR'); const state = { prefs: { @@ -25,8 +39,16 @@ document.addEventListener('DOMContentLoaded', () => { audience: false, timestamp: null }, - audienceCountedThisSession: false, - visibleSections: new Set() + hasSavedDecision: false, + requiresBlockingChoice: bodyStartsLocked, + visitCounts: visitCounterRoot ? { + live: Number(visitCounterRoot.dataset.initialLive || 0), + daily: Number(visitCounterRoot.dataset.initialDaily || 0), + total: Number(visitCounterRoot.dataset.initialTotal || 0), + updated_at: visitCounterRoot.dataset.updatedAt || null, + updated_label: visitCounterRoot.dataset.updatedLabel || '' + } : null, + visitRefreshTimer: null }; function safeParse(rawValue) { @@ -79,6 +101,10 @@ document.addEventListener('DOMContentLoaded', () => { document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax`; } + function deleteCookie(name) { + document.cookie = `${name}=; path=/; max-age=0; SameSite=Lax`; + } + function normalizePrefs(rawPrefs = {}) { return { essential: true, @@ -106,7 +132,12 @@ document.addEventListener('DOMContentLoaded', () => { }); } - function formatDate(dateString) { + function formatNumber(value) { + const numericValue = Number(value); + return numberFormatter.format(Number.isFinite(numericValue) ? numericValue : 0); + } + + function formatTime(dateString) { if (!dateString) { return '—'; } @@ -115,15 +146,22 @@ document.addEventListener('DOMContentLoaded', () => { return '—'; } return new Intl.DateTimeFormat('fr-FR', { - dateStyle: 'short', - timeStyle: 'short' + hour: '2-digit', + minute: '2-digit', + second: '2-digit' }).format(date); } function updateSummaryText() { - const summary = state.prefs.personalization || state.prefs.audience - ? `Réglages actifs : essentiels, ${state.prefs.personalization ? 'personnalisation' : 'personnalisation coupée'} et ${state.prefs.audience ? 'mesure locale activée' : 'mesure locale désactivée'}.` - : 'Le site conserve uniquement l\'essentiel tant que vous n\'avez pas choisi d\'options supplémentaires.'; + let summary = "Le site conserve uniquement l'essentiel tant que vous n'avez pas choisi d'options supplémentaires."; + + if (state.prefs.personalization && state.prefs.audience) { + summary = 'Réglages actifs : essentiels, personnalisation et compteur visiteurs anonymisé.'; + } else if (state.prefs.personalization) { + summary = 'Réglages actifs : essentiels et personnalisation. Le compteur visiteurs reste inactif.'; + } else if (state.prefs.audience) { + summary = "Réglages actifs : essentiels et compteur visiteurs anonymisé. Les préférences d'interface restent minimales."; + } document.querySelectorAll('[data-consent-summary]').forEach((node) => { node.textContent = summary; @@ -148,27 +186,68 @@ document.addEventListener('DOMContentLoaded', () => { reopenLabel.textContent = 'Cookies : essentiels'; } - function updateAudiencePanel(store) { - const audienceEnabled = state.prefs.audience; - const visits = audienceEnabled && store ? Number(store.visits || 0) : 0; - const sections = audienceEnabled && store && Array.isArray(store.sections) ? store.sections.length : 0; - const lastVisit = audienceEnabled && store ? formatDate(store.lastVisit || null) : '—'; - const statusText = audienceEnabled - ? 'La mesure locale d\'audience est activée sur cet appareil. Les compteurs ci-dessous sont stockés uniquement dans votre navigateur.' - : 'La mesure locale d\'audience est désactivée. Aucun service tiers n\'est utilisé.'; + function focusBannerPrimaryAction() { + if (!banner) { + return; + } - document.querySelectorAll('[data-audience-status]').forEach((node) => { - node.textContent = statusText; - }); - document.querySelectorAll('[data-audience-visits]').forEach((node) => { - node.textContent = String(visits); - }); - document.querySelectorAll('[data-audience-sections]').forEach((node) => { - node.textContent = String(sections); - }); - document.querySelectorAll('[data-audience-last-visit]').forEach((node) => { - node.textContent = lastVisit; - }); + const primaryAction = banner.querySelector('[data-cookie-action="reject"]') + || banner.querySelector('[data-cookie-action="save"]') + || banner.querySelector('button, [href], input:not([disabled])'); + + if (primaryAction instanceof HTMLElement) { + primaryAction.focus({ preventScroll: true }); + return; + } + + if (banner instanceof HTMLElement) { + banner.focus({ preventScroll: true }); + } + } + + function syncConsentBarrier() { + const shouldLock = isHomePage && state.requiresBlockingChoice && !state.hasSavedDecision; + + document.body.classList.toggle('cookie-consent-locked', shouldLock); + document.body.dataset.cookieLock = shouldLock ? 'pending' : 'released'; + + if (overlay) { + overlay.hidden = !shouldLock; + } + + if (banner) { + banner.classList.toggle('cookie-banner--modal', shouldLock); + banner.setAttribute('aria-modal', shouldLock ? 'true' : 'false'); + } + + const shouldHideReminder = !state.hasSavedDecision && banner && !banner.hidden; + + if (floatingReminder) { + floatingReminder.hidden = shouldHideReminder; + } + + if (reopenButton) { + reopenButton.hidden = false; + if (shouldHideReminder) { + reopenButton.setAttribute('aria-hidden', 'true'); + } else { + reopenButton.removeAttribute('aria-hidden'); + } + } + } + + function refreshPersonalizationUI() { + const stored = safeParse(storageGet(STORAGE_KEYS.personalization)) || {}; + if (!scrollHint) { + return; + } + + if (!state.prefs.personalization) { + scrollHint.hidden = false; + return; + } + + scrollHint.hidden = stored.scrollHintDismissed === true; } function showToast(message) { @@ -194,65 +273,196 @@ document.addEventListener('DOMContentLoaded', () => { }, 3200); } - function readAudienceStore() { - const parsed = safeParse(storageGet(STORAGE_KEYS.audience)); - if (!parsed || typeof parsed !== 'object') { - return { - visits: 0, - sections: [], - lastVisit: null + function updateVisitCounter(snapshot = null) { + if (!visitCounterRoot) { + return; + } + + if (snapshot && typeof snapshot === 'object') { + state.visitCounts = { + live: Number(snapshot.live || 0), + daily: Number(snapshot.daily || 0), + total: Number(snapshot.total || 0), + updated_at: snapshot.updated_at || null, + updated_label: snapshot.updated_label || '' }; } - return { - visits: Number(parsed.visits || 0), - sections: Array.isArray(parsed.sections) ? parsed.sections : [], - lastVisit: parsed.lastVisit || null + + const counts = state.visitCounts || { + live: 0, + daily: 0, + total: 0, + updated_at: null, + updated_label: '' }; + + visitLiveNodes.forEach((node) => { + node.textContent = formatNumber(counts.live); + }); + visitDailyNodes.forEach((node) => { + node.textContent = formatNumber(counts.daily); + }); + visitTotalNodes.forEach((node) => { + node.textContent = formatNumber(counts.total); + }); + + let statusText = `Le compteur affiche l'audience mesurée sur le site. “En direct” correspond aux visiteurs actifs sur les ${liveWindowMinutes} dernières minutes.`; + if (!state.hasSavedDecision) { + statusText = "Le compteur est visible, mais votre visite ne sera comptée qu’après votre choix cookies."; + } else if (state.prefs.audience) { + statusText = "Le compteur anonyme est actif pour votre visite. Les chiffres se mettent à jour automatiquement sans service tiers."; + } else { + statusText = "Le compteur reste en lecture seule tant que la mesure locale d’audience est désactivée. Votre visite actuelle n’est pas ajoutée."; + } + + visitStatusNodes.forEach((node) => { + node.textContent = statusText; + }); + + const timeLabel = counts.updated_label || formatTime(counts.updated_at); + const updatedText = `Dernière mise à jour : ${timeLabel} · “En direct” = visiteurs actifs sur les ${liveWindowMinutes} dernières minutes.`; + visitUpdatedNodes.forEach((node) => { + node.textContent = updatedText; + }); } - function saveAudienceStore(store) { - storageSet(STORAGE_KEYS.audience, JSON.stringify(store)); + async function requestVisitCounter(method = 'GET', payload = null) { + if (!visitCounterRoot) { + return null; + } + + const options = { + method, + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + credentials: 'same-origin', + cache: 'no-store' + }; + + if (payload !== null) { + options.headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify(payload); + } + + try { + const response = await fetch(visitCounterEndpoint, options); + if (!response.ok) { + return null; + } + const data = await response.json(); + return data && data.success ? data : null; + } catch (error) { + return null; + } } - function refreshPersonalizationUI() { - const stored = safeParse(storageGet(STORAGE_KEYS.personalization)) || {}; - if (!scrollHint) { + function clearVisitRefreshTimer() { + if (state.visitRefreshTimer) { + window.clearInterval(state.visitRefreshTimer); + state.visitRefreshTimer = null; + } + } + + function startVisitRefreshTimer() { + if (!visitCounterRoot || state.visitRefreshTimer) { return; } - if (!state.prefs.personalization) { - scrollHint.hidden = false; - return; - } + state.visitRefreshTimer = window.setInterval(() => { + if (document.visibilityState === 'hidden') { + return; + } - scrollHint.hidden = stored.scrollHintDismissed === true; + if (state.prefs.audience) { + void pingVisitCounter(); + return; + } + + void refreshVisitSnapshot(); + }, VISIT_REFRESH_MS); } - function applyAudienceState() { - if (!state.prefs.audience) { - storageRemove(STORAGE_KEYS.audience); - state.audienceCountedThisSession = false; - updateAudiencePanel(null); + async function refreshVisitSnapshot() { + const data = await requestVisitCounter('GET'); + if (data && data.counts) { + updateVisitCounter(data.counts); + return; + } + updateVisitCounter(); + } + + async function pingVisitCounter() { + const token = getCookie(VISIT_COOKIE_NAME); + const data = await requestVisitCounter('POST', { + action: 'ping', + token + }); + + if (data?.token) { + setCookie(VISIT_COOKIE_NAME, data.token, COOKIE_MAX_AGE); + } + + if (data?.counts) { + updateVisitCounter(data.counts); return; } - const store = readAudienceStore(); - if (!state.audienceCountedThisSession) { - store.visits += 1; - store.lastVisit = new Date().toISOString(); - state.audienceCountedThisSession = true; + updateVisitCounter(); + } + + async function forgetVisitCounter() { + const token = getCookie(VISIT_COOKIE_NAME); + deleteCookie(VISIT_COOKIE_NAME); + + if (!token) { + await refreshVisitSnapshot(); + return; } - if (state.visibleSections.size > 0) { - const merged = new Set([...(store.sections || []), ...state.visibleSections]); - store.sections = Array.from(merged); + const data = await requestVisitCounter('POST', { + action: 'forget', + token + }); + + if (data?.counts) { + updateVisitCounter(data.counts); + return; } - saveAudienceStore(store); - updateAudiencePanel(store); + await refreshVisitSnapshot(); + } + + function syncVisitCounter(previousAudienceEnabled = false) { + if (!visitCounterRoot) { + return; + } + + clearVisitRefreshTimer(); + + if (state.prefs.audience) { + void pingVisitCounter().finally(() => { + startVisitRefreshTimer(); + }); + return; + } + + if (previousAudienceEnabled) { + void forgetVisitCounter().finally(() => { + startVisitRefreshTimer(); + }); + return; + } + + deleteCookie(VISIT_COOKIE_NAME); + void refreshVisitSnapshot().finally(() => { + startVisitRefreshTimer(); + }); } function applyPrefs(prefs) { + const previousAudienceEnabled = Boolean(state.prefs.audience); state.prefs = normalizePrefs(prefs); document.body.dataset.consentPersonalization = state.prefs.personalization ? 'on' : 'off'; document.body.dataset.consentAudience = state.prefs.audience ? 'on' : 'off'; @@ -269,9 +479,10 @@ document.addEventListener('DOMContentLoaded', () => { } refreshPersonalizationUI(); - applyAudienceState(); updateSummaryText(); updateReminderLabel(); + updateVisitCounter(); + syncVisitCounter(previousAudienceEnabled); } function openBanner() { @@ -279,9 +490,11 @@ document.addEventListener('DOMContentLoaded', () => { return; } banner.hidden = false; + syncConsentBarrier(); if (reopenButton) { reopenButton.setAttribute('aria-expanded', 'true'); } + focusBannerPrimaryAction(); } function closeBanner() { @@ -289,6 +502,7 @@ document.addEventListener('DOMContentLoaded', () => { return; } banner.hidden = true; + syncConsentBarrier(); if (reopenButton) { reopenButton.setAttribute('aria-expanded', 'false'); } @@ -298,6 +512,8 @@ document.addEventListener('DOMContentLoaded', () => { const normalized = normalizePrefs(nextPrefs); normalized.timestamp = new Date().toISOString(); setCookie(COOKIE_NAME, JSON.stringify(normalized), COOKIE_MAX_AGE); + state.hasSavedDecision = true; + state.requiresBlockingChoice = false; applyPrefs(normalized); closeBanner(); showToast(notice); @@ -357,36 +573,23 @@ document.addEventListener('DOMContentLoaded', () => { }); }); - if ('IntersectionObserver' in window && trackedSections.length > 0) { - const observer = new IntersectionObserver((entries) => { - let changed = false; - entries.forEach((entry) => { - if (!entry.isIntersecting) { - return; - } + document.addEventListener('visibilitychange', () => { + if (!visitCounterRoot || document.visibilityState !== 'visible') { + return; + } - const id = entry.target.id || entry.target.dataset.trackSection || ''; - if (!id) { - return; - } + if (state.prefs.audience) { + void pingVisitCounter(); + return; + } - if (!state.visibleSections.has(id)) { - state.visibleSections.add(id); - changed = true; - } - }); - - if (changed && state.prefs.audience) { - applyAudienceState(); - } - }, { - threshold: 0.45 - }); - - trackedSections.forEach((section) => observer.observe(section)); - } + void refreshVisitSnapshot(); + }); const savedPrefs = readSavedPrefs(); + state.hasSavedDecision = Boolean(savedPrefs); + state.requiresBlockingChoice = isHomePage && !savedPrefs; + if (savedPrefs) { applyPrefs(savedPrefs); closeBanner(); diff --git a/db/migrations/20260502_create_visit_counter_tables.sql b/db/migrations/20260502_create_visit_counter_tables.sql new file mode 100644 index 0000000..75a6557 --- /dev/null +++ b/db/migrations/20260502_create_visit_counter_tables.sql @@ -0,0 +1,27 @@ +-- 2026-05-02: compteur visiteurs live / journalier / total +-- Tables first-party pour mesurer anonymement les visites après consentement audience. + +CREATE TABLE IF NOT EXISTS visit_counter_sessions ( + visit_token CHAR(48) NOT NULL, + first_seen_at DATETIME NOT NULL, + last_seen_at DATETIME NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (visit_token), + KEY idx_last_seen_at (last_seen_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS visit_counter_daily ( + visit_token CHAR(48) NOT NULL, + visit_date DATE NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (visit_token, visit_date), + KEY idx_visit_date (visit_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS visit_counter_meta ( + meta_key VARCHAR(64) NOT NULL, + meta_value BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL, + PRIMARY KEY (meta_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/db/visit_counter.php b/db/visit_counter.php new file mode 100644 index 0000000..ab2bd4e --- /dev/null +++ b/db/visit_counter.php @@ -0,0 +1,278 @@ +exec( + 'CREATE TABLE IF NOT EXISTS visit_counter_sessions (' + . ' visit_token CHAR(48) NOT NULL,' + . ' first_seen_at DATETIME NOT NULL,' + . ' last_seen_at DATETIME NOT NULL,' + . ' created_at DATETIME NOT NULL,' + . ' updated_at DATETIME NOT NULL,' + . ' PRIMARY KEY (visit_token),' + . ' KEY idx_last_seen_at (last_seen_at)' + . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + + $pdo->exec( + 'CREATE TABLE IF NOT EXISTS visit_counter_daily (' + . ' visit_token CHAR(48) NOT NULL,' + . ' visit_date DATE NOT NULL,' + . ' created_at DATETIME NOT NULL,' + . ' PRIMARY KEY (visit_token, visit_date),' + . ' KEY idx_visit_date (visit_date)' + . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + + $pdo->exec( + 'CREATE TABLE IF NOT EXISTS visit_counter_meta (' + . ' meta_key VARCHAR(64) NOT NULL,' + . ' meta_value BIGINT UNSIGNED NOT NULL DEFAULT 0,' + . ' updated_at DATETIME NOT NULL,' + . ' PRIMARY KEY (meta_key)' + . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + + $initialized = true; +} + +function visit_counter_prune_stale_data(?DateTimeImmutable $now = null): void +{ + static $pruned = false; + if ($pruned) { + return; + } + + visit_counter_ensure_schema(); + $pdo = db(); + $now = $now ?? visit_counter_now(); + + $sessionCutoff = $now->modify('-' . VISIT_COUNTER_RETENTION_MONTHS . ' months')->format('Y-m-d H:i:s'); + $dailyCutoff = $now->modify('-' . VISIT_COUNTER_RETENTION_MONTHS . ' months')->format('Y-m-d'); + + $deleteDaily = $pdo->prepare('DELETE FROM visit_counter_daily WHERE visit_date < :cutoff'); + $deleteDaily->bindValue(':cutoff', $dailyCutoff); + $deleteDaily->execute(); + + $deleteSessions = $pdo->prepare('DELETE FROM visit_counter_sessions WHERE last_seen_at < :cutoff'); + $deleteSessions->bindValue(':cutoff', $sessionCutoff); + $deleteSessions->execute(); + + $pruned = true; +} + +function visit_counter_snapshot(?DateTimeImmutable $now = null): array +{ + visit_counter_ensure_schema(); + visit_counter_prune_stale_data($now); + + $pdo = db(); + $now = $now ?? visit_counter_now(); + $threshold = $now->modify('-' . VISIT_COUNTER_LIVE_WINDOW_MINUTES . ' minutes')->format('Y-m-d H:i:s'); + $today = $now->format('Y-m-d'); + + $liveStmt = $pdo->prepare('SELECT COUNT(*) FROM visit_counter_sessions WHERE last_seen_at >= :threshold'); + $liveStmt->bindValue(':threshold', $threshold); + $liveStmt->execute(); + $live = (int) $liveStmt->fetchColumn(); + + $dailyStmt = $pdo->prepare('SELECT COUNT(*) FROM visit_counter_daily WHERE visit_date = :visit_date'); + $dailyStmt->bindValue(':visit_date', $today); + $dailyStmt->execute(); + $daily = (int) $dailyStmt->fetchColumn(); + + $totalStmt = $pdo->prepare('SELECT meta_value FROM visit_counter_meta WHERE meta_key = :meta_key'); + $totalStmt->bindValue(':meta_key', VISIT_COUNTER_TOTAL_KEY); + $totalStmt->execute(); + $totalValue = $totalStmt->fetchColumn(); + $total = $totalValue === false ? 0 : (int) $totalValue; + + if ($total === 0) { + $fallbackStmt = $pdo->query('SELECT COUNT(*) FROM visit_counter_sessions'); + $total = (int) $fallbackStmt->fetchColumn(); + } + + return [ + 'live' => $live, + 'daily' => $daily, + 'total' => $total, + 'live_window_minutes' => VISIT_COUNTER_LIVE_WINDOW_MINUTES, + 'updated_at' => $now->format(DATE_ATOM), + 'updated_label' => $now->format('H:i:s'), + ]; +} + +function visit_counter_increment_total(string $timestamp): void +{ + $stmt = db()->prepare( + 'INSERT INTO visit_counter_meta (meta_key, meta_value, updated_at) VALUES (:meta_key, :meta_value, :updated_at) ' + . 'ON DUPLICATE KEY UPDATE meta_value = meta_value + 1, updated_at = :updated_at_refresh' + ); + $stmt->bindValue(':meta_key', VISIT_COUNTER_TOTAL_KEY); + $stmt->bindValue(':meta_value', 1, PDO::PARAM_INT); + $stmt->bindValue(':updated_at', $timestamp); + $stmt->bindValue(':updated_at_refresh', $timestamp); + $stmt->execute(); +} + +function visit_counter_decrement_total(string $timestamp): void +{ + $stmt = db()->prepare( + 'INSERT INTO visit_counter_meta (meta_key, meta_value, updated_at) VALUES (:meta_key, :meta_value, :updated_at) ' + . 'ON DUPLICATE KEY UPDATE meta_value = CASE WHEN meta_value > 0 THEN meta_value - 1 ELSE 0 END, updated_at = :updated_at_refresh' + ); + $stmt->bindValue(':meta_key', VISIT_COUNTER_TOTAL_KEY); + $stmt->bindValue(':meta_value', 0, PDO::PARAM_INT); + $stmt->bindValue(':updated_at', $timestamp); + $stmt->bindValue(':updated_at_refresh', $timestamp); + $stmt->execute(); +} + +function visit_counter_track(?string $token = null): array +{ + visit_counter_ensure_schema(); + $pdo = db(); + $now = visit_counter_now(); + visit_counter_prune_stale_data($now); + + $visitToken = visit_counter_normalize_token($token) ?? visit_counter_generate_token(); + $timestamp = $now->format('Y-m-d H:i:s'); + $visitDate = $now->format('Y-m-d'); + + $pdo->beginTransaction(); + try { + $insertSession = $pdo->prepare( + 'INSERT IGNORE INTO visit_counter_sessions (visit_token, first_seen_at, last_seen_at, created_at, updated_at) ' + . 'VALUES (:visit_token, :first_seen_at, :last_seen_at, :created_at, :updated_at)' + ); + $insertSession->bindValue(':visit_token', $visitToken); + $insertSession->bindValue(':first_seen_at', $timestamp); + $insertSession->bindValue(':last_seen_at', $timestamp); + $insertSession->bindValue(':created_at', $timestamp); + $insertSession->bindValue(':updated_at', $timestamp); + $insertSession->execute(); + + $isNewVisitor = $insertSession->rowCount() === 1; + + if (!$isNewVisitor) { + $updateSession = $pdo->prepare( + 'UPDATE visit_counter_sessions SET last_seen_at = :last_seen_at, updated_at = :updated_at WHERE visit_token = :visit_token' + ); + $updateSession->bindValue(':last_seen_at', $timestamp); + $updateSession->bindValue(':updated_at', $timestamp); + $updateSession->bindValue(':visit_token', $visitToken); + $updateSession->execute(); + } + + $insertDaily = $pdo->prepare( + 'INSERT IGNORE INTO visit_counter_daily (visit_token, visit_date, created_at) VALUES (:visit_token, :visit_date, :created_at)' + ); + $insertDaily->bindValue(':visit_token', $visitToken); + $insertDaily->bindValue(':visit_date', $visitDate); + $insertDaily->bindValue(':created_at', $timestamp); + $insertDaily->execute(); + + if ($isNewVisitor) { + visit_counter_increment_total($timestamp); + } + + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $exception; + } + + return [ + 'token' => $visitToken, + 'counts' => visit_counter_snapshot($now), + ]; +} + +function visit_counter_forget(?string $token = null): array +{ + visit_counter_ensure_schema(); + $pdo = db(); + $now = visit_counter_now(); + visit_counter_prune_stale_data($now); + + $visitToken = visit_counter_normalize_token($token); + if ($visitToken === null) { + return visit_counter_snapshot($now); + } + + $timestamp = $now->format('Y-m-d H:i:s'); + + $pdo->beginTransaction(); + try { + $deleteDaily = $pdo->prepare('DELETE FROM visit_counter_daily WHERE visit_token = :visit_token'); + $deleteDaily->bindValue(':visit_token', $visitToken); + $deleteDaily->execute(); + + $deleteSession = $pdo->prepare('DELETE FROM visit_counter_sessions WHERE visit_token = :visit_token'); + $deleteSession->bindValue(':visit_token', $visitToken); + $deleteSession->execute(); + + if ($deleteSession->rowCount() > 0) { + visit_counter_decrement_total($timestamp); + } + + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $exception; + } + + return visit_counter_snapshot($now); +} diff --git a/index.php b/index.php index ee4ec75..7fda577 100644 --- a/index.php +++ b/index.php @@ -3,10 +3,25 @@ declare(strict_types=1); require_once __DIR__ . '/site.php'; $site = site_settings(); -$pageTitle = 'Programme TV ce soir et en ce moment | ' . ($site['project_name'] !== '' ? $site['project_name'] : $site['domain']); -$fallbackDescription = 'Programme TV ce soir, direct en ce moment, chaînes TNT, films, séries, sport, documentaires et prime time : consultez rapidement ce qu\'il y a à la télé sur programmetelecesoir.fr.'; -$keywords = 'programme tv ce soir, programme télé ce soir, en ce moment, ce soir à la télé, programme tv tnt, film ce soir, série ce soir, match ce soir, émission ce soir, programme tf1, programme france 2, programme m6, programme arte, direct tv, grille tv'; +$pageTitle = 'Programme TV ce soir et en ce moment | ' . $site['domain']; +$fallbackDescription = 'Programme TV ce soir, direct en ce moment, chaînes TNT, films, séries, sport, documentaires et prime time : consultez rapidement ce qu\'il y a à la télé sur programmetelecesoir.net.'; +$keywords = 'programme tv ce soir, programme télé ce soir, en ce moment, ce soir à la télé, programme tv tnt, film ce soir, série ce soir, match ce soir, émission ce soir, programme tf1, programme france 2, programme m6, programme arte, direct tv, grille tv, programmetelecesoir.net'; $updatedAt = date('d/m/Y'); +$visitorStats = [ + 'live' => 0, + 'daily' => 0, + 'total' => 0, + 'live_window_minutes' => 5, + 'updated_at' => date(DATE_ATOM), + 'updated_label' => date('H:i:s'), +]; + +try { + require_once __DIR__ . '/db/visit_counter.php'; + $visitorStats = visit_counter_snapshot(); +} catch (Throwable $exception) { + // Le site doit rester browsable même si la base ou le compteur sont temporairement indisponibles. +} $quickLinks = [ [ @@ -75,8 +90,8 @@ $faqItems = [ 'answer' => 'La page couvre les grandes chaînes généralistes et TNT recherchées le plus souvent : TF1, France 2, France 3, M6, Arte, Canal+, France 5, TMC, W9, C8 et d’autres chaînes populaires.', ], [ - 'question' => 'Quels cookies sont utilisés sur programmetelecesoir.fr ?', - 'answer' => 'Cette première version dépose un cookie essentiel pour mémoriser vos choix de consentement. Les autres fonctions optionnelles sont désactivées par défaut et peuvent être activées ou refusées depuis la bannière ou le bouton flottant.', + 'question' => 'Quels cookies sont utilisés sur programmetelecesoir.net ?', + 'answer' => 'Le site dépose le cookie essentiel ptcs_consent pour mémoriser vos choix. Si vous activez la mesure locale d\'audience, le cookie optionnel ptcs_visit_id alimente le compteur de visiteurs en direct, journalier et total, sans service tiers publicitaire.', ], [ 'question' => 'Qui contacter pour les questions de confidentialité ?', @@ -103,7 +118,7 @@ $organizationSchema = [ '@context' => 'https://schema.org', '@type' => 'Organization', 'name' => $site['domain'], - 'url' => $site['base_url'] . '/', + 'url' => ($site['canonical_base_url'] ?? $site['base_url']) . '/', 'description' => $fallbackDescription, 'address' => [ '@type' => 'PostalAddress', @@ -120,13 +135,14 @@ $organizationSchema = [ 'availableLanguage' => ['fr'], ], ]; +$cookieConsentLock = site_should_lock_cookie_overlay(); ?>
- +