Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95ba206594 | ||
|
|
0f59065612 | ||
|
|
cd414e4566 | ||
|
|
b1894c71eb |
49
api/visit-counter.php
Normal file
49
api/visit-counter.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../db/visit_counter.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$rawBody = file_get_contents('php://input');
|
||||||
|
$payload = json_decode(is_string($rawBody) ? $rawBody : '', true);
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
$payload = $_POST;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = is_string($payload['action'] ?? null) ? $payload['action'] : 'ping';
|
||||||
|
$token = is_string($payload['token'] ?? null) ? $payload['token'] : null;
|
||||||
|
|
||||||
|
if ($action === 'forget') {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => 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);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,39 +1,600 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const chatForm = document.getElementById('chat-form');
|
const COOKIE_NAME = 'ptcs_consent';
|
||||||
const chatInput = document.getElementById('chat-input');
|
const VISIT_COOKIE_NAME = 'ptcs_visit_id';
|
||||||
const chatMessages = document.getElementById('chat-messages');
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 180;
|
||||||
|
const VISIT_REFRESH_MS = 30 * 1000;
|
||||||
const appendMessage = (text, sender) => {
|
const STORAGE_KEYS = {
|
||||||
const msgDiv = document.createElement('div');
|
personalization: 'ptcs_ui_preferences'
|
||||||
msgDiv.classList.add('message', sender);
|
|
||||||
msgDiv.textContent = text;
|
|
||||||
chatMessages.appendChild(msgDiv);
|
|
||||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
chatForm.addEventListener('submit', async (e) => {
|
const banner = document.getElementById('cookie-banner');
|
||||||
e.preventDefault();
|
const overlay = document.getElementById('cookie-overlay');
|
||||||
const message = chatInput.value.trim();
|
const reopenButton = document.getElementById('cookie-reopen');
|
||||||
if (!message) return;
|
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');
|
||||||
|
const scrollHint = document.getElementById('scroll-hint');
|
||||||
|
const toastStack = document.getElementById('toast-stack');
|
||||||
|
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 isHomePage = document.body?.dataset.page === 'home';
|
||||||
|
const bodyStartsLocked = document.body?.dataset.cookieLock === 'pending';
|
||||||
|
|
||||||
appendMessage(message, 'visitor');
|
const visitCounterRoot = document.querySelector('[data-visit-counter-endpoint]');
|
||||||
chatInput.value = '';
|
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: {
|
||||||
|
essential: true,
|
||||||
|
personalization: false,
|
||||||
|
audience: false,
|
||||||
|
timestamp: null
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
if (!rawValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawValue);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageGet(key) {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageSet(key, value) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, value);
|
||||||
|
} catch (error) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageRemove(key) {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
const cookies = document.cookie ? document.cookie.split('; ') : [];
|
||||||
|
for (const row of cookies) {
|
||||||
|
if (row.startsWith(prefix)) {
|
||||||
|
return decodeURIComponent(row.substring(prefix.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(name, value, maxAge) {
|
||||||
|
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,
|
||||||
|
personalization: Boolean(rawPrefs.personalization),
|
||||||
|
audience: Boolean(rawPrefs.audience),
|
||||||
|
timestamp: rawPrefs.timestamp || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSavedPrefs() {
|
||||||
|
const parsed = safeParse(getCookie(COOKIE_NAME));
|
||||||
|
return parsed ? normalizePrefs(parsed) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setControlState(controls, value) {
|
||||||
|
controls.forEach((control) => {
|
||||||
|
control.checked = Boolean(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBadge(key, label, status) {
|
||||||
|
document.querySelectorAll(`[data-consent-badge="${key}"]`).forEach((badge) => {
|
||||||
|
badge.textContent = label;
|
||||||
|
badge.dataset.state = status;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
const numericValue = Number(value);
|
||||||
|
return numberFormatter.format(Number.isFinite(numericValue) ? numericValue : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateString) {
|
||||||
|
if (!dateString) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummaryText() {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReminderLabel() {
|
||||||
|
if (!reopenLabel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.prefs.personalization && state.prefs.audience) {
|
||||||
|
reopenLabel.textContent = 'Cookies : choix personnalisés';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.prefs.personalization || state.prefs.audience) {
|
||||||
|
reopenLabel.textContent = 'Cookies : options partielles';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reopenLabel.textContent = 'Cookies : essentiels';
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusBannerPrimaryAction() {
|
||||||
|
if (!banner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (!toastStack) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'app-toast';
|
||||||
|
toast.setAttribute('role', 'status');
|
||||||
|
toast.textContent = message;
|
||||||
|
toastStack.appendChild(toast);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
toast.classList.add('is-visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
toast.classList.remove('is-visible');
|
||||||
|
window.setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 220);
|
||||||
|
}, 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const response = await fetch('api/chat.php', {
|
const response = await fetch(visitCounterEndpoint, options);
|
||||||
method: 'POST',
|
if (!response.ok) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
return null;
|
||||||
body: JSON.stringify({ message })
|
}
|
||||||
});
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
return data && data.success ? data : null;
|
||||||
// Artificial delay for realism
|
|
||||||
setTimeout(() => {
|
|
||||||
appendMessage(data.reply, 'bot');
|
|
||||||
}, 500);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
return null;
|
||||||
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearVisitRefreshTimer() {
|
||||||
|
if (state.visitRefreshTimer) {
|
||||||
|
window.clearInterval(state.visitRefreshTimer);
|
||||||
|
state.visitRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startVisitRefreshTimer() {
|
||||||
|
if (!visitCounterRoot || state.visitRefreshTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.visitRefreshTimer = window.setInterval(() => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.prefs.audience) {
|
||||||
|
void pingVisitCounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshVisitSnapshot();
|
||||||
|
}, VISIT_REFRESH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVisitCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forgetVisitCounter() {
|
||||||
|
const token = getCookie(VISIT_COOKIE_NAME);
|
||||||
|
deleteCookie(VISIT_COOKIE_NAME);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
await refreshVisitSnapshot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await requestVisitCounter('POST', {
|
||||||
|
action: 'forget',
|
||||||
|
token
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data?.counts) {
|
||||||
|
updateVisitCounter(data.counts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
setControlState(personalizationControls, state.prefs.personalization);
|
||||||
|
setControlState(audienceControls, state.prefs.audience);
|
||||||
|
|
||||||
|
setBadge('essential', 'Toujours actif', 'locked');
|
||||||
|
setBadge('personalization', state.prefs.personalization ? 'Activée' : 'Désactivée', state.prefs.personalization ? 'on' : 'off');
|
||||||
|
setBadge('audience', state.prefs.audience ? 'Activée' : 'Désactivée', state.prefs.audience ? 'on' : 'off');
|
||||||
|
|
||||||
|
if (!state.prefs.personalization) {
|
||||||
|
storageRemove(STORAGE_KEYS.personalization);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPersonalizationUI();
|
||||||
|
updateSummaryText();
|
||||||
|
updateReminderLabel();
|
||||||
|
updateVisitCounter();
|
||||||
|
syncVisitCounter(previousAudienceEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBanner() {
|
||||||
|
if (!banner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
banner.hidden = false;
|
||||||
|
syncConsentBarrier();
|
||||||
|
if (reopenButton) {
|
||||||
|
reopenButton.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
focusBannerPrimaryAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBanner() {
|
||||||
|
if (!banner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
banner.hidden = true;
|
||||||
|
syncConsentBarrier();
|
||||||
|
if (reopenButton) {
|
||||||
|
reopenButton.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePrefs(nextPrefs, notice) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dismissScrollHint && scrollHint) {
|
||||||
|
dismissScrollHint.addEventListener('click', () => {
|
||||||
|
scrollHint.hidden = true;
|
||||||
|
if (state.prefs.personalization) {
|
||||||
|
storageSet(STORAGE_KEYS.personalization, JSON.stringify({
|
||||||
|
scrollHintDismissed: true,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
showToast('Le rappel mobile a été masqué pour cet appareil.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
consentActionButtons.forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const action = button.dataset.cookieAction;
|
||||||
|
|
||||||
|
if (action === 'accept') {
|
||||||
|
savePrefs({
|
||||||
|
essential: true,
|
||||||
|
personalization: true,
|
||||||
|
audience: true
|
||||||
|
}, 'Toutes les options facultatives ont été activées.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'reject') {
|
||||||
|
savePrefs({
|
||||||
|
essential: true,
|
||||||
|
personalization: false,
|
||||||
|
audience: false
|
||||||
|
}, 'Seuls les cookies essentiels sont conservés.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'save') {
|
||||||
|
savePrefs({
|
||||||
|
essential: true,
|
||||||
|
personalization: personalizationControls.some((control) => control.checked),
|
||||||
|
audience: audienceControls.some((control) => control.checked)
|
||||||
|
}, 'Vos préférences cookies ont été enregistrées.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
[reopenButton, footerCookieSettings].forEach((trigger) => {
|
||||||
|
if (!trigger) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trigger.addEventListener('click', () => {
|
||||||
|
openBanner();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!visitCounterRoot || document.visibilityState !== 'visible') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.prefs.audience) {
|
||||||
|
void pingVisitCounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshVisitSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedPrefs = readSavedPrefs();
|
||||||
|
state.hasSavedDecision = Boolean(savedPrefs);
|
||||||
|
state.requiresBlockingChoice = isHomePage && !savedPrefs;
|
||||||
|
|
||||||
|
if (savedPrefs) {
|
||||||
|
applyPrefs(savedPrefs);
|
||||||
|
closeBanner();
|
||||||
|
} else {
|
||||||
|
applyPrefs({ essential: true, personalization: false, audience: false });
|
||||||
|
openBanner();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
27
db/migrations/20260502_create_visit_counter_tables.sql
Normal file
27
db/migrations/20260502_create_visit_counter_tables.sql
Normal file
@ -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;
|
||||||
278
db/visit_counter.php
Normal file
278
db/visit_counter.php
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
const VISIT_COUNTER_LIVE_WINDOW_MINUTES = 5;
|
||||||
|
const VISIT_COUNTER_RETENTION_MONTHS = 13;
|
||||||
|
const VISIT_COUNTER_TOTAL_KEY = 'lifetime_total';
|
||||||
|
|
||||||
|
function visit_counter_timezone(): DateTimeZone
|
||||||
|
{
|
||||||
|
static $timezone = null;
|
||||||
|
if ($timezone instanceof DateTimeZone) {
|
||||||
|
return $timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timezone = new DateTimeZone('Europe/Paris');
|
||||||
|
return $timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit_counter_now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('now', visit_counter_timezone());
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit_counter_normalize_token(mixed $token): ?string
|
||||||
|
{
|
||||||
|
if (!is_string($token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtolower(trim($token));
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('/\A[a-f0-9]{48}\z/', $normalized) === 1 ? $normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit_counter_generate_token(): string
|
||||||
|
{
|
||||||
|
return bin2hex(random_bytes(24));
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit_counter_ensure_schema(): void
|
||||||
|
{
|
||||||
|
static $initialized = false;
|
||||||
|
if ($initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$pdo->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);
|
||||||
|
}
|
||||||
523
index.php
523
index.php
@ -1,150 +1,395 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@ini_set('display_errors', '1');
|
require_once __DIR__ . '/site.php';
|
||||||
@error_reporting(E_ALL);
|
|
||||||
@date_default_timezone_set('UTC');
|
|
||||||
|
|
||||||
$phpVersion = PHP_VERSION;
|
$site = site_settings();
|
||||||
$now = date('Y-m-d H:i:s');
|
$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 = [
|
||||||
|
[
|
||||||
|
'title' => 'Voir le direct immédiatement',
|
||||||
|
'copy' => 'Accédez au widget “en ce moment” pour savoir ce qui passe maintenant sur les chaînes les plus consultées.',
|
||||||
|
'href' => '#widget-section',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Comparer le prime time',
|
||||||
|
'copy' => 'Parcourez les mots-clés, les chaînes et les catégories éditoriales pour préparer votre soirée TV.',
|
||||||
|
'href' => '#guide',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Repérer un film, une série ou un match',
|
||||||
|
'copy' => 'Les sections éditoriales ciblent les recherches les plus fréquentes liées au programme TV ce soir.',
|
||||||
|
'href' => '#recherches',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Contrôler vos cookies',
|
||||||
|
'copy' => 'Utilisez la bannière et le bouton flottant en bas à gauche pour modifier vos choix à tout moment.',
|
||||||
|
'href' => '#legal',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$benefits = [
|
||||||
|
'Programme TV ce soir et en ce moment sur une page simple, rapide et lisible.',
|
||||||
|
'Accès prioritaire aux chaînes TNT, aux grandes chaînes nationales et aux rendez-vous de prime time.',
|
||||||
|
'Lecture mobile optimisée avec défilement horizontal visible sous le widget TV.',
|
||||||
|
'Conformité vie privée avec bannière de consentement, réglages persistants et documents légaux dédiés.',
|
||||||
|
];
|
||||||
|
|
||||||
|
$channelCards = [
|
||||||
|
['name' => 'TF1', 'copy' => 'Programme TF1 ce soir : divertissement, fiction populaire, sport et grands événements en prime time.'],
|
||||||
|
['name' => 'France 2', 'copy' => 'Programme France 2 ce soir : séries, magazines, infos, culture et soirées événementielles.'],
|
||||||
|
['name' => 'France 3', 'copy' => 'Programme France 3 ce soir : patrimoine, régions, cinéma français et documentaires accessibles.'],
|
||||||
|
['name' => 'Canal+', 'copy' => 'Programme Canal+ ce soir : cinéma, créations originales, sport premium et événements exclusifs.'],
|
||||||
|
['name' => 'M6', 'copy' => 'Programme M6 ce soir : divertissements, magazines, séries et rendez-vous familiaux.'],
|
||||||
|
['name' => 'Arte', 'copy' => 'Programme Arte ce soir : films d’auteur, documentaires, culture, histoire et créations européennes.'],
|
||||||
|
['name' => 'France 5', 'copy' => 'Programme France 5 ce soir : documentaires, débats, société, science et découverte.'],
|
||||||
|
['name' => 'C8 / CStar', 'copy' => 'Programme C8 et CStar ce soir : magazines, talk-shows, divertissements et musique.'],
|
||||||
|
['name' => 'TMC / TFX', 'copy' => 'Programme TMC et TFX ce soir : films, séries populaires, talks et télé-réalité.'],
|
||||||
|
['name' => 'W9 / 6ter', 'copy' => 'Programme W9 et 6ter ce soir : cinéma, séries, clips, magazines et programmes feel-good.'],
|
||||||
|
['name' => 'RMC Story / RMC Découverte', 'copy' => 'Programme RMC Story et RMC Découverte : enquêtes, découverte, mécanique, société et histoire.'],
|
||||||
|
['name' => 'Gulli / Jeunesse', 'copy' => 'Programme jeunesse ce soir : dessins animés, films familiaux et rendez-vous enfants.'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$keywordBadges = [
|
||||||
|
'programme tv ce soir', 'programme télé ce soir', 'ce soir à la télé', 'programme tv en ce moment', 'programme tnt ce soir',
|
||||||
|
'film ce soir', 'série ce soir', 'match ce soir', 'sport à la tv', 'documentaire ce soir', 'émission ce soir',
|
||||||
|
'prime time', 'deuxième partie de soirée', 'direct tv', 'grille tv', 'chaînes tnt', 'programme tf1 ce soir',
|
||||||
|
'programme france 2 ce soir', 'programme m6 ce soir', 'programme arte ce soir', 'télé ce soir', 'que regarder ce soir',
|
||||||
|
'programme canal+', 'programme france 3', 'programme france 5', 'programme tmc', 'programme w9', 'programme c8',
|
||||||
|
];
|
||||||
|
|
||||||
|
$faqItems = [
|
||||||
|
[
|
||||||
|
'question' => 'Où voir rapidement le programme TV ce soir ?',
|
||||||
|
'answer' => 'La zone “Programme TV en ce moment” placée en haut de la page donne un accès direct au widget, puis les sections éditoriales vous aident à comparer les chaînes, les genres et les créneaux du soir.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'question' => 'Comment savoir ce qu\'il y a à la télé en ce moment ?',
|
||||||
|
'answer' => 'Le widget affiche le direct des chaînes les plus consultées. Sur mobile et tablette, un défilement horizontal est prévu sous le widget pour conserver une lecture confortable.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'question' => 'Quelles chaînes sont mises en avant sur la page ?',
|
||||||
|
'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.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é ?',
|
||||||
|
'answer' => 'Le DPO déclaré sur le site est M LORENTE CHRISTOPHE, 7 rue Lucien Deneau – 28300 Mainvilliers, téléphone 06 58 22 59 16. Les détails figurent aussi dans la politique de confidentialité.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$faqSchema = [
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'FAQPage',
|
||||||
|
'mainEntity' => array_map(static function (array $item): array {
|
||||||
|
return [
|
||||||
|
'@type' => 'Question',
|
||||||
|
'name' => $item['question'],
|
||||||
|
'acceptedAnswer' => [
|
||||||
|
'@type' => 'Answer',
|
||||||
|
'text' => $item['answer'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}, $faqItems),
|
||||||
|
];
|
||||||
|
|
||||||
|
$organizationSchema = [
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $site['domain'],
|
||||||
|
'url' => ($site['canonical_base_url'] ?? $site['base_url']) . '/',
|
||||||
|
'description' => $fallbackDescription,
|
||||||
|
'address' => [
|
||||||
|
'@type' => 'PostalAddress',
|
||||||
|
'streetAddress' => '7 rue Lucien Deneau',
|
||||||
|
'postalCode' => '28300',
|
||||||
|
'addressLocality' => 'Mainvilliers',
|
||||||
|
'addressCountry' => 'FR',
|
||||||
|
],
|
||||||
|
'contactPoint' => [
|
||||||
|
'@type' => 'ContactPoint',
|
||||||
|
'contactType' => 'data protection officer',
|
||||||
|
'name' => $site['dpo_name'],
|
||||||
|
'telephone' => '+33 6 58 22 59 16',
|
||||||
|
'availableLanguage' => ['fr'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$cookieConsentLock = site_should_lock_cookie_overlay();
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<?php render_site_head($pageTitle, $fallbackDescription, $keywords); ?>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>New Style</title>
|
|
||||||
<?php
|
|
||||||
// Read project preview data from environment
|
|
||||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
|
||||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
|
||||||
?>
|
|
||||||
<?php if ($projectDescription): ?>
|
|
||||||
<!-- Meta description -->
|
|
||||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
|
||||||
<!-- Open Graph meta tags -->
|
|
||||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
|
||||||
<!-- Twitter meta tags -->
|
|
||||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($projectImageUrl): ?>
|
|
||||||
<!-- Open Graph image -->
|
|
||||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
|
||||||
<!-- Twitter image -->
|
|
||||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
|
||||||
<?php endif; ?>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-color-start: #6a11cb;
|
|
||||||
--bg-color-end: #2575fc;
|
|
||||||
--text-color: #ffffff;
|
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
|
||||||
animation: bg-pan 20s linear infinite;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
@keyframes bg-pan {
|
|
||||||
0% { background-position: 0% 0%; }
|
|
||||||
100% { background-position: 100% 100%; }
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg-color);
|
|
||||||
border: 1px solid var(--card-border-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2rem;
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
.loader {
|
|
||||||
margin: 1.25rem auto 1.25rem;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px; height: 1px;
|
|
||||||
padding: 0; margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap; border: 0;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 3rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
code {
|
|
||||||
background: rgba(0,0,0,0.2);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="app-body<?= $cookieConsentLock ? ' cookie-consent-locked' : '' ?>" data-page="home" data-cookie-lock="<?= $cookieConsentLock ? 'pending' : 'released' ?>">
|
||||||
<main>
|
<?php render_site_nav('home'); ?>
|
||||||
<div class="card">
|
<noscript>
|
||||||
<h1>Analyzing your requirements and generating your website…</h1>
|
<div class="container pt-3">
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<div class="alert alert-secondary border-0">JavaScript est nécessaire pour la gestion fine des cookies et pour le chargement du widget TV.</div>
|
||||||
<span class="sr-only">Loading…</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
</noscript>
|
||||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
<main class="site-main">
|
||||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
<section class="hero-section" id="top" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="hero-grid">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<span class="eyebrow">Programme TV • France • Mise à jour éditoriale du <?= e($updatedAt) ?></span>
|
||||||
|
<h1>Programme TV ce soir et en ce moment : votre accès rapide aux chaînes et aux rendez-vous du soir</h1>
|
||||||
|
<p class="lead">Programmetelecesoir.net centralise l'intention de recherche la plus importante du secteur : savoir <strong>ce qu'il y a à la télé ce soir</strong> et <strong>ce qui passe en ce moment</strong>. La page met immédiatement le widget TV en avant, puis complète la lecture avec un guide éditorial pensé pour le prime time, les films, les séries, le sport, les documentaires et les chaînes TNT les plus consultées.</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="btn btn-dark btn-refined" href="#widget-section">Voir le programme en ce moment</a>
|
||||||
|
<a class="btn btn-outline-dark btn-refined" href="#guide">Lire le guide TV du soir</a>
|
||||||
|
<a class="btn btn-outline-dark btn-refined" href="#faq">Questions fréquentes</a>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div class="hero-proof">
|
||||||
<footer>
|
<?php foreach ($benefits as $benefit): ?>
|
||||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
<div class="proof-item">
|
||||||
</footer>
|
<span class="proof-dot" aria-hidden="true"></span>
|
||||||
|
<span><?= e($benefit) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside class="stacked-panels" aria-label="Informations complémentaires">
|
||||||
|
<article class="info-panel compact-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<span class="panel-kicker">Préférences</span>
|
||||||
|
<h2>Consentement en direct</h2>
|
||||||
|
</div>
|
||||||
|
<p class="panel-copy" data-consent-summary>Le site conserve uniquement l'essentiel tant que vous n'avez pas choisi d'options supplémentaires.</p>
|
||||||
|
<ul class="status-list list-unstyled mb-0">
|
||||||
|
<li><span>Essentiels</span><span class="status-pill" data-consent-badge="essential">Toujours actif</span></li>
|
||||||
|
<li><span>Personnalisation</span><span class="status-pill" data-consent-badge="personalization">Désactivée</span></li>
|
||||||
|
<li><span>Audience locale</span><span class="status-pill" data-consent-badge="audience">Désactivée</span></li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article class="info-panel compact-panel visit-counter-panel" data-visit-counter-endpoint="/api/visit-counter.php" data-live-window-minutes="<?= e((string) $visitorStats['live_window_minutes']) ?>" data-initial-live="<?= e((string) $visitorStats['live']) ?>" data-initial-daily="<?= e((string) $visitorStats['daily']) ?>" data-initial-total="<?= e((string) $visitorStats['total']) ?>" data-updated-at="<?= e((string) $visitorStats['updated_at']) ?>" data-updated-label="<?= e((string) $visitorStats['updated_label']) ?>">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<span class="panel-kicker">Audience</span>
|
||||||
|
<h2>Compteur visiteurs en direct</h2>
|
||||||
|
</div>
|
||||||
|
<p class="panel-copy" data-visit-status>Le compteur affiche l'audience anonyme en direct, les visiteurs du jour et le total mesuré. Il se met à jour automatiquement si la mesure locale d'audience est acceptée.</p>
|
||||||
|
<div class="stat-grid stat-grid--counter" aria-live="polite">
|
||||||
|
<div>
|
||||||
|
<strong data-visit-live><?= e((string) $visitorStats['live']) ?></strong>
|
||||||
|
<span>en direct</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong data-visit-daily><?= e((string) $visitorStats['daily']) ?></strong>
|
||||||
|
<span>aujourd'hui</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong data-visit-total><?= e((string) $visitorStats['total']) ?></strong>
|
||||||
|
<span>total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="panel-note mb-0" data-visit-updated>Dernière mise à jour : <?= e((string) $visitorStats['updated_label']) ?> · “En direct” = visiteurs actifs sur les <?= e((string) $visitorStats['live_window_minutes']) ?> dernières minutes.</p>
|
||||||
|
</article>
|
||||||
|
<article class="info-panel compact-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<span class="panel-kicker">Conformité</span>
|
||||||
|
<h2>DPO et hébergement</h2>
|
||||||
|
</div>
|
||||||
|
<p class="panel-copy mb-2"><strong><?= e($site['dpo_name']) ?></strong><br><?= e($site['dpo_address']) ?><br>Tél. <?= e($site['dpo_phone']) ?></p>
|
||||||
|
<p class="panel-copy mb-0">Hébergeur indiqué sur le site : <strong><?= e($site['host_name']) ?></strong>.</p>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block" id="widget-section" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-shell">
|
||||||
|
<div class="section-heading with-meta">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Accès direct</span>
|
||||||
|
<h2>Voir ce qu'il y a à la télé maintenant</h2>
|
||||||
|
<p>Le widget est volontairement placé juste sous le titre pour répondre immédiatement à la requête “programme TV en ce moment”. Sur mobile et tablette, la zone est contenue dans un défilement horizontal visible en bas.</p>
|
||||||
|
</div>
|
||||||
|
<div class="section-meta">Défilement horizontal activé sur mobile et tablette</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget-card">
|
||||||
|
<div class="widget-scroll" role="region" aria-label="Widget programme TV avec défilement horizontal sur mobile et tablette">
|
||||||
|
<div class="widget-scroll__inner">
|
||||||
|
<a class="widget-tv2" href="https://tv-programme.com" data-type="en-ce-moment" data-nb-chaines="12" data-color="#DFF0D8" data-width="100%">Programme TV</a><script async src="https://tv-programme.com/widget.js"></script><p class="tvp-widget-attribution">Source TV : <a href="https://tv-programme.com">Programme TV</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scroll-note" id="scroll-hint">
|
||||||
|
<div>
|
||||||
|
<strong>Astuce mobile :</strong> si toute la largeur n'est pas visible, faites glisser horizontalement la zone du widget. Ce rappel peut être mémorisé uniquement si la personnalisation est activée.
|
||||||
|
</div>
|
||||||
|
<button type="button" id="scroll-hint-dismiss">Masquer ce conseil</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block" id="guide" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-shell">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Guide TV du soir</span>
|
||||||
|
<h2>Une longue page SEO orientée sur les recherches réelles des internautes</h2>
|
||||||
|
<p>Cette page cible les requêtes majeures du secteur : <strong>programme TV ce soir</strong>, <strong>programme télé ce soir</strong>, <strong>ce soir à la télé</strong>, <strong>programme TV TNT</strong>, <strong>film ce soir</strong>, <strong>série ce soir</strong>, <strong>match ce soir</strong>, <strong>documentaire ce soir</strong> et <strong>programme TV en ce moment</strong>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="content-grid">
|
||||||
|
<article class="article-panel">
|
||||||
|
<p>Si vous cherchez <strong>quoi regarder ce soir</strong>, la logique de programmetelecesoir.net est simple : offrir d'abord le direct, puis un accompagnement éditorial lisible. En un seul endroit, vous pouvez surveiller le programme TV des grandes chaînes, comparer le prime time, repérer un film à la télévision ce soir, vérifier la présence d'une série, d'un documentaire, d'un match, d'une émission d'information, d'un magazine ou d'un divertissement familial.</p>
|
||||||
|
<p>La structure éditoriale de la page reprend les formulations les plus recherchées autour du <strong>programme télé ce soir</strong> : programme TV TNT ce soir, programme TV maintenant, télé ce soir, programme des chaînes en soirée, directs TV, grille TV du soir, deuxième partie de soirée, films et séries du prime time. L'objectif n'est pas d'encombrer la lecture, mais de répondre précisément aux intentions qui reviennent le plus souvent sur mobile comme sur desktop.</p>
|
||||||
|
<p>Les internautes qui tapent <strong>programme TF1 ce soir</strong>, <strong>programme France 2 ce soir</strong>, <strong>programme M6 ce soir</strong> ou <strong>programme Arte ce soir</strong> recherchent généralement un accès immédiat, sans détour, avec une page rapide, claire et fiable. C'est pour cette raison que le site priorise la lisibilité, le widget en tête de page, des cartes chaînes synthétiques, une FAQ structurée et des documents légaux facilement accessibles.</p>
|
||||||
|
</article>
|
||||||
|
<aside class="aside-panels">
|
||||||
|
<?php foreach ($quickLinks as $item): ?>
|
||||||
|
<article class="info-panel">
|
||||||
|
<h3><?= e($item['title']) ?></h3>
|
||||||
|
<p><?= e($item['copy']) ?></p>
|
||||||
|
<a class="text-link" href="<?= e($item['href']) ?>">Accéder à la section</a>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block" id="chaines" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-shell">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Chaînes recherchées</span>
|
||||||
|
<h2>Programme TV du soir chaîne par chaîne</h2>
|
||||||
|
<p>La page reprend les grandes intentions liées aux principales chaînes consultées en France pour le programme de ce soir, le direct actuel, les films, les séries, les magazines, la culture et le sport.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-grid card-grid--three">
|
||||||
|
<?php foreach ($channelCards as $channel): ?>
|
||||||
|
<article class="channel-card">
|
||||||
|
<div class="channel-card__name"><?= e($channel['name']) ?></div>
|
||||||
|
<p><?= e($channel['copy']) ?></p>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block" id="recherches" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-shell">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Intentions fréquentes</span>
|
||||||
|
<h2>Mots-clés éditoriaux et besoins de lecture</h2>
|
||||||
|
<p>Pour couvrir un maximum d'intentions, la page déploie un champ lexical riche mais propre, sans agressivité visuelle. Les badges ci-dessous représentent les expressions que le visiteur recherche le plus souvent avant de choisir son programme.</p>
|
||||||
|
</div>
|
||||||
|
<div class="keyword-cloud" aria-label="Mots-clés du programme TV ce soir">
|
||||||
|
<?php foreach ($keywordBadges as $keyword): ?>
|
||||||
|
<span class="keyword-pill"><?= e($keyword) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="content-grid mt-4">
|
||||||
|
<article class="info-panel article-like">
|
||||||
|
<h3>Comment choisir rapidement le bon programme ce soir ?</h3>
|
||||||
|
<ol class="ordered-steps">
|
||||||
|
<li>Commencez par le widget “en ce moment” pour identifier l'offre en direct.</li>
|
||||||
|
<li>Repérez ensuite la chaîne qui correspond à votre envie : film, série, documentaire, sport, culture ou divertissement.</li>
|
||||||
|
<li>Consultez les cartes chaînes pour accélérer la comparaison entre TF1, France 2, M6, Arte, Canal+ et TNT.</li>
|
||||||
|
<li>Utilisez la FAQ et les liens légaux si vous souhaitez comprendre la confidentialité, les cookies et les règles d'utilisation.</li>
|
||||||
|
</ol>
|
||||||
|
</article>
|
||||||
|
<article class="info-panel article-like">
|
||||||
|
<h3>Pourquoi cette page fonctionne bien sur mobile ?</h3>
|
||||||
|
<p>Le design a été allégé pour rester très lisible : navigation compacte, sections régulières, contraste fort, cartes sobres, bouton flottant pour les cookies et zone horizontale spécifique sous le widget afin d'éviter toute coupure sur smartphone ou tablette. Le résultat est plus confortable pour les utilisateurs qui cherchent vite “ce soir à la télé” depuis leur mobile.</p>
|
||||||
|
<p>Le site évite aussi les scripts marketing et les effets visuels inutiles. Cela aide à conserver une expérience rapide, rassurante et facile à parcourir, ce qui renforce la consultation répétée lorsqu'on veut simplement savoir quel programme TV regarder ce soir.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block" id="faq" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-shell">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">FAQ</span>
|
||||||
|
<h2>Questions fréquentes sur le programme TV ce soir</h2>
|
||||||
|
<p>Les réponses ci-dessous renforcent l'intention utilisateur tout en gardant une lecture utile, concise et structurée.</p>
|
||||||
|
</div>
|
||||||
|
<div class="accordion custom-accordion" id="faqAccordion">
|
||||||
|
<?php foreach ($faqItems as $index => $item): ?>
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h3 class="accordion-header" id="faq-heading-<?= $index ?>">
|
||||||
|
<button class="accordion-button <?= $index === 0 ? '' : 'collapsed' ?>" type="button" data-bs-toggle="collapse" data-bs-target="#faq-collapse-<?= $index ?>" aria-expanded="<?= $index === 0 ? 'true' : 'false' ?>" aria-controls="faq-collapse-<?= $index ?>">
|
||||||
|
<?= e($item['question']) ?>
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<div id="faq-collapse-<?= $index ?>" class="accordion-collapse collapse <?= $index === 0 ? 'show' : '' ?>" aria-labelledby="faq-heading-<?= $index ?>" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<?= e($item['answer']) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block" id="legal" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-shell">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Confiance & conformité</span>
|
||||||
|
<h2>Politique de cookies, confidentialité et règlement</h2>
|
||||||
|
<p>Les documents juridiques ont été séparés en pages dédiées pour rester clairs et faciles à consulter. Le bouton flottant en bas à gauche rouvre les préférences cookies à tout moment.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-grid card-grid--three">
|
||||||
|
<article class="legal-card">
|
||||||
|
<h3>Politique de cookies</h3>
|
||||||
|
<p>Détail du cookie essentiel de consentement, des options facultatives et du rôle du widget TV nécessaire au service.</p>
|
||||||
|
<a class="text-link" href="/politique-cookies.php">Lire la politique de cookies</a>
|
||||||
|
</article>
|
||||||
|
<article class="legal-card">
|
||||||
|
<h3>Politique de confidentialité</h3>
|
||||||
|
<p>Présentation du responsable, du DPO, de l'hébergement, des données traitées et des droits des personnes.</p>
|
||||||
|
<a class="text-link" href="/politique-confidentialite.php">Lire la politique de confidentialité</a>
|
||||||
|
</article>
|
||||||
|
<article class="legal-card">
|
||||||
|
<h3>Règlement d'utilisation</h3>
|
||||||
|
<p>Cadre d'usage du site, responsabilité, disponibilité, propriété intellectuelle et rappel des règles de consultation.</p>
|
||||||
|
<a class="text-link" href="/reglement.php">Lire le règlement</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<?php render_site_footer(); ?>
|
||||||
|
<?php render_cookie_controls(); ?>
|
||||||
|
<script type="application/ld+json"><?= json_encode($organizationSchema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?></script>
|
||||||
|
<script type="application/ld+json"><?= json_encode($faqSchema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?></script>
|
||||||
|
<?php render_site_scripts(); ?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
118
politique-confidentialite.php
Normal file
118
politique-confidentialite.php
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/site.php';
|
||||||
|
|
||||||
|
$site = site_settings();
|
||||||
|
$pageTitle = 'Politique de confidentialité | ' . $site['domain'];
|
||||||
|
$fallbackDescription = 'Politique de confidentialité de programmetelecesoir.net : responsable de traitement, DPO, hébergement, données traitées et droits des personnes.';
|
||||||
|
$keywords = 'politique de confidentialité, données personnelles, dpo, programmetelecesoir.net, confidentialité programme tv';
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<?php render_site_head($pageTitle, $fallbackDescription, $keywords, true); ?>
|
||||||
|
</head>
|
||||||
|
<body class="app-body legal-body" data-page="privacy">
|
||||||
|
<?php render_site_nav('privacy'); ?>
|
||||||
|
<main class="site-main legal-main">
|
||||||
|
<section class="legal-hero" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="legal-shell">
|
||||||
|
<nav aria-label="Fil d'Ariane" class="breadcrumb-wrap">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="/">Accueil</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Politique de confidentialité</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<span class="eyebrow mt-4">Protection des données</span>
|
||||||
|
<h1>Politique de confidentialité</h1>
|
||||||
|
<p class="lead legal-lead">Cette politique décrit les traitements de données susceptibles d'intervenir lors de la consultation de programmetelecesoir.net, les finalités poursuivies, les durées de conservation, les destinataires et vos droits.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-shell legal-shell">
|
||||||
|
<div class="content-grid legal-grid">
|
||||||
|
<article class="article-panel">
|
||||||
|
<h2>1. Responsable du traitement</h2>
|
||||||
|
<p>Le responsable du traitement et DPO déclaré pour le site est <strong><?= e($site['dpo_name']) ?></strong>, domicilié au <strong><?= e($site['dpo_address']) ?></strong>, joignable au <strong><?= e($site['dpo_phone']) ?></strong>.</p>
|
||||||
|
|
||||||
|
<h2>2. Hébergement</h2>
|
||||||
|
<p>Le site est hébergé par <strong><?= e($site['host_name']) ?></strong>. L'hébergement technique implique le traitement de journaux serveur et d'informations de connexion nécessaires à la sécurité, à la disponibilité du service et à la résolution d'éventuels incidents.</p>
|
||||||
|
|
||||||
|
<h2>3. Catégories de données susceptibles d'être traitées</h2>
|
||||||
|
<p>Le site a été conçu pour limiter au strict nécessaire la collecte de données. Dans cette version, les traitements potentiels concernent principalement :</p>
|
||||||
|
<ul class="legal-list">
|
||||||
|
<li>les données techniques de connexion indispensables au fonctionnement du site et à la sécurité de l'infrastructure (adresse IP, user-agent, requêtes HTTP, horodatage, erreurs techniques) ;</li>
|
||||||
|
<li>vos préférences de consentement, enregistrées dans le cookie <code>ptcs_consent</code> ;</li>
|
||||||
|
<li>les informations échangées avec le prestataire du widget TV, nécessaires à l'affichage du service “en ce moment” lorsque vous consultez la page d'accueil ;</li>
|
||||||
|
<li>les préférences locales optionnelles d'interface et, si vous activez la mesure d'audience, le cookie <code>ptcs_visit_id</code> ainsi que des horodatages anonymes associés au compteur visiteurs en direct, journalier et total.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>4. Finalités et bases juridiques</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table legal-table align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Traitement</th>
|
||||||
|
<th>Finalité</th>
|
||||||
|
<th>Base juridique</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Logs techniques et sécurité</td>
|
||||||
|
<td>Garantir la stabilité, la sécurité et la disponibilité du site.</td>
|
||||||
|
<td>Intérêt légitime du responsable et nécessité opérationnelle.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Cookie de consentement</td>
|
||||||
|
<td>Mémoriser vos préférences et prouver le choix exprimé.</td>
|
||||||
|
<td>Intérêt légitime / conformité en matière de gestion du consentement.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Chargement du widget TV</td>
|
||||||
|
<td>Afficher le service principal de programme TV demandé par l'utilisateur.</td>
|
||||||
|
<td>Nécessité liée au service expressément sollicité.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Personnalisation et compteur interne de visites</td>
|
||||||
|
<td>Améliorer le confort de navigation et afficher le compteur visiteurs en direct uniquement si vous l'autorisez.</td>
|
||||||
|
<td>Consentement.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>5. Destinataires</h2>
|
||||||
|
<p>Les destinataires potentiels des données techniques sont le responsable du site, l'hébergeur <strong><?= e($site['host_name']) ?></strong> pour l'exploitation de l'infrastructure et, pour le service TV embarqué, le fournisseur du widget nécessaire à l'affichage du programme. Le compteur visiteurs optionnel reste traité en first-party dans la base locale du site, sans prestataire analytics tiers.</p>
|
||||||
|
|
||||||
|
<h2>6. Durées de conservation</h2>
|
||||||
|
<p>Le cookie de consentement et le cookie optionnel <code>ptcs_visit_id</code> sont conservés 6 mois. Les horodatages anonymes liés au compteur visiteurs sont conservés au maximum 13 mois, puis supprimés. Le total cumulé peut rester conservé sous forme agrégée, sans identifiant direct. Les durées de conservation des journaux techniques dépendent des règles d'exploitation et de sécurité de l'hébergement.</p>
|
||||||
|
</article>
|
||||||
|
<aside class="aside-panels">
|
||||||
|
<article class="info-panel">
|
||||||
|
<h3>Vos droits</h3>
|
||||||
|
<p>Vous disposez des droits d'accès, de rectification, d'effacement, de limitation, d'opposition et, le cas échéant, de portabilité. Vous pouvez également retirer votre consentement pour les options facultatives à tout moment.</p>
|
||||||
|
</article>
|
||||||
|
<article class="info-panel">
|
||||||
|
<h3>Réclamation</h3>
|
||||||
|
<p>En cas de difficulté persistante, vous pouvez contacter le DPO puis, si nécessaire, saisir l'autorité de contrôle compétente.</p>
|
||||||
|
</article>
|
||||||
|
<article class="info-panel">
|
||||||
|
<h3>Mesures de minimisation</h3>
|
||||||
|
<p>Le site ne propose ni espace membre, ni formulaire de collecte marketing, ni profilage publicitaire dans cette version initiale. L'objectif est de maintenir un traitement réduit et lisible.</p>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<?php render_site_footer(); ?>
|
||||||
|
<?php render_cookie_controls(); ?>
|
||||||
|
<?php render_site_scripts(); ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
126
politique-cookies.php
Normal file
126
politique-cookies.php
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/site.php';
|
||||||
|
|
||||||
|
$site = site_settings();
|
||||||
|
$pageTitle = 'Politique de cookies | ' . $site['domain'];
|
||||||
|
$fallbackDescription = 'Politique de cookies de programmetelecesoir.net : inventaire des cookies first-party, description des préférences et informations sur le widget TV.';
|
||||||
|
$keywords = 'politique de cookies, consentement cookies, programmetelecesoir.net, cookies programme tv';
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<?php render_site_head($pageTitle, $fallbackDescription, $keywords, true); ?>
|
||||||
|
</head>
|
||||||
|
<body class="app-body legal-body" data-page="cookies">
|
||||||
|
<?php render_site_nav('cookies'); ?>
|
||||||
|
<main class="site-main legal-main">
|
||||||
|
<section class="legal-hero" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="legal-shell">
|
||||||
|
<nav aria-label="Fil d'Ariane" class="breadcrumb-wrap">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="/">Accueil</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Politique de cookies</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<span class="eyebrow mt-4">Document légal</span>
|
||||||
|
<h1>Politique de cookies de programmetelecesoir.net</h1>
|
||||||
|
<p class="lead legal-lead">Cette politique explique de manière détaillée quels cookies et traceurs sont utilisés sur le site, à quoi ils servent, combien de temps ils sont conservés et comment vous pouvez modifier vos préférences à tout moment.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-shell legal-shell">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Inventaire</span>
|
||||||
|
<h2>Liste des cookies maîtrisés par le site</h2>
|
||||||
|
<p>La présente version du site limite volontairement les cookies first-party au consentement, à la personnalisation locale et au compteur visiteurs interne activé sur choix. Aucun cookie publicitaire n'est mis en place par programmetelecesoir.net.</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table legal-table align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Finalité</th>
|
||||||
|
<th>Durée</th>
|
||||||
|
<th>Nécessaire</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>ptcs_consent</code></td>
|
||||||
|
<td>Cookie first-party</td>
|
||||||
|
<td>Mémoriser vos choix de consentement (essentiels, personnalisation, audience locale) et éviter de vous redemander vos préférences à chaque visite.</td>
|
||||||
|
<td>6 mois</td>
|
||||||
|
<td>Oui</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ptcs_visit_id</code></td>
|
||||||
|
<td>Cookie first-party optionnel</td>
|
||||||
|
<td>Attribuer un identifiant aléatoire afin d'alimenter le compteur visiteurs en direct, journalier et total, sans recourir à un service tiers publicitaire.</td>
|
||||||
|
<td>6 mois</td>
|
||||||
|
<td>Non</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Aucun cookie publicitaire ou analytics tiers</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>La version actuelle ne déploie ni cookie de retargeting, ni balise marketing, ni solution d'analyse externe.</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>—</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-shell legal-shell">
|
||||||
|
<div class="content-grid legal-grid">
|
||||||
|
<article class="article-panel">
|
||||||
|
<h2>1. Pourquoi une bannière cookies sur ce site ?</h2>
|
||||||
|
<p>La bannière permet d'expliquer clairement la présence du cookie essentiel de consentement et de vous donner la main sur les traitements optionnels disponibles dans cette première version. Le bouton <strong>“Continuer sans accepter”</strong> enregistre un réglage minimal : seuls les éléments essentiels restent actifs. Le bouton <strong>“Tout accepter”</strong> active les préférences optionnelles prévues. Le bouton <strong>“Enregistrer mes choix”</strong> tient compte précisément de vos toggles.</p>
|
||||||
|
|
||||||
|
<h2>2. À quoi correspondent les catégories proposées ?</h2>
|
||||||
|
<ul class="legal-list">
|
||||||
|
<li><strong>Essentiels :</strong> ils assurent la mémorisation de votre choix de confidentialité et le fonctionnement minimum de l'interface de consentement. Cette catégorie ne peut pas être désactivée.</li>
|
||||||
|
<li><strong>Personnalisation :</strong> elle permet de mémoriser localement des préférences d'interface sur votre appareil, par exemple le rappel concernant le défilement horizontal du widget sur mobile.</li>
|
||||||
|
<li><strong>Mesure locale d'audience :</strong> elle active le cookie optionnel <code>ptcs_visit_id</code> et un compteur first-party dans la base locale du site afin d'afficher les visiteurs en direct, le nombre de visiteurs du jour et le total cumulé. Aucun service tiers d'analyse n'est branché dans cette version.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Et le widget TV externe ?</h2>
|
||||||
|
<p>Le service principal du site consiste à afficher un programme TV “en ce moment”. Le widget TV est donc traité comme un élément nécessaire à la prestation expressément attendue sur la page d'accueil. Il est chargé depuis un service tiers, <strong>tv-programme.com</strong>, afin d'afficher les grilles et contenus correspondants. Lorsqu'un service tiers est sollicité, il peut techniquement recevoir certaines informations de connexion liées à votre navigateur. Pour connaître les règles exactes applicables à ce prestataire, il convient également de consulter sa propre documentation.</p>
|
||||||
|
|
||||||
|
<h2>4. Comment modifier vos choix plus tard ?</h2>
|
||||||
|
<p>Un bouton flottant en bas à gauche de l'écran permet de rouvrir à tout moment le centre de préférences. Les toggles sont resynchronisés avec votre choix enregistré afin de vous permettre de l'ajuster immédiatement, sans rechargement complexe ni perte de navigation. Si vous retirez votre consentement pour la mesure d'audience, le cookie <code>ptcs_visit_id</code> est supprimé et la visite en cours n'est plus alimentée dans le compteur.</p>
|
||||||
|
</article>
|
||||||
|
<aside class="aside-panels">
|
||||||
|
<article class="info-panel">
|
||||||
|
<h3>Durée de conservation</h3>
|
||||||
|
<p>Le cookie de consentement et le cookie optionnel de visite sont conservés pendant <strong>6 mois</strong>. Les horodatages anonymes associés au compteur interne sont conservés au maximum <strong>13 mois</strong>, tandis que le total cumulé affiché peut être maintenu sous forme agrégée sans identifiant direct.</p>
|
||||||
|
</article>
|
||||||
|
<article class="info-panel">
|
||||||
|
<h3>Base légale</h3>
|
||||||
|
<p>Les traceurs essentiels reposent sur l'intérêt légitime et la nécessité de conserver la preuve de votre choix. Les fonctionnalités optionnelles ne sont activées qu'après votre action explicite.</p>
|
||||||
|
</article>
|
||||||
|
<article class="info-panel">
|
||||||
|
<h3>Contact DPO</h3>
|
||||||
|
<p><strong><?= e($site['dpo_name']) ?></strong><br><?= e($site['dpo_address']) ?><br>Tél. <?= e($site['dpo_phone']) ?></p>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<?php render_site_footer(); ?>
|
||||||
|
<?php render_cookie_controls(); ?>
|
||||||
|
<?php render_site_scripts(); ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
92
reglement.php
Normal file
92
reglement.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/site.php';
|
||||||
|
|
||||||
|
$site = site_settings();
|
||||||
|
$pageTitle = 'Règlement d\'utilisation | ' . $site['domain'];
|
||||||
|
$fallbackDescription = 'Règlement d\'utilisation de programmetelecesoir.net : accès au site, propriété intellectuelle, responsabilité, disponibilité et règles de consultation.';
|
||||||
|
$keywords = 'règlement d\'utilisation, conditions d\'utilisation, mentions essentielles, programmetelecesoir.net';
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<?php render_site_head($pageTitle, $fallbackDescription, $keywords, true); ?>
|
||||||
|
</head>
|
||||||
|
<body class="app-body legal-body" data-page="rules">
|
||||||
|
<?php render_site_nav('rules'); ?>
|
||||||
|
<main class="site-main legal-main">
|
||||||
|
<section class="legal-hero" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="legal-shell">
|
||||||
|
<nav aria-label="Fil d'Ariane" class="breadcrumb-wrap">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="/">Accueil</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Règlement d'utilisation</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<span class="eyebrow mt-4">Cadre d'usage</span>
|
||||||
|
<h1>Règlement d'utilisation du site</h1>
|
||||||
|
<p class="lead legal-lead">Le présent règlement encadre l'utilisation de programmetelecesoir.net, précise l'objet du service, les responsabilités respectives, les règles de disponibilité et les bonnes pratiques applicables à la consultation du contenu.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-block" data-track-section>
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-shell legal-shell">
|
||||||
|
<div class="content-grid legal-grid">
|
||||||
|
<article class="article-panel">
|
||||||
|
<h2>1. Objet du site</h2>
|
||||||
|
<p>Programmetelecesoir.net est une page éditoriale conçue pour faciliter l'accès au programme TV ce soir et au direct en ce moment. Le site met en avant un widget externe nécessaire au service principal, complété par des contenus éditoriaux, des sections d'aide et des documents juridiques.</p>
|
||||||
|
|
||||||
|
<h2>2. Conditions d'accès</h2>
|
||||||
|
<p>L'accès au site est libre, sous réserve d'une connexion internet compatible. L'utilisateur s'engage à ne pas détourner l'usage du site, à ne pas perturber son bon fonctionnement et à respecter les lois et règlements applicables lors de sa navigation.</p>
|
||||||
|
|
||||||
|
<h2>3. Propriété intellectuelle</h2>
|
||||||
|
<p>Les textes, compositions, éléments graphiques, structures de mise en page et contenus créés pour programmetelecesoir.net sont protégés par les règles applicables à la propriété intellectuelle. Les marques, logos, titres d'émissions, programmes ou contenus tiers restent la propriété de leurs ayants droit respectifs.</p>
|
||||||
|
|
||||||
|
<h2>4. Services tiers et sources externes</h2>
|
||||||
|
<p>Le site s'appuie sur un widget TV embarqué fourni par un service externe afin d'afficher l'information “en ce moment”. La disponibilité, l'exactitude et l'éventuelle évolution de ce service tiers relèvent également de son éditeur. Le site présente clairement la source du widget au sein de la page d'accueil.</p>
|
||||||
|
|
||||||
|
<h2>5. Disponibilité et maintenance</h2>
|
||||||
|
<p>Le responsable s'efforce de maintenir le site accessible et fonctionnel. Des interruptions temporaires peuvent intervenir pour maintenance, mise à jour, incident technique, saturation du réseau ou indisponibilité du fournisseur tiers. Aucune garantie de disponibilité continue absolue n'est donnée.</p>
|
||||||
|
|
||||||
|
<h2>6. Responsabilité</h2>
|
||||||
|
<p>Le site a une vocation d'information et d'accès rapide. L'utilisateur reste libre de vérifier le programme final auprès des diffuseurs concernés. Le responsable ne pourra être tenu responsable d'un changement de grille, d'une annulation de programme, d'une erreur provenant d'une source tierce ou d'une indisponibilité ponctuelle indépendante de sa volonté.</p>
|
||||||
|
|
||||||
|
<h2>7. Données personnelles et cookies</h2>
|
||||||
|
<p>L'utilisation du site emporte l'application des documents dédiés à la confidentialité et aux cookies. La bannière de consentement, le bouton flottant de rappel, le compteur visiteurs optionnel et les pages légales sont conçus pour permettre à l'utilisateur de comprendre et d'ajuster ses choix simplement.</p>
|
||||||
|
|
||||||
|
<h2>8. Contact</h2>
|
||||||
|
<p>Pour toute question relative au fonctionnement du site, à la confidentialité ou à l'exercice de vos droits, vous pouvez contacter le DPO déclaré : <strong><?= e($site['dpo_name']) ?></strong>, <?= e($site['dpo_address']) ?>, tél. <?= e($site['dpo_phone']) ?>.</p>
|
||||||
|
|
||||||
|
<h2>9. Hébergement</h2>
|
||||||
|
<p>L'hébergement du site est assuré par <strong><?= e($site['host_name']) ?></strong>.</p>
|
||||||
|
|
||||||
|
<h2>10. Droit applicable</h2>
|
||||||
|
<p>Le présent règlement est rédigé en français et s'interprète conformément au droit applicable au site et à son lieu d'exploitation. En cas de litige, une résolution amiable sera privilégiée avant toute démarche contentieuse.</p>
|
||||||
|
</article>
|
||||||
|
<aside class="aside-panels">
|
||||||
|
<article class="info-panel">
|
||||||
|
<h3>Usage recommandé</h3>
|
||||||
|
<p>Utilisez les ancres de navigation pour atteindre rapidement le widget, la FAQ et les documents légaux sans perdre le fil de lecture.</p>
|
||||||
|
</article>
|
||||||
|
<article class="info-panel">
|
||||||
|
<h3>Vie privée</h3>
|
||||||
|
<p>Le bouton flottant de rappel des cookies reste affiché en bas à gauche afin que le réglage soit toujours disponible.</p>
|
||||||
|
</article>
|
||||||
|
<article class="info-panel">
|
||||||
|
<h3>Transparence</h3>
|
||||||
|
<p>La source du widget TV est visible dans la page principale et les informations de contact du DPO sont répétées dans les documents juridiques.</p>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<?php render_site_footer(); ?>
|
||||||
|
<?php render_cookie_controls(); ?>
|
||||||
|
<?php render_site_scripts(); ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4
robots.txt
Normal file
4
robots.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://programmetelecesoir.net/sitemap.xml
|
||||||
375
site.php
Normal file
375
site.php
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
@date_default_timezone_set('Europe/Paris');
|
||||||
|
|
||||||
|
function site_asset_version(): string
|
||||||
|
{
|
||||||
|
static $version = null;
|
||||||
|
if ($version !== null) {
|
||||||
|
return $version;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths = [
|
||||||
|
__DIR__ . '/assets/css/custom.css',
|
||||||
|
__DIR__ . '/assets/js/main.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
$mtime = 0;
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
if (is_file($path)) {
|
||||||
|
$mtime = max($mtime, (int) filemtime($path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = (string) ($mtime ?: time());
|
||||||
|
return $version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function site_request_scheme(): string
|
||||||
|
{
|
||||||
|
$cfVisitor = (string) ($_SERVER['HTTP_CF_VISITOR'] ?? '');
|
||||||
|
if ($cfVisitor !== '') {
|
||||||
|
$decoded = json_decode($cfVisitor, true);
|
||||||
|
if (is_array($decoded) && isset($decoded['scheme']) && in_array($decoded['scheme'], ['http', 'https'], true)) {
|
||||||
|
return $decoded['scheme'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$forwardedProto = (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '');
|
||||||
|
if ($forwardedProto !== '') {
|
||||||
|
$proto = strtolower(trim(explode(',', $forwardedProto)[0]));
|
||||||
|
if (in_array($proto, ['http', 'https'], true)) {
|
||||||
|
return $proto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestScheme = strtolower((string) ($_SERVER['REQUEST_SCHEME'] ?? ''));
|
||||||
|
if (in_array($requestScheme, ['http', 'https'], true)) {
|
||||||
|
return $requestScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
function site_request_host(): string
|
||||||
|
{
|
||||||
|
$host = strtolower(trim((string) ($_SERVER['HTTP_HOST'] ?? '')));
|
||||||
|
if ($host === '') {
|
||||||
|
return 'programmetelecesoir.net';
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = preg_replace('/:\d+$/', '', $host) ?? $host;
|
||||||
|
return $host !== '' ? $host : 'programmetelecesoir.net';
|
||||||
|
}
|
||||||
|
|
||||||
|
function site_is_local_host(string $host): bool
|
||||||
|
{
|
||||||
|
return in_array($host, ['127.0.0.1', 'localhost'], true)
|
||||||
|
|| str_ends_with($host, '.local');
|
||||||
|
}
|
||||||
|
|
||||||
|
function site_enforce_public_url(): void
|
||||||
|
{
|
||||||
|
if (PHP_SAPI === 'cli') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$canonicalDomain = 'programmetelecesoir.net';
|
||||||
|
$host = site_request_host();
|
||||||
|
if ($host === '' || site_is_local_host($host)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = site_request_scheme();
|
||||||
|
if ($host === $canonicalDomain && $scheme === 'https') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
|
||||||
|
if ($requestUri === '') {
|
||||||
|
$requestUri = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Vary: Host, X-Forwarded-Proto, CF-Visitor', false);
|
||||||
|
header('Location: https://' . $canonicalDomain . $requestUri, true, 301);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
site_enforce_public_url();
|
||||||
|
|
||||||
|
function site_settings(): array
|
||||||
|
{
|
||||||
|
static $settings = null;
|
||||||
|
if ($settings !== null) {
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = site_request_scheme();
|
||||||
|
$canonicalDomain = 'programmetelecesoir.net';
|
||||||
|
$host = site_request_host();
|
||||||
|
$projectName = (string) ($_SERVER['PROJECT_NAME'] ?? '');
|
||||||
|
if ($projectName === '' || strcasecmp($projectName, 'programmetelecesoir.fr') === 0) {
|
||||||
|
$projectName = $canonicalDomain;
|
||||||
|
} else {
|
||||||
|
$projectName = str_ireplace('programmetelecesoir.fr', $canonicalDomain, $projectName);
|
||||||
|
}
|
||||||
|
$projectDescription = str_ireplace('programmetelecesoir.fr', $canonicalDomain, (string) ($_SERVER['PROJECT_DESCRIPTION'] ?? ''));
|
||||||
|
|
||||||
|
$settings = [
|
||||||
|
'domain' => $canonicalDomain,
|
||||||
|
'project_name' => $projectName,
|
||||||
|
'project_description' => $projectDescription,
|
||||||
|
'project_image_url' => $_SERVER['PROJECT_IMAGE_URL'] ?? '',
|
||||||
|
'google_site_verification' => 'uNUUwP2X_y7thS7ulDRafX0wNLRuC1l2Xj39FaiOZoM',
|
||||||
|
'base_url' => $scheme . '://' . $host,
|
||||||
|
'canonical_base_url' => 'https://' . $canonicalDomain,
|
||||||
|
'asset_version' => site_asset_version(),
|
||||||
|
'owner_name' => 'M LORENTE CHRISTOPHE',
|
||||||
|
'dpo_name' => 'M LORENTE CHRISTOPHE',
|
||||||
|
'dpo_address' => '7 rue Lucien Deneau – 28300 Mainvilliers',
|
||||||
|
'dpo_phone' => '06 58 22 59 16',
|
||||||
|
'host_name' => 'FLATLOGIC.COM',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function e(mixed $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function site_current_path(): string
|
||||||
|
{
|
||||||
|
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$path = strtok($requestUri, '?');
|
||||||
|
if ($path === false || $path === '' || $path === '/index.php') {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function site_asset_url(string $relativePath): string
|
||||||
|
{
|
||||||
|
return $relativePath . '?v=' . rawurlencode(site_asset_version());
|
||||||
|
}
|
||||||
|
|
||||||
|
function site_has_saved_consent(): bool
|
||||||
|
{
|
||||||
|
$rawConsent = $_COOKIE['ptcs_consent'] ?? null;
|
||||||
|
if (!is_string($rawConsent) || $rawConsent === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($rawConsent, true);
|
||||||
|
return is_array($decoded) && array_key_exists('essential', $decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function site_is_home_request(): bool
|
||||||
|
{
|
||||||
|
return in_array(site_current_path(), ['/', '/index.php'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function site_should_lock_cookie_overlay(): bool
|
||||||
|
{
|
||||||
|
return site_is_home_request() && !site_has_saved_consent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_site_head(string $pageTitle, string $fallbackDescription, string $keywords = '', bool $noindex = false): void
|
||||||
|
{
|
||||||
|
$site = site_settings();
|
||||||
|
$projectDescription = $site['project_description'];
|
||||||
|
$projectImageUrl = $site['project_image_url'];
|
||||||
|
$canonical = ($site['canonical_base_url'] ?? $site['base_url']) . site_current_path();
|
||||||
|
$description = $fallbackDescription !== '' ? $fallbackDescription : $projectDescription;
|
||||||
|
?>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title><?= e($pageTitle) ?></title>
|
||||||
|
<meta name="description" content="<?= e($description) ?>" />
|
||||||
|
<?php if (!empty($site['google_site_verification'])): ?>
|
||||||
|
<meta name="google-site-verification" content="<?= e($site['google_site_verification']) ?>" />
|
||||||
|
<?php endif; ?>
|
||||||
|
<meta property="og:description" content="<?= e($description) ?>" />
|
||||||
|
<meta property="twitter:description" content="<?= e($description) ?>" />
|
||||||
|
<?php if ($projectImageUrl): ?>
|
||||||
|
<meta property="og:image" content="<?= e($projectImageUrl) ?>" />
|
||||||
|
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>" />
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($keywords !== ''): ?>
|
||||||
|
<meta name="keywords" content="<?= e($keywords) ?>" />
|
||||||
|
<?php endif; ?>
|
||||||
|
<meta name="author" content="<?= e($site['owner_name']) ?>" />
|
||||||
|
<meta name="theme-color" content="#111827" />
|
||||||
|
<meta property="og:site_name" content="<?= e($site['domain']) ?>" />
|
||||||
|
<meta property="og:title" content="<?= e($pageTitle) ?>" />
|
||||||
|
<meta name="twitter:title" content="<?= e($pageTitle) ?>" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="<?= e($canonical) ?>" />
|
||||||
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
|
<link rel="canonical" href="<?= e($canonical) ?>" />
|
||||||
|
<?php if ($noindex): ?>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<?php else: ?>
|
||||||
|
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
|
||||||
|
<?php endif; ?>
|
||||||
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="<?= e(site_asset_url('assets/css/custom.css')) ?>">
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function nav_link(string $label, string $href, bool $active = false, bool $sectionLink = false): string
|
||||||
|
{
|
||||||
|
$className = 'nav-link';
|
||||||
|
if ($active) {
|
||||||
|
$className .= ' active';
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataAttr = $sectionLink ? ' data-section-link="1"' : '';
|
||||||
|
$current = $active ? ' aria-current="page"' : '';
|
||||||
|
return '<a class="' . e($className) . '" href="' . e($href) . '"' . $current . $dataAttr . '>' . e($label) . '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_site_nav(string $current = 'home'): void
|
||||||
|
{
|
||||||
|
$site = site_settings();
|
||||||
|
?>
|
||||||
|
<header class="site-header">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/"><?= e($site['domain']) ?></a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavbar" aria-controls="mainNavbar" aria-expanded="false" aria-label="Afficher la navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="mainNavbar">
|
||||||
|
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-1">
|
||||||
|
<li class="nav-item"><?= nav_link('En ce moment', '/#widget-section', $current === 'home', true) ?></li>
|
||||||
|
<li class="nav-item"><?= nav_link('Guide TV', '/#guide', false, true) ?></li>
|
||||||
|
<li class="nav-item"><?= nav_link('Chaînes', '/#chaines', false, true) ?></li>
|
||||||
|
<li class="nav-item"><?= nav_link('FAQ', '/#faq', false, true) ?></li>
|
||||||
|
<li class="nav-item"><?= nav_link('Cookies', '/politique-cookies.php', $current === 'cookies') ?></li>
|
||||||
|
<li class="nav-item"><?= nav_link('Confidentialité', '/politique-confidentialite.php', $current === 'privacy') ?></li>
|
||||||
|
<li class="nav-item"><?= nav_link('Règlement', '/reglement.php', $current === 'rules') ?></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_site_footer(): void
|
||||||
|
{
|
||||||
|
$site = site_settings();
|
||||||
|
?>
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-grid">
|
||||||
|
<div>
|
||||||
|
<div class="footer-brand"><?= e($site['domain']) ?></div>
|
||||||
|
<p class="footer-copy">Guide éditorial et page d'accès rapide au programme TV ce soir, au direct en ce moment et aux principales chaînes TNT.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="footer-title">Documents</div>
|
||||||
|
<ul class="footer-links list-unstyled mb-0">
|
||||||
|
<li><a href="/politique-cookies.php">Politique de cookies</a></li>
|
||||||
|
<li><a href="/politique-confidentialite.php">Politique de confidentialité</a></li>
|
||||||
|
<li><a href="/reglement.php">Règlement d'utilisation</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="footer-title">DPO & contact</div>
|
||||||
|
<address class="footer-copy mb-0">
|
||||||
|
<?= e($site['dpo_name']) ?><br>
|
||||||
|
<?= e($site['dpo_address']) ?><br>
|
||||||
|
Tél. <?= e($site['dpo_phone']) ?>
|
||||||
|
</address>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="footer-title">Hébergement</div>
|
||||||
|
<p class="footer-copy mb-0">Hébergeur déclaré : <?= e($site['host_name']) ?>.</p>
|
||||||
|
<button type="button" class="link-button mt-2" id="footer-cookie-settings">Revoir mon choix cookies</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_cookie_controls(): void
|
||||||
|
{
|
||||||
|
$hasSavedConsent = site_has_saved_consent();
|
||||||
|
$showBanner = !$hasSavedConsent;
|
||||||
|
$lockHome = site_should_lock_cookie_overlay();
|
||||||
|
?>
|
||||||
|
<div class="cookie-floating"<?= $showBanner ? ' hidden' : '' ?>>
|
||||||
|
<button type="button" class="cookie-reopen" id="cookie-reopen" aria-controls="cookie-banner" aria-expanded="false">
|
||||||
|
<span class="cookie-reopen__dot" aria-hidden="true"></span>
|
||||||
|
<span id="cookie-reopen-label">Cookies : essentiels</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cookie-overlay" id="cookie-overlay" aria-hidden="true"<?= $lockHome ? '' : ' hidden' ?>></div>
|
||||||
|
|
||||||
|
<div class="cookie-banner<?= $lockHome ? ' cookie-banner--modal' : '' ?>" id="cookie-banner" role="dialog" aria-modal="<?= $lockHome ? 'true' : 'false' ?>" aria-labelledby="cookie-banner-title" aria-describedby="cookie-banner-desc" tabindex="-1"<?= $showBanner ? '' : ' hidden' ?>>
|
||||||
|
<div class="cookie-banner__top">
|
||||||
|
<div>
|
||||||
|
<p class="cookie-banner__eyebrow mb-2">Préférences cookies</p>
|
||||||
|
<h2 class="cookie-banner__title" id="cookie-banner-title">Gérez votre confidentialité</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cookie-banner__intro" id="cookie-banner-desc">Le widget TV nécessaire au service reste actif pour afficher le programme en temps réel. Les traceurs optionnels sont désactivés par défaut tant que vous n'avez pas choisi. Si vous activez la mesure locale d'audience, un identifiant first-party anonyme alimente le compteur visiteurs en direct, journalier et total. Sur la page d'accueil, l'overlay bloque la navigation jusqu'à l'enregistrement d'un choix.</p>
|
||||||
|
<div class="cookie-banner__toggles">
|
||||||
|
<div class="cookie-switch-row">
|
||||||
|
<div>
|
||||||
|
<strong>Essentiels</strong>
|
||||||
|
<p>Conservent votre choix de consentement et la sécurité minimale du site.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch m-0">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="cookie-essential" checked disabled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cookie-switch-row">
|
||||||
|
<div>
|
||||||
|
<label class="cookie-switch-label" for="cookie-personalization">Personnalisation</label>
|
||||||
|
<p>Mémorise vos préférences d'interface sur cet appareil, comme le rappel de lecture mobile.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch m-0">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="cookie-personalization" data-consent-control="personalization">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cookie-switch-row">
|
||||||
|
<div>
|
||||||
|
<label class="cookie-switch-label" for="cookie-audience">Mesure locale d'audience</label>
|
||||||
|
<p>Active un compteur first-party anonyme pour afficher les visiteurs en direct, les visiteurs du jour et le total, sans service tiers ni publicité.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch m-0">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="cookie-audience" data-consent-control="audience">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cookie-banner__actions">
|
||||||
|
<button type="button" class="btn btn-light btn-refined" data-cookie-action="reject">Continuer sans accepter</button>
|
||||||
|
<button type="button" class="btn btn-outline-dark btn-refined" data-cookie-action="save">Enregistrer mes choix</button>
|
||||||
|
<button type="button" class="btn btn-dark btn-refined" data-cookie-action="accept">Tout accepter</button>
|
||||||
|
</div>
|
||||||
|
<div class="cookie-banner__links">
|
||||||
|
<a href="/politique-cookies.php">Voir la politique de cookies</a>
|
||||||
|
<span aria-hidden="true">•</span>
|
||||||
|
<a href="/politique-confidentialite.php">Confidentialité</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-stack" id="toast-stack" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_site_scripts(): void
|
||||||
|
{
|
||||||
|
?>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
|
||||||
|
<script src="<?= e(site_asset_url('assets/js/main.js')) ?>" defer></script>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
8
sitemap.xml
Normal file
8
sitemap.xml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://programmetelecesoir.net/</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
Loading…
x
Reference in New Issue
Block a user