Init Git Prod Mars 2026
This commit is contained in:
commit
d81efe93b1
362 files changed
+86627
No files matched your search
@@ -0,0 +1,6 @@
|
||||
/vendor
|
||||
/.env
|
||||
/.env.*
|
||||
/.vscode
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# --- Sécurité : en-têtes globaux ---
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-Frame-Options "DENY"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||
Header always set Cross-Origin-Opener-Policy "same-origin"
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
# CSP en observation (ne bloque rien pour l’instant)
|
||||
# --- CSP (global) : autorise Matomo + CDNs ---
|
||||
Header always set Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data: https: https://staging.uslugi.fr/matomo/; script-src 'self' 'unsafe-inline' https://staging.uslugi.fr/matomo/ https://cdn.jsdelivr.net https://www.googletagmanager.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-ancestors 'none'"
|
||||
|
||||
</IfModule>
|
||||
|
||||
# Réduire surface d’attaque
|
||||
Options -Indexes -MultiViews
|
||||
|
||||
# --- Réécriture & HTTPS ---
|
||||
RewriteEngine On
|
||||
|
||||
# Forcer HTTPS (OK derrière proxy/Varnish)
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteCond %{HTTP:X-Forwarded-Proto} !https
|
||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
|
||||
# Laisser passer Let's Encrypt
|
||||
RewriteRule ^\.well-known/acme-challenge/ - [L]
|
||||
|
||||
# --- Protection chemins & fichiers sensibles ---
|
||||
# Bloquer VCS
|
||||
RewriteRule ^(\.git|\.svn|\.hg)(/|$) - [F]
|
||||
# Bloquer dotfiles (sauf .well-known)
|
||||
RewriteRule ^\.(?!well-known/) - [F]
|
||||
# Bloquer extensions sensibles
|
||||
RewriteRule \.(env|ini|log|bak|old|sql|zip|tar|gz|7z)$ - [F]
|
||||
# Bloquer fichiers sauvegardes *~
|
||||
RewriteRule ^(.*/)?[^/]+~$ - [F]
|
||||
# Bloquer certains noms explicites
|
||||
<FilesMatch "^(config\.php|config\.sample\.php|composer\.json|composer\.lock|package\.json|yarn\.lock)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
# Ceinture+bretelles : jamais de scripts dans /uploads
|
||||
RewriteRule ^(?:public/)?uploads/.*\.(php|phtml|phar|phps|cgi|pl|py|sh|rb)$ - [F,NC]
|
||||
|
||||
# --- Routage applicatif ---
|
||||
# 0) Servir les fichiers/dirs existants
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# 1) (optionnel) Normaliser la barre finale (sauf /)
|
||||
RewriteCond %{REQUEST_URI} !^/$
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [R=301,L]
|
||||
|
||||
# 2) URL "propres" sans extension -> router.php
|
||||
RewriteRule ^([^.]+)$ router.php?slug=$1 [L,QSA]
|
||||
|
||||
# 3) Fallback général
|
||||
RewriteRule ^ index.php [L,QSA]
|
||||
|
||||
# --- PWA / Types & cache SW ---
|
||||
AddType application/manifest+json .webmanifest
|
||||
|
||||
<Files "sw.js">
|
||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</Files>
|
||||
|
||||
# Activer des en-têtes différents uniquement pour /visio/join.php
|
||||
SetEnvIf Request_URI "^/visio/join\.php$" IS_VISIO=1
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header always unset Permissions-Policy env=IS_VISIO
|
||||
Header always unset Feature-Policy env=IS_VISIO
|
||||
Header always set Permissions-Policy "camera=(self 'https://meet.uslugi.fr'), microphone=(self 'https://meet.uslugi.fr'), display-capture=(self 'https://meet.uslugi.fr'), fullscreen=(self 'https://meet.uslugi.fr')" env=IS_VISIO
|
||||
Header always set Feature-Policy "camera 'self' https://meet.uslugi.fr; microphone 'self' https://meet.uslugi.fr" env=IS_VISIO
|
||||
|
||||
Header always set Content-Security-Policy "default-src 'self'; frame-src https://meet.uslugi.fr; script-src 'self' 'unsafe-inline' https://meet.uslugi.fr; connect-src 'self' https://meet.uslugi.fr wss://meet.uslugi.fr; img-src 'self' data: https://meet.uslugi.fr; style-src 'self' 'unsafe-inline' https://meet.uslugi.fr" env=IS_VISIO
|
||||
</IfModule>
|
||||
|
||||
DirectoryIndex index.php index.html
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// 403.php à la racine
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
|
||||
// Statut HTTP
|
||||
http_response_code(403);
|
||||
|
||||
// Titre / textes (fallbacks RU/FR)
|
||||
$title = $translations['err_403_title'] ?? '403 — Accès refusé';
|
||||
$h1 = $translations['err_403_h1'] ?? 'Accès refusé';
|
||||
$txt = $translations['err_403_text'] ?? 'Vous n’êtes pas autorisé à consulter cette page.';
|
||||
|
||||
$btnLogin = $translations['err_403_login'] ?? 'Se connecter';
|
||||
$btnHome = $translations['err_403_home'] ?? 'Retour à l’accueil';
|
||||
|
||||
// next safe (chemin interne)
|
||||
$next = $_GET['next'] ?? ($_SERVER['REQUEST_URI'] ?? '/');
|
||||
$next = (is_string($next) && preg_match('#^/[^\\r\\n]*$#', $next)) ? $next : '/';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?= htmlspecialchars($_SESSION['lang'] ?? 'fr', ENT_QUOTES, 'UTF-8') ?>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></title>
|
||||
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
|
||||
|
||||
<!-- Styles du site -->
|
||||
<link rel="stylesheet" href="<?= asset('/includes/site_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/custom_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/mobil_style.css') ?>">
|
||||
|
||||
<!-- Optionnel: Bootstrap si tu veux cohérence avec le reste -->
|
||||
<link href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
/* On reprend ton style "card" propre, mais cohérent avec le site */
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
background:#f7f7f9;
|
||||
font-family: 'Montserrat', system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
padding:16px;
|
||||
}
|
||||
.card403{
|
||||
max-width:560px;
|
||||
width:100%;
|
||||
background:#fff;
|
||||
border:1px solid #e5e7eb;
|
||||
border-radius:14px;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.08);
|
||||
padding:28px;
|
||||
text-align:center;
|
||||
}
|
||||
.logoWrap{display:flex; justify-content:center; margin-bottom:14px;}
|
||||
.logoWrap img{height:42px;}
|
||||
h1{margin:6px 0 10px; font-size:22px;}
|
||||
p{margin:0 0 18px; color:#6b7280;}
|
||||
.rowBtns{display:flex; gap:10px; justify-content:center; flex-wrap:wrap;}
|
||||
.btn403{
|
||||
padding:10px 14px;
|
||||
border-radius:999px;
|
||||
text-decoration:none;
|
||||
display:inline-block;
|
||||
font-weight:600;
|
||||
}
|
||||
.btnPrimary{background:#E03C3F; color:#fff; width:200px; text-align:center;}
|
||||
.btnOutline{border:1px solid #d1d5db; color:#fff; background:#38378B; width:200px; text-align:center;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card403">
|
||||
<div class="logoWrap">
|
||||
<a href="/index.php" aria-label="uslugi.fr">
|
||||
<img src="/public/uploads/img/logo.png" alt="uslugi.fr">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h1><?= htmlspecialchars($h1, ENT_QUOTES, 'UTF-8') ?></h1>
|
||||
<p><?= htmlspecialchars($txt, ENT_QUOTES, 'UTF-8') ?></p>
|
||||
|
||||
<div class="rowBtns">
|
||||
<a class="btn403 btnPrimary" href="/connexion.php?next=<?= rawurlencode($next) ?>">
|
||||
<?= htmlspecialchars($btnLogin, ENT_QUOTES, 'UTF-8') ?>
|
||||
</a>
|
||||
<a class="btn403 btnOutline" href="/">
|
||||
<?= htmlspecialchars($btnHome, ENT_QUOTES, 'UTF-8') ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Lang switch optionnel (si tu veux la même UX) -->
|
||||
<div style="margin-top:14px;">
|
||||
<?php
|
||||
$menuLang = __DIR__ . '/public/includes/menu_langue.php';
|
||||
if (is_file($menuLang)) require $menuLang;
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0"?>
|
||||
<users>
|
||||
<user>99C5BC909F03CBDA911E7598C90E90A6</user>
|
||||
</users>
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
|
||||
// ===== Helpers header (identiques à index.php) =====
|
||||
function is_client_logged_in(): bool {
|
||||
$role = $_SESSION['role'] ?? ($_SESSION['user_type'] ?? '');
|
||||
if (!empty($_SESSION['user_id']) && $role === 'client') return true;
|
||||
if (!empty($_SESSION['client_id'])) return true;
|
||||
if (!empty($_SESSION['reference_id']) && $role === 'client') return true;
|
||||
if (!empty($_SESSION['user_id']) && $role === 'praticien') return true;
|
||||
if (!empty($_SESSION['praticien_id'])) return true;
|
||||
if (!empty($_SESSION['reference_id']) && $role === 'praticien') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function include_header_clean(string $absPath): void {
|
||||
global $translations, $lang;
|
||||
ob_start();
|
||||
include $absPath; // /public/includes/header.php
|
||||
$buf = ob_get_clean() ?: '';
|
||||
|
||||
// 0) DOCTYPE + <html>…</html>
|
||||
$buf = preg_replace('~<!doctype\s+html[^>]*>~i', '', $buf);
|
||||
$buf = preg_replace('~</?html\b[^>]*>~i', '', $buf);
|
||||
|
||||
// 1) retirer <head>…</head> et <body> ouverts/fermés
|
||||
$buf = preg_replace('~<head\b[^>]*>.*?</head>~is', '', $buf);
|
||||
$buf = preg_replace('~</?body\b[^>]*>~i', '', $buf);
|
||||
|
||||
// 2) retirer les <link> CSS “globaux” pour éviter d’écraser la home
|
||||
$buf = preg_replace('~<link[^>]+href="[^"]*(bootstrap[^"]*\.css|mobil_style\.css|site_style\.css|fonts\.googleapis)[^"]*"[^>]*>~i', '', $buf);
|
||||
|
||||
// 3) retirer le <script> Bootstrap CDN (on le charge déjà en bas de la page)
|
||||
$buf = preg_replace('~<script[^>]+src="[^"]*bootstrap[^"]*bundle[^"]*\.js[^"]*"[^>]*>\s*</script>~i', '', $buf);
|
||||
|
||||
echo $buf; // garde <header> + tes scripts inline utiles
|
||||
}
|
||||
|
||||
function t(array $translations, string $key, string $fallback = ''): string {
|
||||
$val = $translations[$key] ?? '';
|
||||
if ($val === '') return $fallback;
|
||||
return (string)$val;
|
||||
}
|
||||
|
||||
// Lang HTML
|
||||
$langHtml = preg_match('~^[a-z]{2}(-[A-Z]{2})?$~', (string)($lang ?? '')) ? (string)$lang : 'ru';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?= htmlspecialchars($langHtml, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><?= htmlspecialchars($translations['seo_about_title'], ENT_QUOTES, 'UTF-8') ?></title>
|
||||
<meta name="description" content="<?= htmlspecialchars($translations['seo_about_desc'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta name="keywords" content="<?= htmlspecialchars($translations['seo_about_keywords'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta name="author" content="Uslugi.fr">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
|
||||
<link rel="stylesheet" href="<?= asset('/includes/site_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/custom_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/mobil_style.css') ?>">
|
||||
<?php include_once __DIR__ . '/includes/tracking.php'; ?>
|
||||
</head>
|
||||
<body>
|
||||
<?php if (is_client_logged_in()): ?>
|
||||
<?php
|
||||
require_once __DIR__ . '/public/includes/init_pr_cl.php';
|
||||
include_header_clean(__DIR__ . '/public/includes/header.php'); ?>
|
||||
<?php else: ?>
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo_u.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-mobile"></a>
|
||||
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-desktop"></a>
|
||||
</div>
|
||||
<div class="lang-switch">
|
||||
<?php require __DIR__ . '/includes/menu_langue.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-actions">
|
||||
<a class="btn-login" href="/connexion.php">
|
||||
<span class="d-sm-none"><?= htmlspecialchars($translations['btn_se_connecter'] ?? 'Connexion', ENT_QUOTES, 'UTF-8') ?></span>
|
||||
<span class="d-none d-sm-inline"><?= htmlspecialchars($translations['ja_spec'] ?? 'Je suis spécialiste', ENT_QUOTES, 'UTF-8') ?></span>
|
||||
</a>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-logo rounded-circle d-inline-flex align-items-center justify-content-center question-btn"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="true"
|
||||
aria-expanded="false"
|
||||
aria-label="<?= htmlspecialchars(t($translations, 'about_help_aria', 'Help'), ENT_QUOTES, 'UTF-8') ?>">
|
||||
?
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow help-menu menu-panel">
|
||||
<li><a href="/how-it-work.php"><?= htmlspecialchars($translations['kak_rabotaet'] ?? 'Как это работает', ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
<li><a href="/about.php"><?= htmlspecialchars($translations['pro_nas'] ?? 'О нас', ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
<li><a href="/faq.php"><?= htmlspecialchars($translations['faq'] ?? 'FAQ', ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
<li><a href="/contact.php"><?= htmlspecialchars($translations['nam_pisat'] ?? 'Написать нам', ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
|
||||
<style>
|
||||
/* Spécifique à la page About (complète ton style existant) */
|
||||
.howitworks { --brand:#38378B; --mint:#06FFA8; --text:#111; --muted:#6b7280; --card:#fff; --border:#e5e7eb; }
|
||||
.howitworks { font-family: "Montserrat", system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; color:var(--text); }
|
||||
.howitworks .wrap { max-width: 980px; margin: 0 auto; padding: clamp(16px,4vw,32px); }
|
||||
.howitworks .card {
|
||||
background: var(--card); border:1px solid var(--border); border-radius:16px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.06); padding: clamp(18px,3.6vw,28px);
|
||||
}
|
||||
.howitworks h2 {
|
||||
font-size: clamp(24px, 5.2vw, 36px); line-height:1.2; margin: 0 0 16px;
|
||||
}
|
||||
.howitworks p { font-size: clamp(14px, 2.8vw, 16px); line-height:1.65; color: var(--muted); margin: 0 0 12px; }
|
||||
|
||||
.hiw-steps{ display:grid; gap:14px; grid-template-columns: 1fr; }
|
||||
.about-grid{display:grid;gap:14px;grid-template-columns:1fr}
|
||||
@media (min-width:780px){.about-grid{grid-template-columns:1fr 1fr}}
|
||||
.about-list{list-style:none;padding:0;margin:8px 0 0;display:grid;gap:10px}
|
||||
.about-item{display:flex;gap:12px;align-items:flex-start;background:#fafafa;border:1px solid #f0f1f4;border-radius:12px;padding:12px 14px}
|
||||
.about-ico{width:38px;height:38px;flex:0 0 38px;display:grid;place-items:center;border-radius:50%;
|
||||
background: color-mix(in oklab, var(--brand) 12%, #fff);
|
||||
border:2px solid color-mix(in oklab, var(--brand) 25%, #fff);
|
||||
color:var(--brand);font-weight:700}
|
||||
.about-item h4{margin:2px 0 4px;font-weight:600;color:#1f2937;font-size:clamp(15px,2.8vw,18px)}
|
||||
.about-item p{margin:0}
|
||||
.about-callout{border-left:6px solid var(--brand);background:color-mix(in oklab,var(--brand) 8%,#fff);
|
||||
border-radius:12px;padding:14px 16px;margin-top:12px}
|
||||
</style>
|
||||
|
||||
<section class="howitworks hiw-band">
|
||||
<div class="wrap">
|
||||
|
||||
<div class="card" style="margin-bottom:18px;">
|
||||
<h2><?= htmlspecialchars(t($translations, 'about_intro_title', 'About'), ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_intro_text', 'uslugi.fr is a platform...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
|
||||
<div class="about-grid">
|
||||
<div class="card">
|
||||
<h2><?= htmlspecialchars(t($translations, 'about_mission_title', 'Our mission'), ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<ul class="about-list">
|
||||
<li class="about-item">
|
||||
<div class="about-ico">🤝</div>
|
||||
<div>
|
||||
<h4><?= htmlspecialchars(t($translations, 'about_mission_item1_title', 'Easy booking'), ENT_QUOTES, 'UTF-8') ?></h4>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_mission_item1_text', 'Book in a few clicks...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="about-item">
|
||||
<div class="about-ico">🛡️</div>
|
||||
<div>
|
||||
<h4><?= htmlspecialchars(t($translations, 'about_mission_item2_title', 'Security'), ENT_QUOTES, 'UTF-8') ?></h4>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_mission_item2_text', 'Data privacy and Jitsi...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="about-item">
|
||||
<div class="about-ico">⏱️</div>
|
||||
<div>
|
||||
<h4><?= htmlspecialchars(t($translations, 'about_mission_item3_title', 'Save time'), ENT_QUOTES, 'UTF-8') ?></h4>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_mission_item3_text', 'Calendar and online consults...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2><?= htmlspecialchars(t($translations, 'about_platform_title', 'How the platform works'), ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<ul class="about-list">
|
||||
<li class="about-item">
|
||||
<div class="about-ico">📚</div>
|
||||
<div>
|
||||
<h4><?= htmlspecialchars(t($translations, 'about_platform_item1_title', 'Specialists catalog'), ENT_QUOTES, 'UTF-8') ?></h4>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_platform_item1_text', 'Lawyers, notaries...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="about-item">
|
||||
<div class="about-ico">🗓️</div>
|
||||
<div>
|
||||
<h4><?= htmlspecialchars(t($translations, 'about_platform_item2_title', 'Online booking'), ENT_QUOTES, 'UTF-8') ?></h4>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_platform_item2_text', 'Choose a time and get email confirmation...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="about-item">
|
||||
<div class="about-ico">💬</div>
|
||||
<div>
|
||||
<h4><?= htmlspecialchars(t($translations, 'about_platform_item3_title', 'Chat before the session'), ENT_QUOTES, 'UTF-8') ?></h4>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_platform_item3_text', 'Ask details directly...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="about-item">
|
||||
<div class="about-ico">🎥</div>
|
||||
<div>
|
||||
<h4><?= htmlspecialchars(t($translations, 'about_platform_item4_title', 'Video calls'), ENT_QUOTES, 'UTF-8') ?></h4>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_platform_item4_text', 'Meetings via Jitsi...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="about-grid" style="margin-top:14px;">
|
||||
<div class="card">
|
||||
<h2><?= htmlspecialchars(t($translations, 'about_clients_title', 'For clients'), ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_clients_text', 'Fast search and booking...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
<div class="about-callout">
|
||||
<strong><?= htmlspecialchars(t($translations, 'about_clients_note_label', 'Important:'), ENT_QUOTES, 'UTF-8') ?></strong>
|
||||
<?= htmlspecialchars(t($translations, 'about_clients_note_text', 'uslugi.fr helps you find...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2><?= htmlspecialchars(t($translations, 'about_specialists_title', 'For specialists'), ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_specialists_text', 'Dashboard, calendar...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
<ul class="about-list">
|
||||
<li class="about-item">
|
||||
<div class="about-ico">🔒</div>
|
||||
<div>
|
||||
<h4><?= htmlspecialchars(t($translations, 'about_specialists_item1_title', 'Data is safe'), ENT_QUOTES, 'UTF-8') ?></h4>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_specialists_item1_text', 'Basic security practices...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="about-item">
|
||||
<div class="about-ico">📈</div>
|
||||
<div>
|
||||
<h4><?= htmlspecialchars(t($translations, 'about_specialists_item2_title', 'Grow your audience'), ENT_QUOTES, 'UTF-8') ?></h4>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_specialists_item2_text', 'Visibility in the catalog...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="card">
|
||||
<h2><?= htmlspecialchars(t($translations, 'about_contact_title', 'Contact us'), ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<p><?= htmlspecialchars(t($translations, 'about_contact_text', 'Have suggestions or questions? Write to us.'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
<div class="text-start">
|
||||
<a class="btn btn-danger" href="/contact.php" style="padding:12px 16px;border-radius:999px;">
|
||||
<?= htmlspecialchars(t($translations, 'about_contact_btn', 'Write to us'), ENT_QUOTES, 'UTF-8') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?= $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
require_once __DIR__ . '/../public/includes/init_admin.php';
|
||||
|
||||
// FIX: cast pour comparaison stricte
|
||||
$refId = (int) ($_SESSION['reference_id'] ?? 0);
|
||||
if ($refId === 88888888):
|
||||
|
||||
// CSRF par session
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
// FIX: back URL sûr (référer interne uniquement)
|
||||
$fallbackBack = '/admin/index.php';
|
||||
if (! empty($_SERVER['HTTP_REFERER'])) {
|
||||
$ref = parse_url($_SERVER['HTTP_REFERER']);
|
||||
if (! empty($ref['host']) && $ref['host'] === ($_SERVER['HTTP_HOST'] ?? '')) {
|
||||
$fallbackBack = $_SERVER['HTTP_REFERER'];
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function h($s)
|
||||
{return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');}
|
||||
function admin_url(string $page, array $params = []): string
|
||||
{
|
||||
$q = http_build_query(array_merge(['page' => $page], $params));
|
||||
return '/admin/index.php?' . $q;
|
||||
}
|
||||
|
||||
// Whitelist des vues
|
||||
$views = [
|
||||
'maintenance' => __DIR__ . '/tools/maintenance.php',
|
||||
'add_video' => __DIR__ . '/tools/add_video.php',
|
||||
'gerer_praticiens' => __DIR__ . '/tools/gerer_praticiens.php',
|
||||
'cleanup_inscriptions' => __DIR__ . '/tools/cleanup_inscriptions.php',
|
||||
'purge_conversations' => __DIR__ . '/tools/purge_conversations.php',
|
||||
'purger_mess_upload' => __DIR__ . '/tools/gc.php',
|
||||
'logs' => __DIR__ . '/tools/logs.php',
|
||||
|
||||
// nouveaux
|
||||
'audit' => __DIR__ . '/tools/audit.php',
|
||||
'reauth' => __DIR__ . '/tools/reauth.php',
|
||||
'backup_db' => __DIR__ . '/tools/backup_db.php',
|
||||
'cleanup_rdv' => __DIR__ . '/tools/cleanup_rdv.php',
|
||||
'afficher_messages' => __DIR__ . '/tools/afficher_messages.php',
|
||||
'new_inscrits' => __DIR__ . '/tools/new_clients.php',
|
||||
'mail_praticiens' => __DIR__ . '/tools/mailing_praticiens.php',
|
||||
'connecter_en_admin' => __DIR__ . '/tools/connecter_en_admin.php',
|
||||
'clean_uploads' => __DIR__ . '/tools/clean_uploads.php',
|
||||
];
|
||||
|
||||
$page = $_GET['page'] ?? null;
|
||||
$key = (is_string($page) && isset($views[$page])) ? $page : 'maintenance';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?php echo h($lang ?? 'ru') ?>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?php echo h($translations['admin_title'] ?? 'Admin') ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Police Montserrat -->
|
||||
|
||||
<script src="/js/sweetalert2@11.js"></script>
|
||||
|
||||
<!-- Bootstrap -->
|
||||
<link href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="/includes/admin_style.css?v=202602">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="site-header py-2 border-bottom">
|
||||
<div class="container d-flex justify-content-between align-items-center">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<a href="<?php echo h($fallbackBack) ?>"
|
||||
id="btnBack"
|
||||
class="btn btn-light btn-circle me-2"
|
||||
style="border-radius:5px; box-shadow:0 1px 2px rgba(0,0,0,.08);"
|
||||
aria-label="<?php echo h($translations['retour'] ?? 'Retour') ?>">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
|
||||
<path d="M15 6l-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a href="/admin/index.php" aria-label="<?php echo h($translations['home'] ?? 'Home') ?>">
|
||||
<img src="/public/uploads/img/logo_u.png" alt="<?php echo h($translations['site_name'] ?? 'uslugi.fr') ?>" class="logo-mobile">
|
||||
<img src="/public/uploads/img/logo.png" alt="<?php echo h($translations['site_name'] ?? 'uslugi.fr') ?>" class="logo-desktop">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="lang-switch">
|
||||
<?php require __DIR__ . '/../includes/menu_langue.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="header-actions d-flex align-items-center gap-3 position-relative">
|
||||
<div class="menu-wrap">
|
||||
<button type="button" id="btnMenu" class="btn p-0 border-0 bg-transparent" aria-label="<?php echo h($translations['menu_admin'] ?? 'Menu') ?>">
|
||||
<div class="avatar d-flex align-items-center justify-content-center"
|
||||
style="width:40px;height:40px;border-radius:50%;background:#38378B;color:white;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M19.14,12.94a7.48,7.48,0,0,0,0-1.88l2.11-1.65a.5.5,0,0,0,.12-.64l-2-3.46a.5.5,0,0,0-.61-.22l-2.49,1a7.28,7.28,0,0,0-1.63-.95l-.38-2.65A.5.5,0,0,0,13.78,3H10.22a.5.5,0,0,0-.49.42L9.35,6.07a7.28,7.28,0,0,0-1.63.95l-2.49-1a.5.5,0,0,0-.61.22l-2,3.46a.5.5,0,0,0,.12.64L4.86,11.06a7.48,7.48,0,0,0,0,1.88L2.75,14.59a.5.5,0,0,0-.12.64l2,3.46a.5.5,0,0,0,.61.22l2.49-1a7.28,7.28,0,0,0,1.63.95l.38,2.65a.5.5,0,0,0,.49.42h3.56a.5.5,0,0,0,.49-.42l.38-2.65a7.28,7.28,0,0,0,1.63-.95l2.49,1a.5.5,0,0,0,.61-.22l2-3.46a.5.5,0,0,0-.12-.64ZM12,15.5A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu-custom menu-panel" id="menu" style="display:none; min-width:220px;">
|
||||
<li onclick="window.open('https://staging.uslugi.fr/matomo/index.php','_blank')">
|
||||
<?php echo h($translations['admin_analytic'] ?? 'Analytics') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('add_video')) ?>'">
|
||||
<?php echo h($translations['add_video'] ?? 'Ajouter une vidéo') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('new_inscrits')) ?>'">
|
||||
<?php echo h($translations['les_inscrits'] ?? 'Les Inscrits') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('mail_praticiens')) ?>'">
|
||||
<?php echo h($translations['mail_praticiens'] ?? 'Рассылка практикующим') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('connecter_en_admin')) ?>'">
|
||||
<?php echo h($translations['connecter_en_admin'] ?? 'Войти от имени пользователя') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('gerer_praticiens')) ?>'">
|
||||
<?php echo h($translations['gerer_praticiens'] ?? 'Gérer praticiens') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('cleanup_inscriptions')) ?>'">
|
||||
<?php echo h($translations['supp_inscr'] ?? 'Supp. inscriptions') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('clean_uploads')) ?>'">
|
||||
<?php echo h($translations['supp_uploads'] ?? 'Supp. uploads') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('purge_conversations')) ?>'">
|
||||
<?php echo h($translations['purger_messages'] ?? 'Supp. messages') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('purger_mess_upload')) ?>'">
|
||||
<?php echo h($translations['purger_mess_upload'] ?? 'Supp. Upload') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('logs')) ?>'">
|
||||
<?php echo h($translations['afficher_logs'] ?? 'Logs') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('audit')) ?>'">
|
||||
<?php echo h($translations['audit'] ?? 'Audit') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('backup_db')) ?>'">
|
||||
<?php echo h($translations['backup_db'] ?? 'Backup DB') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('cleanup_rdv')) ?>'">
|
||||
<?php echo h($translations['cleanup_rdv'] ?? 'Cleanup RDV') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='<?php echo h(admin_url('afficher_messages')) ?>'">
|
||||
<?php echo h($translations['afficher_messages'] ?? 'Afficher Messages') ?>
|
||||
</li>
|
||||
|
||||
<li onclick="location.href='/public/deconnexion.php'" class="text-danger">
|
||||
<?php echo h($translations['menu_deconnexion'] ?? 'Déconnexion') ?>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main1">
|
||||
<div class="container my-3">
|
||||
<?php
|
||||
$_GET['page'] = $key;
|
||||
|
||||
$path = $views[$key] ?? null;
|
||||
if ($path && is_file($path)) {
|
||||
require $path;
|
||||
} else {
|
||||
echo '<div class="alert alert-warning">' . h($translations['admin_view_missing'] ?? 'Vue manquante :') . ' ' . h($key) . '</div>';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleMenu() {
|
||||
const menu = document.getElementById('menu');
|
||||
if (!menu) return;
|
||||
menu.style.display = (menu.style.display === 'block') ? 'none' : 'block';
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
const menu = document.getElementById('menu');
|
||||
if (menu) menu.style.display = 'none';
|
||||
closeSubmenus();
|
||||
}
|
||||
|
||||
document.getElementById('btnMenu')?.addEventListener('click', toggleMenu);
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const menu = document.getElementById('menu');
|
||||
if (!menu) return;
|
||||
const toggle = document.getElementById('btnMenu');
|
||||
if (toggle && !toggle.contains(e.target) && !menu.contains(e.target)) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeMenu();
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('.submenu-toggle');
|
||||
if (!btn) return;
|
||||
|
||||
const submenuId = btn.getAttribute('aria-controls');
|
||||
const submenu = document.getElementById(submenuId);
|
||||
if (!submenu) return;
|
||||
|
||||
const isOpen = btn.getAttribute('aria-expanded') === 'true';
|
||||
btn.setAttribute('aria-expanded', String(!isOpen));
|
||||
submenu.hidden = isOpen;
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
function closeSubmenus() {
|
||||
document
|
||||
.querySelectorAll('.submenu-toggle[aria-expanded="true"]')
|
||||
.forEach(btn => {
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
const sub = document.getElementById(btn.getAttribute('aria-controls'));
|
||||
if (sub) sub.hidden = true;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<?php ob_end_flush(); ?>
|
||||
</body>
|
||||
</html>
|
||||
<?php endif; ?>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// /admin/tools/_lib_audit.php
|
||||
if (!class_exists('AdminAudit')) {
|
||||
final class AdminAudit {
|
||||
public static function log(PDO $pdo, array $d): void {
|
||||
// $d: admin_id, action, scope?, target_table?, target_id?, meta?(array), ip?, ua?
|
||||
$sql = "INSERT INTO admin_audit(admin_id, action, scope, target_table, target_id, meta_json, ip, user_agent)
|
||||
VALUES (:admin_id,:action,:scope,:tt,:tid,:meta,:ip,:ua)";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
':admin_id' => (int)($d['admin_id'] ?? 0),
|
||||
':action' => (string)($d['action'] ?? 'unknown'),
|
||||
':scope' => $d['scope'] ?? null,
|
||||
':tt' => $d['target_table'] ?? null,
|
||||
':tid' => isset($d['target_id']) ? (int)$d['target_id'] : null,
|
||||
':meta' => isset($d['meta']) ? json_encode($d['meta'], JSON_UNESCAPED_UNICODE) : null,
|
||||
':ip' => isset($_SERVER['REMOTE_ADDR']) ? inet_pton($_SERVER['REMOTE_ADDR']) : null,
|
||||
':ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
/** УКАЖИ ID администратора-практиканта здесь */
|
||||
const ADMIN_PRACTICIEN_ID = 88888888; // <-- заменить на реальный ID
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo '<div class="container py-5"><div class="alert alert-danger">'
|
||||
. htmlspecialchars($translations['non_autorise'] ?? 'Доступ запрещён.', ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$mustReauth = function(int $ttl=600){
|
||||
$t=(int)($_SESSION['admin_reauth_at']??0);
|
||||
return ($t===0||(time()-$t)>$ttl);
|
||||
};
|
||||
if ($mustReauth()){
|
||||
$qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]);
|
||||
header('Location:?'.$qs);
|
||||
exit;
|
||||
}
|
||||
|
||||
// -- PDO si absent
|
||||
if (!isset($pdo)) { require_once '../../includes/db.php'; }
|
||||
|
||||
// CSRF
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
// helpers
|
||||
function e($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
||||
|
||||
$flash = ['type'=>null,'msg'=>null];
|
||||
|
||||
// ---- Обработка POST ----
|
||||
$action = $_POST['do'] ?? null;
|
||||
|
||||
try {
|
||||
if ($action === 'update_main') {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) throw new Exception('CSRF');
|
||||
$pid = (int)($_POST['id'] ?? 0);
|
||||
$url = trim($_POST['video'] ?? '');
|
||||
$stmt = $pdo->prepare("UPDATE praticien SET video = :video WHERE id = :id");
|
||||
$stmt->execute(['video' => ($url !== '' ? $url : null), 'id' => $pid]);
|
||||
$flash = ['type'=>'success','msg'=>($translations['admin_video_flash_main_saved'] ?? 'Основное видео сохранено.')];
|
||||
$_GET['id'] = $pid;
|
||||
}
|
||||
|
||||
if ($action === 'add_extra') {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) throw new Exception('CSRF');
|
||||
$pid = (int)($_POST['id'] ?? 0);
|
||||
$url = trim($_POST['url'] ?? '');
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$ordre = (int)($_POST['ordre'] ?? 0);
|
||||
if ($url === '') throw new Exception($translations['admin_video_err_url_required'] ?? 'Укажите URL');
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO praticien_video (praticien_id, url, title, ordre) VALUES (?,?,?,?)");
|
||||
$stmt->execute([$pid, $url, ($title !== '' ? $title : null), $ordre]);
|
||||
$flash = ['type'=>'success','msg'=>($translations['admin_video_flash_added'] ?? 'Видео добавлено.')];
|
||||
$_GET['id'] = $pid;
|
||||
}
|
||||
|
||||
if ($action === 'update_extra') {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) throw new Exception('CSRF');
|
||||
$pid = (int)($_POST['pid'] ?? 0);
|
||||
$vid = (int)($_POST['vid'] ?? 0);
|
||||
$url = trim($_POST['url'] ?? '');
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$ordre = (int)($_POST['ordre'] ?? 0);
|
||||
if ($vid <= 0 || $url === '') throw new Exception($translations['admin_video_err_check_fields'] ?? 'Проверьте поля');
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE praticien_video SET url=?, title=?, ordre=? WHERE id=?");
|
||||
$stmt->execute([$url, ($title !== '' ? $title : null), $ordre, $vid]);
|
||||
$flash = ['type'=>'success','msg'=>($translations['admin_video_flash_updated'] ?? 'Видео обновлено.')];
|
||||
$_GET['id'] = $pid;
|
||||
}
|
||||
|
||||
if ($action === 'delete_extra') {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) throw new Exception('CSRF');
|
||||
$pid = (int)($_POST['pid'] ?? 0);
|
||||
$vid = (int)($_POST['vid'] ?? 0);
|
||||
if ($vid <= 0) throw new Exception($translations['admin_video_err_unknown_id'] ?? 'Неизвестный ID');
|
||||
|
||||
$stmt = $pdo->prepare("DELETE FROM praticien_video WHERE id=?");
|
||||
$stmt->execute([$vid]);
|
||||
$flash = ['type'=>'success','msg'=>($translations['admin_video_flash_deleted'] ?? 'Видео удалено.')];
|
||||
$_GET['id'] = $pid;
|
||||
}
|
||||
} catch (Exception $ex) {
|
||||
$flash = ['type'=>'danger','msg'=>($translations['err_prefixe'] ?? 'Ошибка: ') . $ex->getMessage()];
|
||||
}
|
||||
|
||||
// ---- РЕЖИМ A: список всех практиков ----
|
||||
if (empty($_GET['id'])) {
|
||||
$rows = $pdo->query("
|
||||
SELECT id, titre, nom, prenom, specialite, photo, video
|
||||
FROM praticien
|
||||
ORDER BY nom ASC, prenom ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-3 text-center"><?= e($translations['admin_video_title'] ?? '🎬 Видео практиков — Администрирование') ?></h2>
|
||||
|
||||
<?php if ($flash['msg']): ?>
|
||||
<div class="alert alert-<?= e($flash['type']) ?>"><?= e($flash['msg']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mb-3">
|
||||
<input type="text" class="form-control" id="filter"
|
||||
placeholder="<?= e($translations['admin_video_filter_placeholder'] ?? 'Фильтр (имя, специальность)…') ?>">
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover align-middle mobile-cards" id="tab">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:70px;"><?= e($translations['admin_video_th_photo'] ?? 'Фото') ?></th>
|
||||
<th><?= e($translations['admin_video_th_name'] ?? 'Имя') ?></th>
|
||||
<th><?= e($translations['admin_video_th_specialite'] ?? 'Специальность') ?></th>
|
||||
<th><?= e($translations['admin_video_th_main_video'] ?? 'Основное видео') ?></th>
|
||||
<th class="text-center" style="width:150px;"><?= e($translations['admin_video_th_actions'] ?? 'Действия') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r):
|
||||
$name = trim($r['prenom'].' '.$r['nom']);
|
||||
$photo = !empty($r['photo']) ? '/public/uploads/'.e($r['photo']) : '/public/uploads/img/user_placeholder.png';
|
||||
?>
|
||||
<tr data-hay="<?= e(mb_strtolower($name.' '.$r['specialite'])) ?>">
|
||||
<td><img src="<?= $photo ?>" alt="" class="rounded" style="width:48px;height:48px;object-fit:cover;"></td>
|
||||
<td><?= e($name) ?></td>
|
||||
<td><?= e($r['specialite'] ?? '') ?></td>
|
||||
<td>
|
||||
<?php if (!empty($r['video'])): ?>
|
||||
<span class="badge bg-success"><?= e($translations['admin_video_badge_set'] ?? 'указано') ?></span>
|
||||
<a class="ms-2" href="<?= e($r['video']) ?>" target="_blank" rel="noopener">
|
||||
<?= e($translations['admin_video_open'] ?? 'открыть') ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-secondary"><?= e($translations['admin_video_badge_not_set'] ?? 'не указано') ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<a class="btn btn-sm btn-danger" href="index.php?page=add_video&id=<?= (int)$r['id'] ?>">
|
||||
<?= e($translations['admin_video_manage'] ?? 'Управлять') ?>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Простой фильтр по таблице (на клиенте)
|
||||
document.getElementById('filter').addEventListener('input', function(){
|
||||
const q = (this.value || '').trim().toLowerCase();
|
||||
document.querySelectorAll('#tab tbody tr').forEach(tr=>{
|
||||
const hay = (tr.dataset.hay || '').toLowerCase();
|
||||
tr.style.display = !q || hay.includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- РЕЖИМ B: управление конкретным практиком ----
|
||||
$pid = (int)$_GET['id'];
|
||||
|
||||
$st = $pdo->prepare("SELECT id, titre, nom, prenom, specialite, photo, video FROM praticien WHERE id=?");
|
||||
$st->execute([$pid]);
|
||||
$praticien = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$praticien) {
|
||||
echo '<div class="container py-5"><div class="alert alert-warning">'
|
||||
. e($translations['admin_video_not_found'] ?? 'Практикант не найден.')
|
||||
. '</div><a href="add_video.php" class="btn btn-secondary mt-2">⬅️ '
|
||||
. e($translations['admin_video_back'] ?? 'Назад')
|
||||
. '</a></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$extras = $pdo->prepare("SELECT id, url, title, ordre, created_at FROM praticien_video WHERE praticien_id=? ORDER BY ordre ASC, id DESC");
|
||||
$extras->execute([$pid]);
|
||||
$videos = $extras->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$name = trim($praticien['prenom'].' '.$praticien['nom']);
|
||||
$photo = !empty($praticien['photo']) ? '/public/uploads/'.e($praticien['photo']) : '/public/uploads/img/user_placeholder.png';
|
||||
?>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
|
||||
<a href="index.php?page=add_video" class="btn btn-outline-secondary mb-3" style="border-radius:999px;">
|
||||
⬅️ <?= e($translations['admin_video_back_to_list'] ?? 'Назад к списку') ?>
|
||||
</a>
|
||||
|
||||
<?php if ($flash['msg']): ?>
|
||||
<div class="alert alert-<?= e($flash['type']) ?>"><?= e($flash['msg']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body d-flex gap-3 align-items-center">
|
||||
<img src="<?= $photo ?>" class="rounded" style="width:64px;height:64px;object-fit:cover;" alt="">
|
||||
<div>
|
||||
<h5 class="mb-1"><?= e($name) ?></h5>
|
||||
<div class="text-muted"><?= e($praticien['specialite'] ?? '') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Основное видео (praticien.video) -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light"><strong><?= e($translations['admin_video_main_block_title'] ?? 'Основное видео') ?></strong></div>
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= e($translations['admin_video_main_url_label'] ?? 'URL видео (YouTube, Vimeo, Dailymotion, MP4…)') ?></label>
|
||||
<input type="url" name="video" class="form-control"
|
||||
placeholder="<?= e($translations['admin_video_url_placeholder'] ?? 'https://…') ?>"
|
||||
value="<?= e($praticien['video'] ?? '') ?>">
|
||||
<div class="form-text"><?= e($translations['admin_video_main_help'] ?? 'Поле сохраняется в praticien.video и используется на публичных страницах.') ?></div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$pid ?>">
|
||||
<input type="hidden" name="do" value="update_main">
|
||||
<button class="btn btn-danger" style="border-radius:999px;"><?= e($translations['enregistrer'] ?? 'Сохранить') ?></button>
|
||||
<?php if (!empty($praticien['video'])): ?>
|
||||
<a class="btn btn-outline-secondary ms-2" target="_blank" rel="noopener" href="<?= e($praticien['video']) ?>">
|
||||
<?= e($translations['admin_video_open_video'] ?? 'Открыть видео') ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Дополнительные видео (praticien_video) -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><strong><?= e($translations['admin_video_extra_block_title'] ?? 'Дополнительные видео (необязательно)') ?></strong></div>
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= e($translations['admin_video_title_label_optional'] ?? 'Название (необязательно)') ?></label>
|
||||
<input type="text" name="title" class="form-control"
|
||||
placeholder="<?= e($translations['admin_video_title_placeholder'] ?? 'Презентация кабинета') ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"><?= e($translations['admin_video_order_label'] ?? 'Порядок') ?></label>
|
||||
<input type="number" name="ordre" class="form-control" value="0" min="0" step="1">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= e($translations['admin_video_url_label'] ?? 'URL видео') ?></label>
|
||||
<input type="url" name="url" class="form-control"
|
||||
placeholder="<?= e($translations['admin_video_url_placeholder'] ?? 'https://…') ?>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$pid ?>">
|
||||
<input type="hidden" name="do" value="add_extra">
|
||||
<button class="btn btn-success" style="border-radius:999px;"><?= e($translations['admin_video_add'] ?? 'Добавить') ?></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (empty($videos)): ?>
|
||||
<div class="text-muted"><?= e($translations['admin_video_no_extras'] ?? 'Пока нет дополнительных видео.') ?></div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:80px;"><?= e($translations['admin_video_th_order'] ?? 'Порядок') ?></th>
|
||||
<th><?= e($translations['admin_video_th_title'] ?? 'Название') ?></th>
|
||||
<th><?= e($translations['admin_video_th_url'] ?? 'URL') ?></th>
|
||||
<th style="width:180px;"><?= e($translations['admin_video_th_actions'] ?? 'Действия') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($videos as $v): ?>
|
||||
<tr>
|
||||
<form method="post">
|
||||
<td style="width:80px;">
|
||||
<input type="number" name="ordre" class="form-control" value="<?= (int)$v['ordre'] ?>" min="0" step="1">
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="title" class="form-control" value="<?= e($v['title']) ?>"
|
||||
placeholder="<?= e($translations['admin_video_title_optional_placeholder'] ?? 'Название (необязательно)') ?>">
|
||||
</td>
|
||||
<td>
|
||||
<input type="url" name="url" class="form-control" value="<?= e($v['url']) ?>">
|
||||
</td>
|
||||
<td class="d-flex gap-2">
|
||||
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
|
||||
<input type="hidden" name="pid" value="<?= (int)$pid ?>">
|
||||
<input type="hidden" name="vid" value="<?= (int)$v['id'] ?>">
|
||||
<button name="do" value="update_extra" class="btn btn-outline-primary btn-sm">
|
||||
<?= e($translations['enregistrer'] ?? 'Сохранить') ?>
|
||||
</button>
|
||||
<button name="do" value="delete_extra" class="btn btn-outline-danger btn-sm"
|
||||
onclick="return confirm('<?= e($translations['admin_video_confirm_delete'] ?? 'Удалить это видео?') ?>')">
|
||||
<?= e($translations['supprimer'] ?? 'Удалить') ?>
|
||||
</button>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
// Повторная аутентификация админа (10 минут)
|
||||
$ttl = 600;
|
||||
if (empty($_SESSION['admin_reauth_at']) || (time() - (int)$_SESSION['admin_reauth_at']) > $ttl) {
|
||||
$redir = $_SERVER['REQUEST_URI'] ?? 'index.php?page=afficher_messages';
|
||||
header('Location: ?page=reauth&redir='.rawurlencode($redir));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (Throwable) {}
|
||||
|
||||
$e = static fn($s)=>htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
// CSRF helper
|
||||
if (!function_exists('require_post_csrf')) {
|
||||
function require_post_csrf() {
|
||||
global $translations;
|
||||
if ($_SERVER['REQUEST_METHOD']!=='POST') { http_response_code(405); exit($translations['contactad_method_not_allowed'] ?? 'Méthode non autorisée'); }
|
||||
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf_token'])) {
|
||||
http_response_code(419); exit($translations['contactad_csrf_invalid'] ?? 'CSRF invalide');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отправка письма через PHPMailer (HTML + текст)
|
||||
function admin_send_reply(string $to, string $subject, string $html, string $text): array {
|
||||
/** @var array $MAILCFG */
|
||||
global $MAILCFG;
|
||||
$m = new PHPMailer(true);
|
||||
try {
|
||||
$m->isSMTP();
|
||||
$m->Host = $MAILCFG['smtp']['host'];
|
||||
$m->Port = $MAILCFG['smtp']['port'];
|
||||
$m->SMTPSecure = $MAILCFG['smtp']['secure'];
|
||||
$m->SMTPAuth = $MAILCFG['smtp']['auth'];
|
||||
$m->Username = $MAILCFG['smtp']['username'];
|
||||
$m->Password = $MAILCFG['smtp']['password'];
|
||||
$m->CharSet = 'UTF-8';
|
||||
$m->Encoding = 'base64';
|
||||
$m->XMailer = '';
|
||||
|
||||
if (!empty($MAILCFG['bounce'])) {
|
||||
// Envelope From / return-path (для SPF/DMARC)
|
||||
$m->Sender = $MAILCFG['bounce'];
|
||||
}
|
||||
$from = $MAILCFG['from'] ?? ['no-reply@uslugi.fr','USLUGI.FR'];
|
||||
$rpto = $MAILCFG['reply_to'] ?? ['no-reply@uslugi.fr','USLUGI.FR'];
|
||||
$m->setFrom($from[0], $from[1] ?? '');
|
||||
if (!empty($rpto[0])) {
|
||||
$m->addReplyTo($rpto[0], $rpto[1] ?? '');
|
||||
}
|
||||
|
||||
$m->addAddress($to);
|
||||
|
||||
// Анти-автоответы (по желанию)
|
||||
if (!empty($MAILCFG['suppress_auto'])) {
|
||||
$m->addCustomHeader('Auto-Submitted', 'auto-generated');
|
||||
$m->addCustomHeader('X-Auto-Response-Suppress', 'All');
|
||||
}
|
||||
|
||||
$m->isHTML(true);
|
||||
$m->Subject = $subject;
|
||||
$m->Body = $html;
|
||||
$m->AltBody = $text;
|
||||
|
||||
$m->send();
|
||||
return [true, null];
|
||||
} catch (Throwable $e) {
|
||||
error_log('PHPMailer admin reply error: '.$e->getMessage().($m->ErrorInfo ? ' | '.$m->ErrorInfo : ''));
|
||||
return [false, $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// Действия POST: ответ / удаление
|
||||
$err = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
require_post_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'reply') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$txt = trim((string)($_POST['reply'] ?? ''));
|
||||
if ($id <= 0 || $txt === '') {
|
||||
$err = $translations['contactad_err_invalid_data'] ?? 'Données invalides';
|
||||
} else {
|
||||
$st = $pdo->prepare("SELECT * FROM contact_messages WHERE id=? LIMIT 1");
|
||||
$st->execute([$id]);
|
||||
$m = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$m) {
|
||||
$err = $translations['contactad_err_not_found'] ?? 'Message introuvable';
|
||||
} else {
|
||||
$to = (string)$m['email'];
|
||||
$subj = $translations['contactad_mail_subject_reply'] ?? "Réponse de l'administrateur";
|
||||
$greet= $translations['contactad_mail_greeting'] ?? "Bonjour";
|
||||
$orig = $translations['contactad_mail_original_label'] ?? "Votre message initial :";
|
||||
|
||||
// HTML версия
|
||||
$origHtml = nl2br($e($m['message']));
|
||||
$html = '
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#111;">
|
||||
<p>'.$e($greet).' '.$e($m['prenom'].' '.$m['nom']).',</p>
|
||||
<p>'.$e($txt).'</p>
|
||||
<hr>
|
||||
<p><strong>'.$e($orig).'</strong></p>
|
||||
<blockquote style="margin:0;padding:10px 12px;border-left:4px solid #ddd;background:#f7f7f7;">'.$origHtml.'</blockquote>
|
||||
</div>';
|
||||
|
||||
// Текстовая версия
|
||||
$text = $greet.' '.($m['prenom'].' '.$m['nom']).","."\n\n".
|
||||
$txt."\n\n".
|
||||
"-----------------------\n".$orig."\n".$m['message']."\n";
|
||||
|
||||
// Отправка
|
||||
[$ok,$errSend] = admin_send_reply($to, $subj, $html, $text);
|
||||
|
||||
// Записываем статус "repondu" в любом случае (как раньше)
|
||||
$stU = $pdo->prepare("UPDATE contact_messages
|
||||
SET statut='repondu', reponse=?, replied_by=?, replied_at=NOW()
|
||||
WHERE id=?");
|
||||
$stU->execute([$txt, (int)($_SESSION['compte_id'] ?? 0), $id]);
|
||||
|
||||
if (function_exists('log_admin_action')) {
|
||||
log_admin_action(
|
||||
$pdo,
|
||||
(int)($_SESSION['compte_id'] ?? 0),
|
||||
'contact_reply',
|
||||
json_encode(['id'=>$id,'email'=>$to,'sent'=>(bool)$ok,'error'=>$errSend], JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
|
||||
// Возврат к списку (без id)
|
||||
header('Location: ?page=afficher_messages&flash=1');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($action === 'delete') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$del = $pdo->prepare("DELETE FROM contact_messages WHERE id=?");
|
||||
$del->execute([$id]);
|
||||
if (function_exists('log_admin_action')) {
|
||||
log_admin_action($pdo, (int)($_SESSION['compte_id'] ?? 0), 'contact_delete',
|
||||
json_encode(['id'=>$id,'deleted'=>$del->rowCount()>0], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
header('Location: ?page=afficher_messages&deleted=1'); return;
|
||||
} else {
|
||||
$err = $translations['contactad_err_invalid_id'] ?? 'ID invalide';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Открытое сообщение?
|
||||
$msg = null;
|
||||
if (isset($_GET['id'])) {
|
||||
$id = (int)$_GET['id'];
|
||||
$st = $pdo->prepare("SELECT * FROM contact_messages WHERE id=? LIMIT 1");
|
||||
$st->execute([$id]);
|
||||
$msg = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if ($msg && $msg['statut'] === 'nouveau') {
|
||||
$pdo->prepare("UPDATE contact_messages SET statut='lu' WHERE id=?")->execute([$id]);
|
||||
$msg['statut'] = 'lu';
|
||||
}
|
||||
}
|
||||
|
||||
// Список (пагинация)
|
||||
$page = max(1, (int)($_GET['p'] ?? 1));
|
||||
$per = 25; $off = ($page - 1) * $per;
|
||||
$total = (int)$pdo->query("SELECT COUNT(*) FROM contact_messages")->fetchColumn();
|
||||
$rows = $pdo->query("SELECT id, nom, prenom, email, sujet, statut, created_at
|
||||
FROM contact_messages
|
||||
ORDER BY id DESC
|
||||
LIMIT $per OFFSET $off")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$pages = max(1, (int)ceil($total / $per));
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<h3 class="m-0"><?= $e($translations['contactad_title'] ?? 'Messages utilisateurs') ?></h3>
|
||||
<span class="badge text-bg-secondary"><?= (int)$total ?></span>
|
||||
<!-- Переключатель языков удалён -->
|
||||
</div>
|
||||
|
||||
<?php if(isset($_GET['flash'])): ?>
|
||||
<div class="alert alert-success"><?= $e($translations['contactad_flash_sent_ok'] ?? 'Réponse envoyée') ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if(isset($_GET['deleted'])): ?>
|
||||
<div class="alert alert-success"><?= $e($translations['contactad_flash_deleted_ok'] ?? 'Message supprimé.') ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if($err): ?><div class="alert alert-danger"><?= $e($err) ?></div><?php endif; ?>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Список: на весь экран если деталка не открыта -->
|
||||
<div class="<?= $msg ? 'col-lg-6' : 'col-12' ?>">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><?= $e($translations['contactad_list_title'] ?? 'Messages') ?></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 align-middle mobile-cards">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= $e($translations['contactad_th_id'] ?? '#') ?></th>
|
||||
<th><?= $e($translations['contactad_th_from'] ?? 'De') ?></th>
|
||||
<th><?= $e($translations['contactad_th_email'] ?? 'Email') ?></th>
|
||||
<th><?= $e($translations['contactad_th_subject'] ?? 'Sujet') ?></th>
|
||||
<th><?= $e($translations['contactad_th_date'] ?? 'Date') ?></th>
|
||||
<th class="text-end"><?= $e($translations['contactad_th_actions'] ?? 'Actions') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r): $st=$r['statut']; ?>
|
||||
<tr>
|
||||
<td><?= (int)$r['id'] ?></td>
|
||||
<td><?= $e($r['prenom'].' '.$r['nom']) ?></td>
|
||||
<td><a href="mailto:<?= $e($r['email']) ?>"><?= $e($r['email']) ?></a></td>
|
||||
<td>
|
||||
<?= $e($r['sujet']) ?>
|
||||
<span class="badge ms-2 text-bg-<?= $st==='nouveau'?'warning':($st==='repondu'?'success':'secondary') ?>">
|
||||
<?php
|
||||
$label = $st;
|
||||
if ($st === 'nouveau') $label = $translations['contactad_status_nouveau'] ?? 'Nouveau';
|
||||
elseif ($st === 'lu') $label = $translations['contactad_status_lu'] ?? 'Lu';
|
||||
elseif ($st === 'repondu') $label = $translations['contactad_status_repondu'] ?? 'Répondu';
|
||||
echo $e($label);
|
||||
?>
|
||||
</span>
|
||||
</td>
|
||||
<td><small><?= $e($r['created_at']) ?></small></td>
|
||||
<td class="text-end">
|
||||
<!-- Кнопки выровнены: d-flex + gap-2, форма без внешних отступов -->
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary"
|
||||
href="?page=afficher_messages&id=<?= (int)$r['id'] ?>">
|
||||
<?= $e($translations['contactad_btn_open'] ?? 'Ouvrir') ?>
|
||||
</a>
|
||||
<form method="post" class="m-0" onsubmit="return confirm('<?= $e($translations['contactad_confirm_delete'] ?? 'Supprimer ce message ?') ?>')">
|
||||
<input type="hidden" name="csrf_token" value="<?= $e($_SESSION['csrf_token'] ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
|
||||
<button class="btn btn-sm btn-outline-danger" name="action" value="delete">
|
||||
<?= $e($translations['contactad_btn_delete'] ?? 'Supprimer') ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; if (!$rows): ?>
|
||||
<tr><td colspan="6" class="p-3 text-muted"><?= $e($translations['contactad_no_messages'] ?? 'Aucun message.') ?></td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if ($pages > 1): ?>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<?php
|
||||
$tpl = $translations['contactad_pager_label'] ?? 'Page {P} / {N}';
|
||||
echo $e(str_replace(['{P}','{N}'], [(string)(int)$page, (string)(int)$pages], $tpl));
|
||||
?>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<?php for ($i=max(1,$page-2); $i<=min($pages,$page+2); $i++):
|
||||
$q=$_GET; $q['p']=$i; ?>
|
||||
<a class="btn btn-sm <?= $i===$page?'btn-dark':'btn-outline-dark' ?>" href="?<?= http_build_query($q) ?>"><?= $i ?></a>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><?= $e($translations['contactad_detail_title'] ?? 'Détails') ?></span>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="?page=afficher_messages">
|
||||
<?= $e($translations['contactad_btn_close_detail'] ?? 'Fermer') ?>
|
||||
</a>
|
||||
<form method="post" class="m-0" onsubmit="return confirm('<?= $e($translations['contactad_confirm_delete'] ?? 'Supprimer ce message ?') ?>')">
|
||||
<input type="hidden" name="csrf_token" value="<?= $e($_SESSION['csrf_token'] ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$msg['id'] ?>">
|
||||
<button class="btn btn-sm btn-danger" name="action" value="delete"><?= $e($translations['contactad_btn_delete'] ?? 'Supprimer') ?></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<strong>#<?= (int)$msg['id'] ?></strong> • <?= $e($msg['created_at']) ?>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<strong><?= $e($msg['prenom'].' '.$msg['nom']) ?></strong> —
|
||||
<a href="mailto:<?= $e($msg['email']) ?>"><?= $e($msg['email']) ?></a>
|
||||
</div>
|
||||
<div class="mb-2"><em><?= $e($msg['sujet']) ?></em></div>
|
||||
<pre class="p-2 bg-light border rounded" style="white-space:pre-wrap"><?= $e($msg['message']) ?></pre>
|
||||
|
||||
<hr>
|
||||
<form method="post" class="d-grid gap-2">
|
||||
<input type="hidden" name="csrf_token" value="<?= $e($_SESSION['csrf_token'] ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$msg['id'] ?>">
|
||||
<textarea class="form-control" name="reply" rows="5"
|
||||
placeholder="<?= $e($translations['contactad_reply_placeholder'] ?? 'Répondre') ?>"
|
||||
required><?= $e($msg['reponse'] ?? '') ?></textarea>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-dark" name="action" value="reply"><?= $e($translations['contactad_btn_send'] ?? 'Envoyer') ?></button>
|
||||
<a class="btn btn-outline-secondary" href="?page=afficher_messages"><?= $e($translations['contactad_btn_back'] ?? 'Retour') ?></a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
// admin/tools/ajax_fiche_user.php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
// ID "super admin"
|
||||
const ADMIN_PRACTICIEN_ID = 88888888;
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok'=>false, 'error'=>'Доступ запрещён.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$mustReauth=function(int $ttl=600){$t=(int)($_SESSION['admin_reauth_at']??0);return ($t===0||(time()-$t)>$ttl);};
|
||||
if ($mustReauth()){ $qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]); header('Location:?'.$qs); exit; }
|
||||
|
||||
|
||||
function jerr(string $msg, int $code = 400): void {
|
||||
http_response_code($code);
|
||||
echo json_encode(['ok'=>false, 'error'=>$msg], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function h($s) {
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
// CSRF
|
||||
$csrf = $_POST['csrf'] ?? '';
|
||||
if (empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $csrf)) {
|
||||
jerr('Ошибка CSRF. Обновите страницу.', 400);
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
jerr('Неверный идентификатор пользователя.', 400);
|
||||
}
|
||||
|
||||
// Загружаем запись из compte
|
||||
$st = $pdo->prepare("SELECT * FROM compte WHERE id = ?");
|
||||
$st->execute([$id]);
|
||||
$c = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$c) {
|
||||
jerr('Аккаунт не найден.', 404);
|
||||
}
|
||||
|
||||
$role = $c['role'] ?? '';
|
||||
|
||||
$client = null;
|
||||
$prat = null;
|
||||
$secBase = null; // если для секретаря будет отдельная таблица в будущем
|
||||
|
||||
if ($role === 'client') {
|
||||
$refId = (int)($c['reference_id'] ?? 0);
|
||||
if ($refId > 0) {
|
||||
$st2 = $pdo->prepare("SELECT * FROM client WHERE id = ?");
|
||||
$st2->execute([$refId]);
|
||||
$client = $st2->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
} elseif ($role === 'praticien' || $role === 'secretaire') {
|
||||
$refId = (int)($c['reference_id'] ?? 0);
|
||||
if ($refId > 0) {
|
||||
$st2 = $pdo->prepare("SELECT * FROM praticien WHERE id = ?");
|
||||
$st2->execute([$refId]);
|
||||
$prat = $st2->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
|
||||
// Строим HTML
|
||||
ob_start();
|
||||
?>
|
||||
<div class="text-start">
|
||||
<h5 class="mb-3">Основная информация аккаунта</h5>
|
||||
<table class="table table-sm table-bordered">
|
||||
<tr>
|
||||
<th style="width:180px;">ID аккаунта</th>
|
||||
<td><?= (int)$c['id'] ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>E-mail</th>
|
||||
<td><?= h($c['email']) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Роль</th>
|
||||
<td>
|
||||
<?php
|
||||
if ($role === 'client') echo 'Клиент';
|
||||
elseif ($role === 'praticien') echo 'Практик';
|
||||
elseif ($role === 'secretaire') echo 'Секретарь';
|
||||
else echo h($role);
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Дата создания</th>
|
||||
<td>
|
||||
<?php
|
||||
if (!empty($c['cree_le'])) {
|
||||
echo h((new DateTime($c['cree_le']))->format('d.m.Y H:i'));
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Дата изменения</th>
|
||||
<td>
|
||||
<?php
|
||||
if (!empty($c['modifie_le'])) {
|
||||
echo h((new DateTime($c['modifie_le']))->format('d.m.Y H:i'));
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Статус e-mail</th>
|
||||
<td>
|
||||
<?php if ((int)($c['email_valide'] ?? 0) === 1): ?>
|
||||
<span class="badge bg-success">Подтверждён</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-warning text-dark">Не подтверждён</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>ID в связанной таблице (reference_id)</th>
|
||||
<td><?= (int)($c['reference_id'] ?? 0) ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php if ($role === 'client'): ?>
|
||||
<h5 class="mt-4 mb-3">Данные клиента</h5>
|
||||
<?php if (!$client): ?>
|
||||
<div class="alert alert-warning">Запись клиента не найдена.</div>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm table-bordered">
|
||||
<tr><th style="width:180px;">ID клиента</th><td><?= (int)$client['id'] ?></td></tr>
|
||||
<tr><th>Фамилия</th><td><?= h($client['nom']) ?></td></tr>
|
||||
<tr><th>Имя</th><td><?= h($client['prenom']) ?></td></tr>
|
||||
<tr><th>E-mail</th><td><?= h($client['email']) ?></td></tr>
|
||||
<tr><th>Телефон</th><td><?= h($client['telephone']) ?></td></tr>
|
||||
<tr><th>Дата рождения</th><td><?= h($client['date_naissance']) ?></td></tr>
|
||||
<tr><th>Адрес</th><td><?= nl2br(h($client['adresse'])) ?></td></tr>
|
||||
<tr><th>Город</th><td><?= h($client['ville']) ?></td></tr>
|
||||
<tr><th>Индекс</th><td><?= h($client['code_postal']) ?></td></tr>
|
||||
<tr><th>Пол</th><td><?= h($client['genre']) ?></td></tr>
|
||||
<tr>
|
||||
<th>CGU (accepted_cgu)</th>
|
||||
<td>
|
||||
<?php if ((int)($client['accepted_cgu'] ?? 0) === 1): ?>
|
||||
<span class="badge bg-success">Принял(а)</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-danger">Не принял(а) или не указано</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php elseif ($role === 'praticien' || $role === 'secretaire'): ?>
|
||||
<h5 class="mt-4 mb-3">
|
||||
<?= $role === 'praticien' ? 'Данные практика' : 'Данные, связанные с секретарём' ?>
|
||||
</h5>
|
||||
<?php if (!$prat): ?>
|
||||
<div class="alert alert-warning">Запись практика не найдена.</div>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm table-bordered">
|
||||
<tr><th style="width:200px;">ID практика</th><td><?= (int)$prat['id'] ?></td></tr>
|
||||
<tr>
|
||||
<th>Фото (файл)</th>
|
||||
<td>
|
||||
<div style="width:120px; height:120px; overflow:hidden; border-radius:10px;">
|
||||
<img src="/../public/uploads/<?= h($prat['photo']) ?>"
|
||||
alt="Фото практика"
|
||||
style="width:100%; height:100%; object-fit:cover;">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th>Тариф</th><td><?= h($prat['titre']) ?></td></tr>
|
||||
<tr><th>Длительность приёма (мин)</th><td><?= (int)$prat['duree_rdv'] ?></td></tr>
|
||||
<tr><th>Фамилия</th><td><?= h($prat['nom']) ?></td></tr>
|
||||
<tr><th>Имя</th><td><?= h($prat['prenom']) ?></td></tr>
|
||||
<tr><th>E-mail</th><td><?= h($prat['email']) ?></td></tr>
|
||||
<tr><th>Телефон</th><td><?= h($prat['telephone']) ?></td></tr>
|
||||
<tr><th>Специальность</th><td><?= h($prat['specialite']) ?></td></tr>
|
||||
<tr><th>Адрес</th><td><?= nl2br(h($prat['adresse'])) ?></td></tr>
|
||||
<tr><th>Город</th><td><?= h($prat['ville']) ?></td></tr>
|
||||
<tr><th>Индекс</th><td><?= h($prat['code_postal']) ?></td></tr>
|
||||
<tr><th>SIRET</th><td><?= h($prat['siret']) ?></td></tr>
|
||||
<tr>
|
||||
<th>CGU (accepted_cgu)</th>
|
||||
<td>
|
||||
<?php if ((int)($prat['accepted_cgu'] ?? 0) === 1): ?>
|
||||
<span class="badge bg-success">Принял(а)</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-danger">Не принял(а) или не указано</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th>Презентация</th><td><?= nl2br(h($prat['presentation'])) ?></td></tr>
|
||||
<tr><th>Основное видео (URL)</th><td><?= h($prat['video']) ?></td></tr>
|
||||
<tr><th>Доступность (сырой текст)</th><td><?= nl2br(h($prat['disponibilite'])) ?></td></tr>
|
||||
<tr><th>Тип календаря</th><td><?= h($prat['calendrier_type']) ?></td></tr>
|
||||
|
||||
<tr><th>Абонемент ID</th><td><?= h($prat['abonnement_id']) ?></td></tr>
|
||||
<tr><th>Дата окончания абонемента</th><td><?= h($prat['date_expiration']) ?></td></tr>
|
||||
<tr>
|
||||
<th>Синхронизация активна</th>
|
||||
<td>
|
||||
<?php if ((int)($prat['synchronisation_active'] ?? 0) === 1): ?>
|
||||
<span class="badge bg-success">Да</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-secondary">Нет</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Создан</th>
|
||||
<td>
|
||||
<?php
|
||||
if (!empty($prat['created_at'])) {
|
||||
echo h((new DateTime($prat['created_at']))->format('d.m.Y H:i'));
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Изменён</th>
|
||||
<td>
|
||||
<?php
|
||||
if (!empty($prat['updated_at'])) {
|
||||
echo h((new DateTime($prat['updated_at']))->format('d.m.Y H:i'));
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php else: ?>
|
||||
<div class="alert alert-info mt-3">
|
||||
Для этой роли подробная карточка ещё не настроена.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
$html = ob_get_clean();
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'html' => $html
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
// admin/tools/ajax_send_validation_link.php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
// ID "super admin"
|
||||
const ADMIN_PRACTICIEN_ID = 88888888;
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok'=>false, 'error'=>'Доступ запрещён.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
function jerr(string $msg, int $code = 400): void {
|
||||
http_response_code($code);
|
||||
echo json_encode(['ok'=>false, 'error'=>$msg], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF
|
||||
$csrf = $_POST['csrf'] ?? '';
|
||||
if (empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $csrf)) {
|
||||
jerr('Ошибка CSRF. Обновите страницу.', 400);
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
jerr('Неверный идентификатор пользователя.', 400);
|
||||
}
|
||||
|
||||
// Загружаем аккаунт
|
||||
$st = $pdo->prepare("SELECT id, email, role FROM compte WHERE id = ? AND role IN ('client','praticien','secretaire')");
|
||||
$st->execute([$id]);
|
||||
$compte = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$compte) {
|
||||
jerr('Пользователь не найден или роль не поддерживается.', 404);
|
||||
}
|
||||
|
||||
$email = trim($compte['email'] ?? '');
|
||||
if ($email === '') {
|
||||
jerr('У пользователя не указан e-mail.', 400);
|
||||
}
|
||||
|
||||
// Генерируем новый токен
|
||||
try {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
} catch (Exception $e) {
|
||||
jerr('Не удалось сгенерировать токен.', 500);
|
||||
}
|
||||
|
||||
// Сохраняем токен и сбрасываем флаг подтверждения
|
||||
$upd = $pdo->prepare("UPDATE compte SET token_validation = ?, email_valide = 0, modifie_le = NOW() WHERE id = ?");
|
||||
$upd->execute([$token, $compte['id']]);
|
||||
|
||||
// Строим ссылку подтверждения
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'uslugi.fr';
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$validateUrl = $scheme . '://' . $host . '/auth/validate.php?token=' . urlencode($token);
|
||||
|
||||
// Отправка письма (простой mail(), можно заменить на PHPMailer)
|
||||
|
||||
$subject = 'Подтверждение регистрации на uslugi.fr';
|
||||
|
||||
// On prépare le contenu HTML (avec des sauts de ligne <br>)
|
||||
$htmlBody = nl2br(
|
||||
"Здравствуйте!\n\n" .
|
||||
"Вы (или кто-то другой) зарегистрировались на сайте uslugi.fr с этим адресом электронной почты.\n\n" .
|
||||
"Чтобы подтвердить регистрацию, перейдите по следующей ссылке:\n" .
|
||||
"<a href='$validateUrl'>$validateUrl</a>\n\n" .
|
||||
"Если вы не регистрировались на сайте, просто проигнорируйте это письмо.\n\n" .
|
||||
"С уважением,\n" .
|
||||
"Команда uslugi.fr\n"
|
||||
);
|
||||
|
||||
// On garde une version texte pur sans HTML
|
||||
$textBody = strip_tags($htmlBody);
|
||||
|
||||
// REMPLACEMENT DE mail() PAR uslugi_mail()
|
||||
list($sent, $err) = uslugi_mail(
|
||||
$email,
|
||||
$subject, // Plus besoin de base64_encode ici
|
||||
$htmlBody,
|
||||
$textBody,
|
||||
['to_name' => "Пользователь"] // Optionnel : vous pouvez passer le nom si vous l'avez
|
||||
);
|
||||
|
||||
// On adapte le test de succès
|
||||
if (!$sent) {
|
||||
// On affiche l'erreur réelle retournée par Brevo/PHPMailer pour le debug
|
||||
jerr('Ошибка отправки (Brevo): ' . $err, 500);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'message' => 'Ссылка для подтверждения регистрации успешно отправлена на ' . $email
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
if (($_GET['page'] ?? '') !== 'audit') return;
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
$roles = (array)($_SESSION['role'] ?? []);
|
||||
if (!in_array('admin', $roles, true)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['audite_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
require_once __DIR__.'/_lib_audit.php';
|
||||
// reauth (10 min)
|
||||
$mustReauth=function(int $ttl=600){$t=(int)($_SESSION['admin_reauth_at']??0);return ($t===0||(time()-$t)>$ttl);};
|
||||
if ($mustReauth()){ $qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]); header('Location:?'.$qs); exit; }
|
||||
if (!isset($pdo)) { $db = realpath(__DIR__.'/../../includes/db.php'); if ($db) require_once $db; }
|
||||
if (!isset($pdo) || !($pdo instanceof PDO)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['audite_db_unavailable'] ?? 'DB indisponible.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch(Throwable) {}
|
||||
|
||||
$e = static fn($s)=>htmlspecialchars((string)$s,ENT_QUOTES,'UTF-8');
|
||||
if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token']=bin2hex(random_bytes(32));
|
||||
$CSRF=$_SESSION['csrf_token'];
|
||||
|
||||
// filtres
|
||||
$qAdmin = (int)($_GET['admin_id'] ?? 0);
|
||||
$qAct = trim($_GET['action'] ?? '');
|
||||
$qFrom = trim($_GET['from'] ?? '');
|
||||
$qTo = trim($_GET['to'] ?? '');
|
||||
$page = max(1, (int)($_GET['p'] ?? 1));
|
||||
$per = min(200, max(10,(int)($_GET['per'] ?? 50)));
|
||||
|
||||
$w=[]; $p=[];
|
||||
if ($qAdmin>0){ $w[]='admin_id=:aid'; $p[':aid']=$qAdmin; }
|
||||
if ($qAct!==''){ $w[]='action=:act'; $p[':act']=$qAct; }
|
||||
if ($qFrom!==''){ $w[]='created_at>=:f'; $p[':f']=$qFrom.' 00:00:00'; }
|
||||
if ($qTo!==''){ $w[]='created_at<=:t'; $p[':t']=$qTo.' 23:59:59'; }
|
||||
$where = $w?('WHERE '.implode(' AND ',$w)):'';
|
||||
|
||||
$cnt = $pdo->prepare("SELECT COUNT(*) FROM admin_audit $where");
|
||||
$cnt->execute($p); $total=(int)$cnt->fetchColumn();
|
||||
$pages = max(1,(int)ceil($total/$per));
|
||||
$off = ($page-1)*$per;
|
||||
|
||||
$sql = "SELECT id,admin_id,action,scope,target_table,target_id,meta_json,INET6_NTOA(ip) ip,user_agent,created_at
|
||||
FROM admin_audit $where ORDER BY id DESC LIMIT $per OFFSET $off";
|
||||
$stmt=$pdo->prepare($sql); $stmt->execute($p);
|
||||
$rows=$stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<h3 class="m-0"><?= $e($translations['audite_title'] ?? 'Journal d’audit (actions admin)') ?></h3>
|
||||
<span class="badge text-bg-secondary">
|
||||
<?php
|
||||
$tpl = (string)($translations['audite_total_badge'] ?? '{N} lignes');
|
||||
echo $e(str_replace('{N}', (string)((int)$total), $tpl));
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form class="row g-2 mb-3" method="get">
|
||||
<input type="hidden" name="page" value="audit">
|
||||
<div class="col-6 col-md-2">
|
||||
<input class="form-control" name="admin_id" type="number"
|
||||
placeholder="<?= $e($translations['audite_ph_admin_id'] ?? 'admin_id') ?>"
|
||||
value="<?= $qAdmin ?>">
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<input class="form-control" name="action"
|
||||
placeholder="<?= $e($translations['audite_ph_action'] ?? 'action') ?>"
|
||||
value="<?= $e($qAct) ?>">
|
||||
</div>
|
||||
<div class="col-6 col-md-2"><input class="form-control" name="from" type="date" value="<?= $e($qFrom) ?>"></div>
|
||||
<div class="col-6 col-md-2"><input class="form-control" name="to" type="date" value="<?= $e($qTo) ?>"></div>
|
||||
<div class="col-6 col-md-2">
|
||||
<select class="form-select" name="per">
|
||||
<?php foreach ([25,50,100,200] as $n): ?>
|
||||
<option value="<?= $n ?>" <?= $per===$n?'selected':'' ?>>
|
||||
<?php
|
||||
$tpl = (string)($translations['audite_per_page'] ?? '{N}/page');
|
||||
echo $e(str_replace('{N}', (string)$n, $tpl));
|
||||
?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2 d-grid">
|
||||
<button class="btn btn-outline-secondary"><?= $e($translations['audite_btn_filter'] ?? 'Filtrer') ?></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle mb-0 mobile-cards">
|
||||
<thead><tr>
|
||||
<th><?= $e($translations['audite_th_id'] ?? '#') ?></th>
|
||||
<th><?= $e($translations['audite_th_admin'] ?? 'Admin') ?></th>
|
||||
<th><?= $e($translations['audite_th_action'] ?? 'Action') ?></th>
|
||||
<th><?= $e($translations['audite_th_scope'] ?? 'Scope') ?></th>
|
||||
<th><?= $e($translations['audite_th_target'] ?? 'Cible') ?></th>
|
||||
<th><?= $e($translations['audite_th_ip'] ?? 'IP') ?></th>
|
||||
<th><?= $e($translations['audite_th_date'] ?? 'Date') ?></th>
|
||||
<th><?= $e($translations['audite_th_meta'] ?? 'Meta') ?></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach($rows as $r): ?>
|
||||
<tr>
|
||||
<td><?= (int)$r['id'] ?></td>
|
||||
<td><?= (int)$r['admin_id'] ?></td>
|
||||
<td><span class="badge text-bg-dark"><?= $e($r['action']) ?></span></td>
|
||||
<td><?= $e($r['scope'] ?? '') ?></td>
|
||||
<td><small><?= $e($r['target_table'] ?? '') ?><?= $r['target_id']?(' #'.(int)$r['target_id']):'' ?></small></td>
|
||||
<td><small><?= $e($r['ip'] ?? '') ?></small></td>
|
||||
<td><small><?= $e($r['created_at']) ?></small></td>
|
||||
<td><code class="small"><?= $e($r['meta_json'] ?? '') ?></code></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php if ($pages>1): ?>
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
<div>
|
||||
<?php
|
||||
$tpl = (string)($translations['audite_page_x_y'] ?? 'Page {X} / {Y}');
|
||||
echo $e(str_replace(['{X}','{Y}'], [(string)$page, (string)$pages], $tpl));
|
||||
?>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<?php for($i=max(1,$page-2);$i<=min($pages,$page+2);$i++):
|
||||
$q=$_GET; $q['p']=$i; ?>
|
||||
<a class="btn btn-sm <?= $i===$page?'btn-dark':'btn-outline-dark' ?>" href="?<?= http_build_query($q) ?>"><?= $i ?></a>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
if (($_GET['page'] ?? '') !== 'backup_db') return;
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
$roles=(array)($_SESSION['role']??[]);
|
||||
if(!in_array('admin',$roles,true)){
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['backup_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
if (!isset($pdo)) { $db=realpath(__DIR__.'/../../includes/db.php'); if($db) require_once $db; }
|
||||
if (!isset($pdo) || !($pdo instanceof PDO)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['backup_db_unavailable'] ?? 'DB indisponible.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
try{$pdo->setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION);}catch(Throwable){}
|
||||
|
||||
require_once __DIR__.'/_lib_audit.php';
|
||||
|
||||
// reauth (10 min)
|
||||
$mustReauth=function(int $ttl=600){$t=(int)($_SESSION['admin_reauth_at']??0);return ($t===0||(time()-$t)>$ttl);};
|
||||
if ($mustReauth()){ $qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]); header('Location:?'.$qs); exit; }
|
||||
|
||||
$e=static fn($s)=>htmlspecialchars((string)$s,ENT_QUOTES,'UTF-8');
|
||||
if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token']=bin2hex(random_bytes(32));
|
||||
$CSRF=$_SESSION['csrf_token'];
|
||||
|
||||
$siteRoot=realpath(__DIR__.'/../../');
|
||||
$backupDir=$siteRoot.'/backups/db';
|
||||
@is_dir($backupDir) || @mkdir($backupDir,0755,true);
|
||||
|
||||
$msg=null; $ok=null;
|
||||
|
||||
// Params
|
||||
$retentionDays = max(0,(int)($_POST['retention'] ?? ($_GET['retention'] ?? 30)));
|
||||
$useGzip = true;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD']==='POST') {
|
||||
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
|
||||
$msg = (string)($translations['backup_csrf_invalid'] ?? 'CSRF invalide.');
|
||||
}
|
||||
else {
|
||||
$act = $_POST['action'] ?? '';
|
||||
if ($act==='create') {
|
||||
// Dump via PDO (fallback si mysqldump non dispo)
|
||||
$dbName = $pdo->query("SELECT DATABASE()")->fetchColumn();
|
||||
$fname = 'db_'.$dbName.'_'.date('Ymd_His').'.sql';
|
||||
$tmp = $backupDir.'/'.$fname;
|
||||
|
||||
// --- export ---
|
||||
$fh = @fopen($tmp,'wb');
|
||||
if(!$fh){
|
||||
$tpl = (string)($translations['backup_cannot_write_dir'] ?? "Impossible d'écrire dans {DIR}");
|
||||
$msg = str_replace('{DIR}', (string)$backupDir, $tpl);
|
||||
}
|
||||
else {
|
||||
fwrite($fh, "-- Backup of `$dbName` on ".date('c')."\nSET FOREIGN_KEY_CHECKS=0;\n");
|
||||
// tables
|
||||
$tables = $pdo->query("SHOW FULL TABLES WHERE Table_Type='BASE TABLE'")->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($tables as $t) {
|
||||
// DDL
|
||||
$row = $pdo->query("SHOW CREATE TABLE `$t`")->fetch(PDO::FETCH_ASSOC);
|
||||
fwrite($fh, "\n-- Structure `$t`\nDROP TABLE IF EXISTS `$t`;\n".$row['Create Table'].";\n");
|
||||
// DML
|
||||
$st = $pdo->query("SELECT * FROM `$t`", PDO::FETCH_ASSOC);
|
||||
$cols = array_map(fn($c)=>'`'.$c.'`', array_keys($st->fetch() ?: []));
|
||||
if ($cols) {
|
||||
// Refaire la requête pour ne pas “perdre” la première ligne
|
||||
$st = $pdo->query("SELECT * FROM `$t`", PDO::FETCH_ASSOC);
|
||||
$colList = implode(',', $cols);
|
||||
$placeholders = '(' . implode(',', array_fill(0, count($cols), '?')) . ')';
|
||||
$batch=[]; $n=0;
|
||||
foreach($st as $row){
|
||||
$vals = array_values($row);
|
||||
$vals = array_map(function($v) use($pdo){ return isset($v)?$pdo->quote((string)$v):"NULL"; }, $vals);
|
||||
$batch[] = '('.implode(',', $vals).')';
|
||||
if (++$n % 200 === 0) { fwrite($fh, "INSERT INTO `$t` ($colList) VALUES\n".implode(",\n",$batch).";\n"); $batch=[]; }
|
||||
}
|
||||
if ($batch){ fwrite($fh, "INSERT INTO `$t` ($colList) VALUES\n".implode(",\n",$batch).";\n"); }
|
||||
}
|
||||
}
|
||||
fwrite($fh, "SET FOREIGN_KEY_CHECKS=1;\n");
|
||||
fclose($fh);
|
||||
|
||||
// gzip
|
||||
if ($useGzip) {
|
||||
$gz = $tmp.'.gz';
|
||||
$gzh = gzopen($gz,'wb9');
|
||||
$src = fopen($tmp,'rb');
|
||||
while(!feof($src)){ gzwrite($gzh, fread($src, 1<<20)); }
|
||||
fclose($src); gzclose($gzh);
|
||||
@unlink($tmp);
|
||||
$final=$gz;
|
||||
} else { $final=$tmp; }
|
||||
|
||||
$tpl = (string)($translations['backup_created_ok'] ?? 'Sauvegarde créée : {FILE}');
|
||||
$ok = str_replace('{FILE}', basename($final), $tpl);
|
||||
|
||||
AdminAudit::log($pdo, [
|
||||
'admin_id'=>(int)($_SESSION['user_id']??0),
|
||||
'action'=>'backup_create',
|
||||
'scope'=>'maintenance',
|
||||
'meta'=>['file'=>basename($final),'dir'=>'backups/db']
|
||||
]);
|
||||
}
|
||||
}
|
||||
elseif ($act==='prune') {
|
||||
$cut = time()-($retentionDays*86400);
|
||||
$n=0; foreach (glob($backupDir.'/db_*.sql*') as $f){ if(filemtime($f)<$cut){ @unlink($f); $n++; } }
|
||||
|
||||
$tpl = (string)($translations['backup_retention_applied'] ?? 'Rétention appliquée : {N} fichier(s) supprimé(s) (> {D} j)');
|
||||
$ok = str_replace(['{N}','{D}'], [(string)$n, (string)$retentionDays], $tpl);
|
||||
|
||||
AdminAudit::log($pdo, [
|
||||
'admin_id'=>(int)($_SESSION['user_id']??0),
|
||||
'action'=>'backup_prune',
|
||||
'scope'=>'maintenance',
|
||||
'meta'=>['retention_days'=>$retentionDays]
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Liste
|
||||
$files = array_map('basename', glob($backupDir.'/db_*.sql*') ?: []);
|
||||
usort($files, fn($a,$b)=>strcmp($b,$a)); // plus récent d'abord
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<h3 class="m-0"><?= $e($translations['backup_title'] ?? 'Sauvegardes de la base') ?></h3>
|
||||
<span class="badge text-bg-secondary">
|
||||
<?php
|
||||
$tpl = (string)($translations['backup_files_badge'] ?? '{N} fichier(s)');
|
||||
echo $e(str_replace('{N}', (string)count($files), $tpl));
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<?php if($ok): ?><div class="alert alert-success"><?= $e($ok) ?></div><?php endif; ?>
|
||||
<?php if($msg): ?><div class="alert alert-danger"><?= $e($msg) ?></div><?php endif; ?>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="csrf" value="<?= $e($CSRF) ?>">
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button class="btn btn-dark" name="action" value="create"
|
||||
onclick="return confirm('<?= $e($translations['backup_confirm_create'] ?? 'Créer un dump SQL compressé ?') ?>')">
|
||||
<?= $e($translations['backup_btn_create'] ?? 'Sauvegarder') ?>
|
||||
</button>
|
||||
<div class="ms-auto"></div>
|
||||
<div class="input-group" style="max-width:320px">
|
||||
<span class="input-group-text"><?= $e($translations['backup_retention_label'] ?? 'Rétention (jours)') ?></span>
|
||||
<input class="form-control" type="number" min="0" name="retention" value="<?= (int)$retentionDays ?>">
|
||||
<button class="btn btn-outline-danger" name="action" value="prune"
|
||||
onclick="return confirm('<?= $e($translations['backup_confirm_prune'] ?? 'Supprimer les backups plus anciens ?') ?>')">
|
||||
<?= $e($translations['backup_btn_prune'] ?? 'Go') ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer small text-muted">
|
||||
<?php
|
||||
$tpl = (string)($translations['backup_folder_line'] ?? 'Dossier: {DIR}');
|
||||
echo $e(str_replace('{DIR}', (string)$backupDir, $tpl));
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 mobile-cards">
|
||||
<thead><tr>
|
||||
<th><?= $e($translations['backup_th_file'] ?? 'Fichier') ?></th>
|
||||
<th><?= $e($translations['backup_th_size'] ?? 'Taille') ?></th>
|
||||
<th><?= $e($translations['backup_th_date'] ?? 'Date') ?></th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach($files as $f):
|
||||
$full=$backupDir.'/'.$f; ?>
|
||||
<tr>
|
||||
<td><code><?= $e($f) ?></code></td>
|
||||
<td><?= number_format((filesize($full)?:0)/1024,1) ?> KB</td>
|
||||
<td><?= date('Y-m-d H:i:s', filemtime($full)?:time()) ?></td>
|
||||
<td class="text-end">
|
||||
<a class="btn btn-sm btn-outline-primary"
|
||||
href="/admin/tools/backup_download.php?f=<?= $e(urlencode($f)) ?>&csrf=<?= $e($CSRF) ?>">
|
||||
<?= $e($translations['backup_btn_download'] ?? 'Télécharger') ?>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; if(!$files): ?>
|
||||
<tr><td colspan="4" class="text-muted p-3"><?= $e($translations['backup_none_yet'] ?? 'Aucun backup encore.') ?></td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
if (empty($_SESSION['role']) || !in_array('admin',(array)$_SESSION['role'],true)) { http_response_code(403); exit('Forbidden'); }
|
||||
if (empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_GET['csrf'] ?? '')) { http_response_code(400); exit('CSRF'); }
|
||||
|
||||
$siteRoot=realpath(__DIR__.'/../../'); $dir=$siteRoot.'/backups/db';
|
||||
$f=basename($_GET['f'] ?? ''); $path=realpath($dir.'/'.$f);
|
||||
if(!$path || !str_starts_with($path, realpath($dir)) || !is_file($path)){ http_response_code(404); exit('Not found'); }
|
||||
|
||||
while(ob_get_level()) ob_end_clean();
|
||||
header('Content-Type: application/octet-stream');
|
||||
header('Content-Disposition: attachment; filename="'.basename($path).'"');
|
||||
header('Content-Length: '.filesize($path));
|
||||
readfile($path); exit;
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
/**
|
||||
* clean_uploads.php
|
||||
* Compatible:
|
||||
* - accès direct /admin/tools/clean_uploads.php
|
||||
* - inclusion via /admin/index.php?page=clean_uploads
|
||||
*/
|
||||
|
||||
// Si APP_HTDOCS n'existe pas, c'est qu'on n'est pas passé par le bootstrap admin
|
||||
if (! defined('APP_HTDOCS')) {
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
}
|
||||
|
||||
// Dossier uploads (chez toi: htdocs/public/uploads/)
|
||||
$uploadDir = rtrim(APP_HTDOCS, '/\\') . '/public/uploads/';
|
||||
if (! is_dir($uploadDir)) {
|
||||
http_response_code(500);
|
||||
exit("Uploads introuvable: " . $uploadDir);
|
||||
}
|
||||
$uploadDir = rtrim($uploadDir, "/\\") . DIRECTORY_SEPARATOR;
|
||||
|
||||
// Assurer PDO
|
||||
if (! isset($pdo) || ! ($pdo instanceof PDO)) {
|
||||
http_response_code(500);
|
||||
exit("PDO introuvable (DB non initialisée).");
|
||||
}
|
||||
|
||||
// ⚙️ Sources DB : tu peux en ajouter plusieurs (plusieurs tables/colonnes)
|
||||
$dbSources = [
|
||||
[
|
||||
'sql' => "SELECT photo FROM praticien WHERE photo IS NOT NULL AND photo <> ''",
|
||||
'col' => 'photo',
|
||||
],
|
||||
// Exemple si tu as d'autres tables :
|
||||
// ['sql' => "SELECT image FROM articles WHERE image IS NOT NULL AND image <> ''", 'col' => 'image'],
|
||||
];
|
||||
|
||||
// --- CSRF token ---
|
||||
if (empty($_SESSION['csrf_clean_uploads'])) {
|
||||
$_SESSION['csrf_clean_uploads'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$csrf = $_SESSION['csrf_clean_uploads'];
|
||||
|
||||
// --- Paramètres ---
|
||||
$limitList = 400; // pour éviter une page énorme si milliers de fichiers
|
||||
$action = $_POST['action'] ?? '';
|
||||
$confirmed = isset($_POST['confirm_delete']) && $_POST['confirm_delete'] === '1';
|
||||
|
||||
// --- 1) Charger noms d'images depuis la DB ---
|
||||
$dbNames = [];
|
||||
foreach ($dbSources as $src) {
|
||||
$stmt = $pdo->query($src['sql']);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($rows as $name) {
|
||||
$name = trim((string) $name);
|
||||
if ($name !== '') {
|
||||
$dbNames[$name] = true;
|
||||
}
|
||||
// set
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2) Scanner le dossier uploads (fichiers seulement) ---
|
||||
$files = @scandir($uploadDir);
|
||||
if (! is_array($files)) {
|
||||
http_response_code(500);
|
||||
exit('Impossible de lire le dossier uploads.');
|
||||
}
|
||||
|
||||
$orphans = []; // [ ['name'=>..., 'size'=>..., 'mtime'=>...] ]
|
||||
$totalOrphanSize = 0;
|
||||
|
||||
foreach ($files as $f) {
|
||||
if ($f === '.' || $f === '..') {
|
||||
continue;
|
||||
if ($f[0] === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ignore fichiers système fréquents
|
||||
if ($f === '.DS_Store' || $f === 'Thumbs.db' || $f === '.htaccess') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$full = $uploadDir . $f;
|
||||
|
||||
// uniquement les fichiers (pas sous-dossiers)
|
||||
if (! is_file($full)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// si le fichier n'est pas dans la base => orphelin
|
||||
if (! isset($dbNames[$f])) {
|
||||
$size = filesize($full);
|
||||
$mtime = filemtime($full);
|
||||
$orphans[] = [
|
||||
'name' => $f,
|
||||
'size' => is_int($size) ? $size : 0,
|
||||
'mtime' => is_int($mtime) ? $mtime : 0,
|
||||
];
|
||||
$totalOrphanSize += (is_int($size) ? $size : 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Tri : plus gros d'abord (pratique pour voir vite les gains)
|
||||
usort($orphans, fn($a, $b) => ($b['size'] <=> $a['size']));
|
||||
|
||||
// --- 3) Suppression si demandé ---
|
||||
$deleted = [];
|
||||
$errors = [];
|
||||
|
||||
if ($action === 'delete') {
|
||||
// CSRF
|
||||
$postedCsrf = (string) ($_POST['csrf'] ?? '');
|
||||
if (! hash_equals($csrf, $postedCsrf)) {
|
||||
$errors[] = "CSRF invalide. Recharge la page.";
|
||||
} elseif (! $confirmed) {
|
||||
$errors[] = "Coche la confirmation avant de supprimer.";
|
||||
} else {
|
||||
// On supprime uniquement les orphelins trouvés à l’instant T
|
||||
foreach ($orphans as $o) {
|
||||
$name = $o['name'];
|
||||
|
||||
// Sécurité anti traversal
|
||||
if (str_contains($name, '/') || str_contains($name, '\\') || $name === '') {
|
||||
$errors[] = "Nom de fichier suspect ignoré: " . h($name);
|
||||
continue;
|
||||
}
|
||||
|
||||
$full = $uploadDir . $name;
|
||||
|
||||
// recheck réel
|
||||
if (! is_file($full)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// s'assurer qu'on reste dans uploadDir
|
||||
$real = realpath($full);
|
||||
if ($real === false || strncmp($real, $uploadDir, strlen($uploadDir)) !== 0) {
|
||||
$errors[] = "Chemin refusé: " . h($name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (@unlink($real)) {
|
||||
$deleted[] = $name;
|
||||
} else {
|
||||
$errors[] = "Impossible de supprimer: " . h($name);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-générer un nouveau token après action destructive
|
||||
$_SESSION['csrf_clean_uploads'] = bin2hex(random_bytes(32));
|
||||
$csrf = $_SESSION['csrf_clean_uploads'];
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function format_bytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$i = 0;
|
||||
$val = (float) $bytes;
|
||||
while ($val >= 1024 && $i < count($units) - 1) {$val /= 1024;
|
||||
$i++;}
|
||||
return ($i === 0 ? (string) $bytes : number_format($val, 2, '.', ' ')) . ' ' . $units[$i];
|
||||
}
|
||||
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Nettoyage uploads (orphelins)</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:20px;line-height:1.35}
|
||||
.box{padding:12px 14px;border:1px solid #ddd;border-radius:10px;margin:12px 0}
|
||||
.ok{background:#f2fff5;border-color:#bde8c7}
|
||||
.err{background:#fff4f4;border-color:#f0b9b9}
|
||||
table{border-collapse:collapse;width:100%;margin-top:10px}
|
||||
th,td{border-bottom:1px solid #eee;padding:8px 6px;text-align:left;font-size:14px}
|
||||
th{background:#fafafa}
|
||||
code{background:#f6f6f6;padding:2px 6px;border-radius:6px}
|
||||
.muted{color:#666}
|
||||
.row{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
|
||||
.btn{display:inline-block;padding:10px 14px;border-radius:10px;border:1px solid #ccc;background:#fff;cursor:pointer}
|
||||
.btn-danger{border-color:#e3a4a4;background:#fff1f1}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h2>Nettoyage du dossier uploads</h2>
|
||||
<div class="box">
|
||||
<div class="muted">Dossier :</div>
|
||||
<div><code><?php echo h($uploadDir) ?></code></div>
|
||||
</div>
|
||||
|
||||
<?php if (! empty($deleted)): ?>
|
||||
<div class="box ok">
|
||||
✅ Supprimés : <b><?php echo count($deleted) ?></b>
|
||||
<div class="muted" style="margin-top:6px;">
|
||||
Exemple : <?php echo h($deleted[0]) ?><?php echo count($deleted) > 1 ? ' …' : '' ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (! empty($errors)): ?>
|
||||
<div class="box err">
|
||||
❌ Erreurs :
|
||||
<ul>
|
||||
<?php foreach ($errors as $e): ?>
|
||||
<li><?php echo $e ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="box">
|
||||
<div class="row">
|
||||
<div>Fichiers orphelins : <b><?php echo count($orphans) ?></b></div>
|
||||
<div>Taille totale : <b><?php echo h(format_bytes((int) $totalOrphanSize)) ?></b></div>
|
||||
<div class="muted">(triés par taille décroissante)</div>
|
||||
</div>
|
||||
|
||||
<?php if (count($orphans) === 0): ?>
|
||||
<p class="muted" style="margin-top:10px;">Aucun fichier orphelin trouvé ✅</p>
|
||||
<?php else: ?>
|
||||
<form method="post" onsubmit="return confirm('Confirmer la suppression des fichiers orphelins ?');" style="margin-top:12px;">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="csrf" value="<?php echo h($csrf) ?>">
|
||||
|
||||
<label style="display:flex;gap:8px;align-items:center;margin:10px 0;">
|
||||
<input type="checkbox" name="confirm_delete" value="1">
|
||||
<span>Je confirme la suppression de <b><?php echo count($orphans) ?></b> fichiers orphelins (<?php echo h(format_bytes((int) $totalOrphanSize)) ?>).</span>
|
||||
</label>
|
||||
|
||||
<button class="btn btn-danger" type="submit">Supprimer maintenant</button>
|
||||
<span class="muted" style="margin-left:10px;">(Tu as déjà une copie sur ton PC, parfait.)</span>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Fichier</th>
|
||||
<th>Taille</th>
|
||||
<th>Dernière modif</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$max = min(count($orphans), $limitList);
|
||||
for ($i = 0; $i < $max; $i++):
|
||||
$o = $orphans[$i];
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo $i + 1 ?></td>
|
||||
<td><?php echo h($o['name']) ?></td>
|
||||
<td><?php echo h(format_bytes((int) $o['size'])) ?></td>
|
||||
<td><?php echo $o['mtime'] ? h(date('Y-m-d H:i:s', (int) $o['mtime'])) : '-' ?></td>
|
||||
</tr>
|
||||
<?php endfor; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if (count($orphans) > $limitList): ?>
|
||||
<p class="muted">Affichage limité à <?php echo (int) $limitList ?> fichiers. (Mais la suppression concerne tous les orphelins trouvés.)</p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
@session_start();
|
||||
}
|
||||
|
||||
// Lang (pour $translations)
|
||||
|
||||
|
||||
function e($s): string {
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
const ADMIN_PRACTICIEN_ID = 88888888;
|
||||
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo '<div class="container py-5"><div class="alert alert-danger">'
|
||||
. e($translations['admin_access_denied'] ?? 'Доступ запрещён.')
|
||||
. '</div></div>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Reauth (10 min)
|
||||
$mustReauth = function (int $ttl = 600) {
|
||||
$t = (int)($_SESSION['admin_reauth_at'] ?? 0);
|
||||
return ($t === 0 || (time() - $t) > $ttl);
|
||||
};
|
||||
if ($mustReauth()) {
|
||||
$qs = http_build_query(['page' => 'reauth', 'redir' => ($_SERVER['REQUEST_URI'] ?? 'index.php?page=maintenance')]);
|
||||
header('Location:?' . $qs);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
// CSRF
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
// Récupération des comptes non validés depuis >6h
|
||||
$sql = "SELECT * FROM compte WHERE email_valide = 0 AND TIMESTAMPDIFF(HOUR, cree_le, NOW()) > 6";
|
||||
$stmt = $pdo->query($sql);
|
||||
$comptes = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Ajouter nom, prénom et âge du compte
|
||||
foreach ($comptes as &$c) {
|
||||
$nom = $prenom = ($translations['label_unknown'] ?? '(inconnu)');
|
||||
|
||||
if ($c['role'] === 'client') {
|
||||
$stmt2 = $pdo->prepare("SELECT nom, prenom FROM client WHERE id = :id");
|
||||
$stmt2->execute(['id' => $c['reference_id']]);
|
||||
$info = $stmt2->fetch(PDO::FETCH_ASSOC);
|
||||
if ($info) { $nom = $info['nom']; $prenom = $info['prenom']; }
|
||||
} elseif ($c['role'] === 'praticien') {
|
||||
$stmt2 = $pdo->prepare("SELECT nom, prenom FROM praticien WHERE id = :id");
|
||||
$stmt2->execute(['id' => $c['reference_id']]);
|
||||
$info = $stmt2->fetch(PDO::FETCH_ASSOC);
|
||||
if ($info) { $nom = $info['nom']; $prenom = $info['prenom']; }
|
||||
}
|
||||
|
||||
$c['nom'] = $nom;
|
||||
$c['prenom'] = $prenom;
|
||||
|
||||
// Calculer la durée depuis création
|
||||
$diff = time() - strtotime($c['cree_le']);
|
||||
$heures = floor($diff / 3600);
|
||||
$minutes = floor(($diff % 3600) / 60);
|
||||
$c['age_humain'] = sprintf("%dh %02dmin", $heures, $minutes);
|
||||
}
|
||||
unset($c);
|
||||
|
||||
// --- Suppression sur POST ---
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST'
|
||||
&& isset($_POST['csrf_token'])
|
||||
&& hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
|
||||
&& !empty($_POST['ids'])) {
|
||||
|
||||
$idsToDelete = array_map('intval', $_POST['ids']);
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($comptes as $compte) {
|
||||
if (!in_array($compte['id'], $idsToDelete, true)) continue;
|
||||
|
||||
$id = $compte['id'];
|
||||
$role = $compte['role'];
|
||||
$ref_id = $compte['reference_id'];
|
||||
|
||||
// Supprimer fiche associée
|
||||
if ($role === 'client') {
|
||||
$pdo->prepare("DELETE FROM client WHERE id = :id")->execute(['id' => $ref_id]);
|
||||
} elseif ($role === 'praticien') {
|
||||
$pdo->prepare("DELETE FROM praticien WHERE id = :id")->execute(['id' => $ref_id]);
|
||||
}
|
||||
|
||||
// Supprimer le compte
|
||||
$pdo->prepare("DELETE FROM compte WHERE id = :id")->execute(['id' => $id]);
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$msgTpl = ($translations['flash_unverified_deleted'] ?? '✅ {N} compte(s) non validé(s) supprimé(s).');
|
||||
$msg = str_replace('{N}', (string)$deleted, $msgTpl);
|
||||
|
||||
echo '<div class="alert alert-success text-center p-4">'
|
||||
. e($msg)
|
||||
. '</div>';
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-3 text-center">
|
||||
<?= e($translations['h2_unverified_accounts_6h'] ?? '🕒 Comptes non validés depuis plus de 6h') ?>
|
||||
</h2>
|
||||
|
||||
<?php if (empty($comptes)): ?>
|
||||
<div class="alert alert-success text-center">
|
||||
<?= e($translations['alert_no_unverified_accounts'] ?? 'Aucun compte non validé à supprimer 🎉') ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form method="post" onsubmit="return confirm('<?= e($translations['confirm_delete_checked_accounts'] ?? '⚠️ Supprimer les comptes cochés ?') ?>');">
|
||||
<input type="hidden" name="csrf_token" value="<?= e($CSRF) ?>">
|
||||
|
||||
<table class="table table-bordered table-sm align-middle text-center">
|
||||
<thead class="table-secondary">
|
||||
<tr>
|
||||
<th><input type="checkbox" id="checkAll" aria-label="<?= e($translations['aria_check_all'] ?? 'Tout cocher') ?>"></th>
|
||||
<th><?= e($translations['th_id'] ?? 'ID') ?></th>
|
||||
<th><?= e($translations['th_email'] ?? 'Email') ?></th>
|
||||
<th><?= e($translations['th_nom'] ?? 'Nom') ?></th>
|
||||
<th><?= e($translations['th_prenom'] ?? 'Prénom') ?></th>
|
||||
<th><?= e($translations['th_role'] ?? 'Rôle') ?></th>
|
||||
<th><?= e($translations['th_created_at'] ?? 'Créé le') ?></th>
|
||||
<th><?= e($translations['th_account_age'] ?? 'Âge du compte') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($comptes as $compte): ?>
|
||||
<tr>
|
||||
<td><input type="checkbox" name="ids[]" value="<?= (int)$compte['id'] ?>"></td>
|
||||
<td><?= e($compte['id']) ?></td>
|
||||
<td><?= e($compte['email']) ?></td>
|
||||
<td><?= e($compte['nom']) ?></td>
|
||||
<td><?= e($compte['prenom']) ?></td>
|
||||
<td><?= e($compte['role']) ?></td>
|
||||
<td><?= e($compte['cree_le']) ?></td>
|
||||
<td><?= e($compte['age_humain']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<small class="text-muted">
|
||||
<?php
|
||||
$tpl = ($translations['label_accounts_listed'] ?? 'Comptes listés : {N}');
|
||||
echo e(str_replace('{N}', (string)count($comptes), $tpl));
|
||||
?>
|
||||
</small>
|
||||
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<?= e($translations['btn_delete_checked_accounts'] ?? '🗑️ Supprimer les comptes cochés') ?>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Cocher / décocher tout
|
||||
document.getElementById('checkAll').addEventListener('change', function() {
|
||||
const boxes = document.querySelectorAll('input[name="ids[]"]');
|
||||
boxes.forEach(b => b.checked = this.checked);
|
||||
});
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
/**
|
||||
* /admin/tools/cleanup_rdv.php
|
||||
* Nettoyage des rendez-vous (table `rendez_vous`)
|
||||
* - Cibles: annulé (>N jours), prévu passé (>N jours), (option) terminé (>N jours), orphelins, doublons exacts
|
||||
* - DRY-RUN par défaut ; purge LIVE via POST + CSRF
|
||||
* - Reauth admin (10 min)
|
||||
* - Journal d’audit (admin_audit)
|
||||
*/
|
||||
|
||||
if (($_GET['page'] ?? '') !== 'cleanup_rdv') return;
|
||||
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
$roles = (array)($_SESSION['role'] ?? []);
|
||||
if (!in_array('admin', $roles, true)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['clean_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isset($pdo) || !($pdo instanceof PDO)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['clean_db_unavailable'] ?? 'DB indisponible.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (Throwable) {}
|
||||
|
||||
require_once __DIR__.'/_lib_audit.php';
|
||||
|
||||
// ---- Reauth 10 min
|
||||
$mustReauth = function(int $ttl=600){ $t=(int)($_SESSION['admin_reauth_at']??0); return ($t===0 || (time()-$t)>$ttl); };
|
||||
if ($mustReauth()) {
|
||||
$qs = http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI'] ?? 'index.php?page=maintenance')]);
|
||||
header('Location:?'.$qs); exit;
|
||||
}
|
||||
|
||||
// ---- Helpers (closures, pas de fonctions globales)
|
||||
$e = static fn($s)=>htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token']=bin2hex(random_bytes(32));
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
// ---- Paramètres UI
|
||||
$days = max(0, (int)($_REQUEST['days'] ?? 30));
|
||||
$mode = ($_REQUEST['mode'] ?? 'dry') === 'live' ? 'live' : 'dry';
|
||||
$dry = ($mode !== 'live');
|
||||
|
||||
$opt_cancelled = isset($_REQUEST['cancelled']) ? ($_REQUEST['cancelled']==='1') : true; // "annulé"
|
||||
$opt_past_prevu = isset($_REQUEST['past_prevu']) ? ($_REQUEST['past_prevu']==='1') : true; // "prévu" passé
|
||||
$opt_terminated = isset($_REQUEST['terminated']) ? ($_REQUEST['terminated']==='1') : false; // "terminé" (option)
|
||||
$opt_orphans = isset($_REQUEST['orphans']) ? ($_REQUEST['orphans']==='1') : true; // orphelins
|
||||
$opt_duplicates = isset($_REQUEST['duplicates']) ? ($_REQUEST['duplicates']==='1') : true; // doublons exacts
|
||||
|
||||
// ---- Cutoff (on évite INTERVAL ? DAY ; on calcule en PHP)
|
||||
$cutoff = date('Y-m-d H:i:s', time() - $days*86400);
|
||||
|
||||
// ---- Sanity: table & colonnes
|
||||
$tbl = 'rendez_vous';
|
||||
$hasTable = (bool)$pdo->query("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'rendez_vous'")->fetchColumn();
|
||||
if (!$hasTable) {
|
||||
$tpl = (string)($translations['clean_table_missing'] ?? 'Table <code>{T}</code> introuvable.');
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'.str_replace('{T}', $e('rendez_vous'), $tpl).'</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Détection tables référentielles (souples : pluriel/singulier)
|
||||
$guessTable = static function(PDO $pdo, array $candidates): ?string {
|
||||
foreach ($candidates as $t) {
|
||||
$st = $pdo->prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?");
|
||||
$st->execute([$t]);
|
||||
if ($st->fetchColumn()) return $t;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
$tblPraticiens = $guessTable($pdo, ['praticiens','praticien','praticien_profil','praticien_profile']);
|
||||
$tblClients = $guessTable($pdo, ['clients','client']);
|
||||
|
||||
// ---- Collecte candidats par catégorie
|
||||
$cats = []; // ['key'=>['title'=>..., 'ids'=>[], 'rows'=>[...]]]
|
||||
$pushCat = static function(array &$cats, string $key, string $title, array $rows) {
|
||||
$cats[$key] = ['title'=>$title, 'rows'=>$rows, 'ids'=>array_map(fn($r)=>(int)$r['id'],$rows)];
|
||||
};
|
||||
|
||||
// 1) "annulé" avant $cutoff
|
||||
if ($opt_cancelled) {
|
||||
$st = $pdo->prepare("SELECT id, statut, date_rdv FROM `{$tbl}` WHERE statut = 'annulé' AND date_rdv < :cut ORDER BY date_rdv ASC LIMIT 5000");
|
||||
$st->execute([':cut'=>$cutoff]);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$tpl = (string)($translations['clean_cat_cancelled_title'] ?? 'RDV annulés avant {D} j');
|
||||
$pushCat($cats, 'cancelled', str_replace('{D}', (string)$days, $tpl), $rows);
|
||||
}
|
||||
|
||||
// 2) "prévu" passé avant $cutoff (no-show)
|
||||
if ($opt_past_prevu) {
|
||||
$st = $pdo->prepare("SELECT id, statut, date_rdv FROM `{$tbl}` WHERE statut = 'prévu' AND date_rdv < :cut ORDER BY date_rdv ASC LIMIT 5000");
|
||||
$st->execute([':cut'=>$cutoff]);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$tpl = (string)($translations['clean_cat_past_prevu_title'] ?? 'RDV prévus mais passés (> {D} j)');
|
||||
$pushCat($cats, 'past_prevu', str_replace('{D}', (string)$days, $tpl), $rows);
|
||||
}
|
||||
|
||||
// 3) (option) "terminé" avant $cutoff
|
||||
if ($opt_terminated) {
|
||||
$st = $pdo->prepare("SELECT id, statut, date_rdv FROM `{$tbl}` WHERE statut = 'terminé' AND date_rdv < :cut ORDER BY date_rdv ASC LIMIT 5000");
|
||||
$st->execute([':cut'=>$cutoff]);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$tpl = (string)($translations['clean_cat_terminated_title'] ?? 'RDV terminés (> {D} j)');
|
||||
$pushCat($cats, 'terminated', str_replace('{D}', (string)$days, $tpl), $rows);
|
||||
}
|
||||
|
||||
// 4) Orphelins (praticien/client inexistants)
|
||||
if ($opt_orphans && $tblPraticiens && $tblClients) {
|
||||
$sql = "SELECT rv.id, rv.statut, rv.date_rdv
|
||||
FROM `{$tbl}` rv
|
||||
LEFT JOIN `{$tblPraticiens}` p ON p.id = rv.praticien_id
|
||||
LEFT JOIN `{$tblClients}` c ON c.id = rv.client_id
|
||||
WHERE (p.id IS NULL OR c.id IS NULL)
|
||||
ORDER BY rv.date_rdv ASC
|
||||
LIMIT 5000";
|
||||
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$pushCat($cats, 'orphans', (string)($translations['clean_cat_orphans_title'] ?? 'RDV orphelins (praticien/client manquants)'), $rows);
|
||||
} elseif ($opt_orphans) {
|
||||
$rows = [];
|
||||
$pushCat($cats, 'orphans', (string)($translations['clean_cat_orphans_tables_missing'] ?? 'RDV orphelins (tables praticiens/clients introuvables)'), $rows);
|
||||
}
|
||||
|
||||
// 5) Doublons exacts (même praticien_id, client_id, date_rdv). On garde le MIN(id), supprime le reste.
|
||||
if ($opt_duplicates) {
|
||||
// On limite aux RDV plus vieux que cutoff pour rester conservateurs
|
||||
$sql = "
|
||||
SELECT rv2.id, rv2.statut, rv2.date_rdv
|
||||
FROM `{$tbl}` rv2
|
||||
JOIN (
|
||||
SELECT praticien_id, client_id, date_rdv, MIN(id) keep_id, COUNT(*) cnt
|
||||
FROM `{$tbl}`
|
||||
WHERE date_rdv < :cut
|
||||
GROUP BY praticien_id, client_id, date_rdv
|
||||
HAVING cnt > 1
|
||||
) d ON d.praticien_id = rv2.praticien_id
|
||||
AND d.client_id = rv2.client_id
|
||||
AND d.date_rdv = rv2.date_rdv
|
||||
WHERE rv2.id <> d.keep_id
|
||||
AND rv2.statut <> 'confirmé'
|
||||
ORDER BY rv2.date_rdv ASC
|
||||
LIMIT 5000";
|
||||
$st = $pdo->prepare($sql); $st->execute([':cut'=>$cutoff]);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$pushCat($cats, 'duplicates', (string)($translations['clean_cat_duplicates_title'] ?? 'Doublons exacts (on garde l’ID le plus bas)'), $rows);
|
||||
}
|
||||
|
||||
// ---- Union de tous les IDs à supprimer
|
||||
$allIds = [];
|
||||
foreach ($cats as $k=>$c) { foreach ($c['ids'] as $id) { $allIds[$id] = true; } }
|
||||
$allIds = array_keys($allIds);
|
||||
|
||||
// ---- Exécution LIVE (DELETE) si demandé
|
||||
$deleted = 0; $err = null;
|
||||
if (!$dry && $_SERVER['REQUEST_METHOD']==='POST' && ($_POST['action'] ?? '')==='purge') {
|
||||
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
|
||||
$err = (string)($translations['clean_csrf_invalid'] ?? 'Token CSRF invalide.');
|
||||
} else {
|
||||
if ($allIds) {
|
||||
// Suppression en batch
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Découpage par tranches de 1000
|
||||
$chunks = array_chunk($allIds, 1000);
|
||||
foreach ($chunks as $batch) {
|
||||
$placeholders = implode(',', array_fill(0, count($batch), '?'));
|
||||
$st = $pdo->prepare("DELETE FROM `{$tbl}` WHERE id IN ($placeholders)");
|
||||
$st->execute(array_map('intval', $batch));
|
||||
$deleted += $st->rowCount();
|
||||
}
|
||||
$pdo->commit();
|
||||
AdminAudit::log($pdo, [
|
||||
'admin_id' => (int)($_SESSION['user_id'] ?? 0),
|
||||
'action' => 'rdv_cleanup',
|
||||
'scope' => 'maintenance',
|
||||
'target_table' => $tbl,
|
||||
'meta' => [
|
||||
'deleted' => $deleted,
|
||||
'days' => $days,
|
||||
'cancelled' => $opt_cancelled,
|
||||
'past_prevu' => $opt_past_prevu,
|
||||
'terminated' => $opt_terminated,
|
||||
'orphans' => $opt_orphans,
|
||||
'duplicates' => $opt_duplicates
|
||||
]
|
||||
]);
|
||||
} catch (Throwable $ex) {
|
||||
$pdo->rollBack();
|
||||
$err = (string)($translations['clean_delete_error_prefix'] ?? 'Erreur suppression: ') . $e($ex->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Rendu
|
||||
$totalByCat = []; foreach ($cats as $k=>$c){ $totalByCat[$k]=count($c['ids']); }
|
||||
$totalAll = count($allIds);
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<h3 class="m-0"><?= $e($translations['clean_title'] ?? 'Nettoyage des rendez-vous') ?></h3>
|
||||
<span class="badge text-bg-<?= $dry?'secondary':'danger' ?>">
|
||||
<?= $dry ? $e($translations['clean_mode_dry'] ?? 'DRY-RUN (simulation)') : $e($translations['clean_mode_live'] ?? 'LIVE (suppression activée)') ?>
|
||||
</span>
|
||||
<span class="badge text-bg-info">
|
||||
<?php
|
||||
$tpl = (string)($translations['clean_age_badge'] ?? 'Âge ≥ {D} j (référence: <code>{COL}</code>)');
|
||||
echo str_replace(['{D}','{COL}'], [$e((string)(int)$days), $e('date_rdv')], $tpl);
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<?php if($err): ?><div class="alert alert-danger"><?= $e($err) ?></div><?php endif; ?>
|
||||
<?php if(!$err && !$dry && $deleted>0): ?>
|
||||
<div class="alert alert-success">
|
||||
<?php
|
||||
$tpl = (string)($translations['clean_deleted_ok'] ?? '{N} rendez-vous supprimés.');
|
||||
echo $e(str_replace('{N}', (string)(int)$deleted, $tpl));
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="csrf" value="<?= $e($CSRF) ?>">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label"><?= $e($translations['clean_age_label'] ?? 'Âge minimum') ?></label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" type="number" min="0" name="days" value="<?= (int)$days ?>">
|
||||
<span class="input-group-text"><?= $e($translations['clean_days_unit'] ?? 'jours') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-9">
|
||||
<label class="form-label"><?= $e($translations['clean_categories_label'] ?? 'Catégories ciblées') ?></label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="cancelled" value="1" <?= $opt_cancelled?'checked':'' ?>>
|
||||
<span class="form-check-label"><?= $e($translations['clean_opt_cancelled'] ?? 'Annulés') ?></span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="past_prevu" value="1" <?= $opt_past_prevu?'checked':'' ?>>
|
||||
<span class="form-check-label"><?= $e($translations['clean_opt_past_prevu'] ?? 'Prévu (dépassés)') ?></span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="terminated" value="1" <?= $opt_terminated?'checked':'' ?>>
|
||||
<span class="form-check-label"><?= $e($translations['clean_opt_terminated'] ?? 'Terminés') ?></span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="orphans" value="1" <?= $opt_orphans?'checked':'' ?>>
|
||||
<span class="form-check-label"><?= $e($translations['clean_opt_orphans'] ?? 'Orphelins') ?></span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="duplicates" value="1" <?= $opt_duplicates?'checked':'' ?>>
|
||||
<span class="form-check-label"><?= $e($translations['clean_opt_duplicates'] ?? 'Doublons exacts') ?></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary"
|
||||
name="action" value="scan"
|
||||
formaction="?page=cleanup_rdv&mode=dry">
|
||||
<?= $e($translations['clean_btn_scan'] ?? 'Scanner') ?>
|
||||
</button>
|
||||
<button class="btn btn-danger"
|
||||
name="action" value="purge"
|
||||
formaction="?page=cleanup_rdv&mode=live"
|
||||
onclick="return confirm('<?= $e($translations['clean_confirm_purge'] ?? 'Confirmer la suppression des RDV listés ?') ?>')">
|
||||
<?= $e($translations['clean_btn_purge'] ?? 'Purger') ?>
|
||||
</button>
|
||||
<div class="ms-auto d-flex align-items-center small text-muted">
|
||||
<?php
|
||||
$tpl = (string)($translations['clean_table_label'] ?? 'Table:');
|
||||
echo $e($tpl);
|
||||
?>
|
||||
<code class="ms-1"><?= $e($tbl) ?></code>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><?= $e($translations['clean_summary_title'] ?? 'Résumé') ?></div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_total'] ?? 'Total candidats') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)$totalAll ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_cancelled'] ?? 'Annulés') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)($totalByCat['cancelled'] ?? 0) ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_past_prevu'] ?? 'Prévu (dépassés)') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)($totalByCat['past_prevu'] ?? 0) ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_terminated'] ?? 'Terminés') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)($totalByCat['terminated'] ?? 0) ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_orphans'] ?? 'Orphelins') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)($totalByCat['orphans'] ?? 0) ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_duplicates'] ?? 'Doublons') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)($totalByCat['duplicates'] ?? 0) ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['clean_sum_mode'] ?? 'Mode') ?>
|
||||
<span class="badge text-bg-<?= $dry?'secondary':'danger' ?>"><?= $e($dry ? ($translations['clean_mode_short_dry'] ?? 'DRY-RUN') : ($translations['clean_mode_short_live'] ?? 'LIVE')) ?></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><?= $e($translations['clean_preview_title'] ?? 'Aperçu des candidats (max 150 par catégorie)') ?></div>
|
||||
<div class="card-body p-0">
|
||||
<?php foreach ($cats as $key=>$cat):
|
||||
$rows = array_slice($cat['rows'], 0, 150);
|
||||
?>
|
||||
<div class="p-2 border-bottom fw-semibold"><?= $e($cat['title']) ?> — <?= count($cat['rows']) ?> <?= $e($translations['clean_found'] ?? 'trouvés') ?></div>
|
||||
<?php if (!$rows): ?>
|
||||
<div class="p-3 small text-muted"><?= $e($translations['clean_none_for_criteria'] ?? 'Aucun avec ces critères.') ?></div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr>
|
||||
<th><?= $e($translations['clean_th_id'] ?? 'ID') ?></th>
|
||||
<th><?= $e($translations['clean_th_status'] ?? 'Statut') ?></th>
|
||||
<th><?= $e($translations['clean_th_date_rdv'] ?? 'Date RDV') ?></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r): ?>
|
||||
<tr>
|
||||
<td><?= (int)$r['id'] ?></td>
|
||||
<td><span class="badge text-bg-secondary"><?= $e($r['statut'] ?? $r['s'] ?? '') ?></span></td>
|
||||
<td><?= $e($r['date_rdv'] ?? $r['d'] ?? '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
// admin/tools/connecter_en_admin.php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
const ADMIN_PRACTICIEN_ID = 88888888;
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo '<div class="container py-5"><div class="alert alert-danger">'.htmlspecialchars(($translations['access_denied'] ?? 'Доступ запрещён.'), ENT_QUOTES, 'UTF-8').'</div></div>';
|
||||
exit;
|
||||
}
|
||||
// reauth (10 min)
|
||||
$mustReauth=function(int $ttl=600){$t=(int)($_SESSION['admin_reauth_at']??0);return ($t===0||(time()-$t)>$ttl);};
|
||||
if ($mustReauth()){
|
||||
$qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]);
|
||||
header('Location:?'.$qs);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF
|
||||
if (!isset($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
function e($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
||||
|
||||
// Helpers DB : test existence de la table de log
|
||||
function tableExists(PDO $pdo, string $table): bool {
|
||||
$sql = "SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1";
|
||||
$st = $pdo->prepare($sql);
|
||||
$st->execute([$table]);
|
||||
return (bool)$st->fetchColumn();
|
||||
}
|
||||
|
||||
// ------------ Traitement POST ------------
|
||||
$flash = null;
|
||||
|
||||
if (($_POST['do'] ?? '') === 'impersonate') {
|
||||
try {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
|
||||
throw new Exception('CSRF');
|
||||
}
|
||||
|
||||
$email = trim((string)($_POST['email'] ?? ''));
|
||||
if ($email === '') {
|
||||
throw new Exception('NO_EMAIL');
|
||||
}
|
||||
|
||||
// On cherche dans la table compte (à adapter si le nom diffère)
|
||||
$sql = "SELECT id, email, role, reference_id
|
||||
FROM compte
|
||||
WHERE email = ?
|
||||
LIMIT 1";
|
||||
$st = $pdo->prepare($sql);
|
||||
$st->execute([$email]);
|
||||
$user = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user) {
|
||||
throw new Exception('NOT_FOUND');
|
||||
}
|
||||
|
||||
$role = (string)($user['role'] ?? '');
|
||||
|
||||
// On autorise seulement certains rôles à être "impersonnés"
|
||||
$allowedRoles = ['praticien', 'client'];
|
||||
if (!in_array($role, $allowedRoles, true)) {
|
||||
throw new Exception('ROLE_FORBIDDEN');
|
||||
}
|
||||
|
||||
// Sauvegarder l’admin actuel si ce n’est pas déjà fait
|
||||
if (empty($_SESSION['admin_impersonate']) || !is_array($_SESSION['admin_impersonate'])) {
|
||||
$_SESSION['admin_impersonate'] = [
|
||||
'admin_user_id' => $_SESSION['user_id'] ?? null,
|
||||
'admin_reference_id' => $_SESSION['reference_id'] ?? null,
|
||||
'admin_role' => $_SESSION['role'] ?? null,
|
||||
'started_at' => time(),
|
||||
];
|
||||
}
|
||||
|
||||
// Log de l’action si la table existe
|
||||
if (tableExists($pdo, 'admin_impersonation_log')) {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? null;
|
||||
$adminUid = (int)($_SESSION['user_id'] ?? 0);
|
||||
$adminRef = (int)($_SESSION['reference_id'] ?? 0);
|
||||
$targetUid = (int)($user['id'] ?? 0);
|
||||
$targetRef = (int)($user['reference_id'] ?? 0);
|
||||
$emailLog = (string)($user['email'] ?? '');
|
||||
|
||||
$logSql = "INSERT INTO admin_impersonation_log
|
||||
(admin_user_id, admin_reference_id, target_user_id, target_reference_id, target_role, email, ip, user_agent)
|
||||
VALUES (?,?,?,?,?,?,?,?)";
|
||||
$pdo->prepare($logSql)->execute([
|
||||
$adminUid,
|
||||
$adminRef,
|
||||
$targetUid,
|
||||
$targetRef,
|
||||
$role,
|
||||
$emailLog,
|
||||
$ip,
|
||||
$ua,
|
||||
]);
|
||||
}
|
||||
|
||||
// Basculer la session sur l’utilisateur cible
|
||||
$_SESSION['user_id'] = (int)$user['id'];
|
||||
$_SESSION['role'] = $role;
|
||||
$_SESSION['reference_id'] = (int)($user['reference_id'] ?? 0);
|
||||
|
||||
// Redirection selon le rôle
|
||||
if ($role === 'praticien') {
|
||||
header('Location: /public/espace_praticien/index.php');
|
||||
} elseif ($role === 'client') {
|
||||
header('Location: /public/espace_client/index.php');
|
||||
} else {
|
||||
// fallback
|
||||
header('Location: /');
|
||||
}
|
||||
exit;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$code = $e->getMessage();
|
||||
$msg = ($translations['admin_impersonate_err_default'] ?? 'Ошибка при подключении.');
|
||||
|
||||
if ($code === 'CSRF') $msg = ($translations['admin_impersonate_err_csrf'] ?? 'Ошибка безопасности. Обновите страницу.');
|
||||
if ($code === 'NO_EMAIL') $msg = ($translations['admin_impersonate_err_no_email'] ?? 'Укажите e-mail практикующего или клиента.');
|
||||
if ($code === 'NOT_FOUND') $msg = ($translations['admin_impersonate_err_not_found'] ?? 'Пользователь с таким e-mail не найден.');
|
||||
if ($code === 'ROLE_FORBIDDEN') $msg = ($translations['admin_impersonate_err_role_forbidden'] ?? 'Невозможно подключиться под этим типом пользователя.');
|
||||
|
||||
$flash = ['type'=>'danger','msg'=>$msg];
|
||||
}
|
||||
}
|
||||
|
||||
// Indicateur si on est déjà en mode impersonation (admin_impersonate présent)
|
||||
$isImpersonating = !empty($_SESSION['admin_impersonate']) && is_array($_SESSION['admin_impersonate']);
|
||||
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-3"><?= e($translations['admin_impersonate_title'] ?? 'Подключиться как пользователь') ?></h2>
|
||||
|
||||
<?php if ($isImpersonating): ?>
|
||||
<div class="alert alert-warning">
|
||||
<?= e($translations['admin_impersonate_warn_active'] ?? 'Внимание: сейчас вы авторизованы как другой пользователь.') ?>
|
||||
<a href="/admin/index.php?page=retour_admin" class="btn btn-sm btn-outline-danger ms-2" style="border-radius:999px;">
|
||||
<?= e($translations['admin_impersonate_btn_back_admin'] ?? 'Вернуться в режим администратора') ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($flash): ?>
|
||||
<div class="alert alert-<?= e($flash['type'] ?? 'info') ?>"><?= e($flash['msg'] ?? '') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" class="mb-4">
|
||||
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
|
||||
<input type="hidden" name="do" value="impersonate">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= e($translations['admin_impersonate_label_email'] ?? 'E-mail практикующего или клиента') ?></label>
|
||||
<input type="email" name="email" class="form-control" required
|
||||
placeholder="<?= e($translations['admin_impersonate_ph_email'] ?? 'example@domaine.fr') ?>">
|
||||
<div class="form-text">
|
||||
<?= e($translations['admin_impersonate_help'] ?? 'Администратор будет авторизован под этим пользователем без ввода пароля.') ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-danger" style="border-radius:999px;">
|
||||
<?= e($translations['admin_impersonate_btn_submit'] ?? 'Подключиться под этим пользователем') ?>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<p class="text-muted mb-0">
|
||||
<?= e($translations['admin_impersonate_footer'] ?? 'Все действия должны соответствовать политике конфиденциальности. Доступ к этой функции есть только у супер-администратора.') ?>
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
// ⚠️ FICHIER DE DEBUG — ne pas laisser accessible en prod
|
||||
|
||||
$file = __DIR__ . '/../../public/lang/ru.php'; // adapte si besoin
|
||||
|
||||
$code = file_get_contents($file);
|
||||
if ($code === false) {
|
||||
die("Impossible de lire le fichier");
|
||||
}
|
||||
|
||||
// Match 'key' => ou "key" =>
|
||||
preg_match_all(
|
||||
'/[\'"]([^\'"]+)[\'"]\s*=>/u',
|
||||
$code,
|
||||
$matches
|
||||
);
|
||||
|
||||
$keys = $matches[1] ?? [];
|
||||
|
||||
$total = count($keys);
|
||||
$counts = array_count_values($keys);
|
||||
$duplicates = array_filter($counts, fn($c) => $c > 1);
|
||||
|
||||
echo "<pre>";
|
||||
echo "Total clés trouvées : $total\n\n";
|
||||
|
||||
if (!$duplicates) {
|
||||
echo "✅ Aucun doublon détecté\n";
|
||||
} else {
|
||||
echo "❌ Doublons détectés :\n\n";
|
||||
foreach ($duplicates as $k => $c) {
|
||||
echo "- $k : $c occurrences\n";
|
||||
}
|
||||
}
|
||||
echo "</pre>";
|
||||
@@ -0,0 +1,368 @@
|
||||
<?php
|
||||
/**
|
||||
* /admin/tools/purger_mess_upload.php
|
||||
* Affichage Bootstrap pour purger messages/uploads (orphans + fix DB).
|
||||
* - S'affiche seulement si ?page=purger_mess_upload
|
||||
* - DRY-RUN par défaut (scan), purge LIVE via POST + CSRF.
|
||||
*/
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
if (($_GET['page'] ?? '') !== 'purger_mess_upload') {
|
||||
return; // ne rien afficher hors de la page ciblée
|
||||
}
|
||||
|
||||
// -- Sécurité: on suppose que admin/index.php a déjà fait require_login_or_redirect(['admin'])
|
||||
// Mais on ajoute une ceinture si besoin :
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
|
||||
|
||||
if (empty($_SESSION['role']) || !in_array('admin', (array)$_SESSION['role'], true)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['admin_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
// reauth (10 min)
|
||||
$mustReauth=function(int $ttl=600){$t=(int)($_SESSION['admin_reauth_at']??0);return ($t===0||(time()-$t)>$ttl);};
|
||||
if ($mustReauth()){ $qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]); header('Location:?'.$qs); exit; }
|
||||
|
||||
|
||||
// -- CSRF
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
// -----------------------
|
||||
// Paramètres utilisateur
|
||||
// -----------------------
|
||||
$uploadsSubdir = 'messages/uploads'; // relatif à la racine du site
|
||||
$minAgeDays = isset($_REQUEST['min_age']) ? max(0, (int)$_REQUEST['min_age']) : 2;
|
||||
$fixDbRefs = isset($_REQUEST['fix_db']) ? ($_REQUEST['fix_db'] === '1') : true;
|
||||
|
||||
$doLive = false;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'purge') {
|
||||
// Vérif CSRF
|
||||
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['csrf_invalid'] ?? 'Token CSRF invalide.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
$doLive = true;
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Résolution des chemins
|
||||
// -----------------------
|
||||
$siteRoot = realpath(__DIR__ . '/../../'); // racine du site
|
||||
$uploadsDir = $siteRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $uploadsSubdir);
|
||||
|
||||
// -----------------------
|
||||
// Garde-fous fichiers
|
||||
// -----------------------
|
||||
$errors = [];
|
||||
if (!$siteRoot || !is_dir($siteRoot)) {
|
||||
$errors[] = htmlspecialchars((string)($translations['purge_mess_root_missing'] ?? 'Racine du site introuvable.'), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
if (!is_dir($uploadsDir)) {
|
||||
$tpl = (string)($translations['purge_mess_uploads_dir_missing'] ?? 'Dossier uploads introuvable: <code>{PATH}</code>');
|
||||
$errors[] = str_replace('{PATH}', htmlspecialchars((string)$uploadsDir, ENT_QUOTES, 'UTF-8'), $tpl);
|
||||
}
|
||||
if (!is_readable($uploadsDir)) {
|
||||
$errors[] = htmlspecialchars((string)($translations['purge_mess_uploads_not_readable'] ?? 'Dossier uploads non lisible.'), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
if ($doLive && !is_writable($uploadsDir)) {
|
||||
$errors[] = htmlspecialchars((string)($translations['purge_mess_uploads_not_writable_live'] ?? 'Dossier uploads non inscriptible alors que purge LIVE demandée.'), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Utilitaires
|
||||
// -----------------------
|
||||
$logs = [];
|
||||
function logln(string $msg){ global $logs; $logs[] = $msg; }
|
||||
function ageDays(int $mtime): float { return (time() - $mtime) / 86400.0; }
|
||||
function safeWithinUploads(string $path, string $uploadsRoot): bool {
|
||||
$rp = realpath($path);
|
||||
$ur = realpath($uploadsRoot);
|
||||
return $rp && $ur && strpos($rp, $ur) === 0;
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Exécution (si OK)
|
||||
// -----------------------
|
||||
$start = microtime(true);
|
||||
$stats = [
|
||||
'seen' => 0,
|
||||
'deleted' => 0,
|
||||
'skipped' => 0,
|
||||
'fixed' => 0,
|
||||
];
|
||||
$deletedList = [];
|
||||
$dry = !$doLive;
|
||||
|
||||
if (empty($errors)) {
|
||||
// 1) Chemins référencés en DB
|
||||
$dbPaths = [];
|
||||
if (isset($pdo) && $pdo instanceof PDO) {
|
||||
$stmt = $pdo->query("SELECT attachment_path FROM messages WHERE attachment_path IS NOT NULL");
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$p = trim((string)$row['attachment_path']);
|
||||
if ($p === '') continue;
|
||||
if (strpos($p, $uploadsSubdir . '/') === 0 || $p === $uploadsSubdir) {
|
||||
$dbPaths[$p] = true;
|
||||
}
|
||||
}
|
||||
$tpl = (string)($translations['purge_mess_db_refs_loaded'] ?? 'Références DB chargées: {N}');
|
||||
logln(str_replace('{N}', (string)count($dbPaths), $tpl));
|
||||
} else {
|
||||
logln($translations['purge_mess_pdo_unavailable_info'] ?? "[INFO] PDO indisponible: l'étape DB sera limitée.");
|
||||
}
|
||||
|
||||
// 2) Balayage disque
|
||||
$rii = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::CURRENT_AS_FILEINFO)
|
||||
);
|
||||
foreach ($rii as $fi) {
|
||||
/** @var SplFileInfo $fi */
|
||||
if (!$fi->isFile()) continue;
|
||||
$stats['seen']++;
|
||||
|
||||
$basename = $fi->getFilename();
|
||||
if ($basename === '.htaccess' || $basename[0] === '.') continue;
|
||||
|
||||
$absPath = $fi->getRealPath();
|
||||
if (!$absPath) continue;
|
||||
|
||||
$relFromUploads = ltrim(str_replace($uploadsDir, '', $absPath), DIRECTORY_SEPARATOR);
|
||||
$webRel = rtrim($uploadsSubdir, '/').'/'.str_replace(DIRECTORY_SEPARATOR, '/', $relFromUploads);
|
||||
|
||||
// référencé ?
|
||||
if (isset($dbPaths[$webRel])) continue;
|
||||
|
||||
// âge
|
||||
$age = ageDays($fi->getMTime());
|
||||
if ($age < $minAgeDays) {
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// garde-fou
|
||||
if (!safeWithinUploads($absPath, $uploadsDir)) {
|
||||
$tpl = (string)($translations['purge_mess_alert_outside_scope_skip'] ?? '[ALERTE] Hors périmètre uploads (skip): {PATH}');
|
||||
logln(str_replace('{PATH}', $absPath, $tpl));
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dry) {
|
||||
$deletedList[] = [$webRel, $age, 'simulation'];
|
||||
$tpl = (string)($translations['purge_mess_log_dry_orphan_sim'] ?? '[DRY] Orphelin: {FILE} ({DAYS} j) -> suppression simulée');
|
||||
logln(str_replace(
|
||||
['{FILE}','{DAYS}'],
|
||||
[$webRel, number_format($age,1)],
|
||||
$tpl
|
||||
));
|
||||
} else {
|
||||
if (@unlink($absPath)) {
|
||||
$stats['deleted']++;
|
||||
$deletedList[] = [$webRel, $age, 'supprimé'];
|
||||
$tpl = (string)($translations['purge_mess_log_ok_deleted'] ?? '[OK] Supprimé: {FILE} ({DAYS} j)');
|
||||
logln(str_replace(
|
||||
['{FILE}','{DAYS}'],
|
||||
[$webRel, number_format($age,1)],
|
||||
$tpl
|
||||
));
|
||||
} else {
|
||||
$tpl = (string)($translations['purge_mess_log_warn_delete_failed'] ?? '[WARN] Échec suppression: {FILE}');
|
||||
logln(str_replace('{FILE}', $webRel, $tpl));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Normaliser DB si fichier manquant
|
||||
if ($fixDbRefs && isset($pdo) && $pdo instanceof PDO) {
|
||||
$stmt = $pdo->query("SELECT id, attachment_path FROM messages WHERE attachment_path IS NOT NULL");
|
||||
$update = $pdo->prepare("UPDATE messages SET attachment_path = NULL, attachment_name = NULL WHERE id = ?");
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$id = (int)$row['id'];
|
||||
$p = (string)$row['attachment_path'];
|
||||
if ($p === '' || strpos($p, $uploadsSubdir . '/') !== 0) continue;
|
||||
|
||||
$abs = $siteRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $p);
|
||||
$absReal = realpath($abs);
|
||||
if (!$absReal || !is_file($absReal)) {
|
||||
if ($dry) {
|
||||
$tpl = (string)($translations['purge_mess_log_dry_db_fix'] ?? '[DRY] DB FIX id={ID} : {PATH} -> NULL (fichier manquant)');
|
||||
logln(str_replace(['{ID}','{PATH}'], [(string)$id, $p], $tpl));
|
||||
} else {
|
||||
$update->execute([$id]);
|
||||
$stats['fixed'] += $update->rowCount();
|
||||
$tpl = (string)($translations['purge_mess_log_ok_db_fix'] ?? '[OK] DB FIX id={ID} : {PATH} -> NULL');
|
||||
logln(str_replace(['{ID}','{PATH}'], [(string)$id, $p], $tpl));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$dur = microtime(true) - $start;
|
||||
|
||||
// -----------------------
|
||||
// Rendu Bootstrap
|
||||
// -----------------------
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
|
||||
<h3 class="m-0"><?= htmlspecialchars((string)($translations['purge_mess_title'] ?? 'Purger PJ messagerie'), ENT_QUOTES, 'UTF-8') ?></h3>
|
||||
<span class="badge text-bg-<?= $dry ? 'secondary' : 'danger' ?>">
|
||||
<?= $dry
|
||||
? htmlspecialchars((string)($translations['purge_mess_mode_dry'] ?? 'DRY-RUN'), ENT_QUOTES, 'UTF-8')
|
||||
: htmlspecialchars((string)($translations['purge_mess_mode_live'] ?? 'LIVE (suppression activée)'), ENT_QUOTES, 'UTF-8')
|
||||
?>
|
||||
</span>
|
||||
<span class="badge text-bg-info">
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_mess_age_threshold_badge'] ?? 'Seuil d’âge ≥ {N} j');
|
||||
echo htmlspecialchars(str_replace('{N}', (string)((int)$minAgeDays), $tpl), ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
</span>
|
||||
<span class="badge text-bg-<?= $fixDbRefs ? 'primary':'secondary' ?>">
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_mess_fix_db_badge'] ?? 'Fix refs DB : {VAL}');
|
||||
$val = $fixDbRefs ? ($translations['yes'] ?? 'OUI') : ($translations['no'] ?? 'NON');
|
||||
echo htmlspecialchars(str_replace('{VAL}', (string)$val, $tpl), ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($errors)): ?>
|
||||
<div class="alert alert-danger mb-3">
|
||||
<strong><?= htmlspecialchars((string)($translations['errors_title'] ?? 'Erreurs'), ENT_QUOTES, 'UTF-8') ?></strong><br>
|
||||
<ul class="mb-0">
|
||||
<?php foreach ($errors as $e): ?>
|
||||
<li><?= $e ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars($CSRF, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label"><?= htmlspecialchars((string)($translations['purge_mess_min_age_label'] ?? 'Âge minimum (jours)'), ENT_QUOTES, 'UTF-8') ?></label>
|
||||
<input type="number" class="form-control" name="min_age" min="0" value="<?= (int)$minAgeDays ?>">
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label"><?= htmlspecialchars((string)($translations['purge_mess_fix_db_label'] ?? 'Fixer les refs DB manquantes'), ENT_QUOTES, 'UTF-8') ?></label>
|
||||
<select class="form-select" name="fix_db">
|
||||
<option value="1" <?= $fixDbRefs ? 'selected':'' ?>><?= htmlspecialchars((string)($translations['yes'] ?? 'Oui'), ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<option value="0" <?= !$fixDbRefs ? 'selected':'' ?>><?= htmlspecialchars((string)($translations['no'] ?? 'Non'), ENT_QUOTES, 'UTF-8') ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button type="submit" name="action" value="scan" class="btn btn-outline-secondary">
|
||||
<?= htmlspecialchars((string)($translations['btn_scan'] ?? 'Scanner'), ENT_QUOTES, 'UTF-8') ?>
|
||||
</button>
|
||||
<button type="submit" name="action" value="purge" class="btn btn-danger"
|
||||
onclick="return confirm('<?= htmlspecialchars((string)($translations['confirm_purge_orphan_files'] ?? 'Confirmer la purge réelle des fichiers orphelins ?'), ENT_QUOTES, 'UTF-8') ?>');">
|
||||
<?= htmlspecialchars((string)($translations['btn_purge'] ?? 'Purger'), ENT_QUOTES, 'UTF-8') ?>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($errors)): ?>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><?= htmlspecialchars((string)($translations['purge_mess_summary'] ?? 'Résumé'), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<?= htmlspecialchars((string)($translations['purge_mess_uploads_dir'] ?? 'Dossier uploads'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<code class="ms-2"><?= htmlspecialchars($uploadsDir, ENT_QUOTES, 'UTF-8') ?></code>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= htmlspecialchars((string)($translations['purge_mess_files_scanned'] ?? 'Fichiers scannés'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="badge text-bg-secondary ms-2"><?= (int)$stats['seen'] ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_mess_orphans_line'] ?? 'Orphelins {STATE}');
|
||||
$state = $dry ? ($translations['purge_mess_orphans_detected'] ?? 'détectés') : ($translations['purge_mess_orphans_deleted'] ?? 'supprimés');
|
||||
echo htmlspecialchars(str_replace('{STATE}', (string)$state, $tpl), ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<span class="badge text-bg-<?= $dry ? 'warning' : 'danger' ?> ms-2"><?= (int)$stats['deleted'] ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= htmlspecialchars((string)($translations['purge_mess_orphans_too_recent'] ?? 'Orphelins trop récents (ignorés)'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="badge text-bg-info ms-2"><?= (int)$stats['skipped'] ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= htmlspecialchars((string)($translations['purge_mess_db_rows_fixed'] ?? 'Lignes DB normalisées'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="badge text-bg-primary ms-2"><?= (int)$stats['fixed'] ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= htmlspecialchars((string)($translations['purge_mess_duration'] ?? 'Durée'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="badge text-bg-secondary ms-2"><?= number_format($dur, 3) ?> s</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_mess_details_header'] ?? 'Détails {MODE}');
|
||||
$mode = $dry ? ($translations['purge_mess_details_sim'] ?? '(simulation)') : ($translations['purge_mess_details_live'] ?? '(LIVE)');
|
||||
echo htmlspecialchars(str_replace('{MODE}', (string)$mode, $tpl), ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<?php if (empty($deletedList)): ?>
|
||||
<div class="p-3 text-muted"><?= htmlspecialchars((string)($translations['purge_mess_no_orphans_found'] ?? 'Aucun fichier orphelin ancien trouvé.'), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= htmlspecialchars((string)($translations['th_file'] ?? 'Fichier'), ENT_QUOTES, 'UTF-8') ?></th>
|
||||
<th><?= htmlspecialchars((string)($translations['th_age_days'] ?? 'Âge (j)'), ENT_QUOTES, 'UTF-8') ?></th>
|
||||
<th><?= htmlspecialchars((string)($translations['th_action'] ?? 'Action'), ENT_QUOTES, 'UTF-8') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($deletedList as [$webRel, $age, $act]): ?>
|
||||
<tr>
|
||||
<td><code><?= htmlspecialchars($webRel, ENT_QUOTES, 'UTF-8') ?></code></td>
|
||||
<td><?= number_format($age, 1) ?></td>
|
||||
<td>
|
||||
<span class="badge text-bg-<?= $act === 'supprimé' ? 'danger' : 'secondary' ?>">
|
||||
<?= htmlspecialchars((string)($translations[$act === 'supprimé' ? 'status_deleted' : 'status_simulation'] ?? $act), ENT_QUOTES, 'UTF-8') ?>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><?= htmlspecialchars((string)($translations['purge_mess_log_title'] ?? 'Journal'), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div class="card-body">
|
||||
<pre class="small mb-0" style="white-space:pre-wrap;"><?= htmlspecialchars(implode("\n", $logs), ENT_QUOTES, 'UTF-8') ?></pre>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,425 @@
|
||||
<?php
|
||||
// /admin/gerer_praticiens.php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
// reauth (10 min)
|
||||
$mustReauth = function(int $ttl = 600) {
|
||||
$t = (int)($_SESSION['admin_reauth_at'] ?? 0);
|
||||
return ($t === 0 || (time() - $t) > $ttl);
|
||||
};
|
||||
if ($mustReauth()) {
|
||||
$qs = http_build_query(['page' => 'reauth', 'redir' => ($_SERVER['REQUEST_URI'] ?? 'index.php?page=maintenance')]);
|
||||
header('Location:?' . $qs);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Session sûre
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||
|
||||
|
||||
if ($pdo && method_exists($pdo, 'setAttribute')) {
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
}
|
||||
|
||||
// ===== Accès admin praticien strict =====
|
||||
const ADMIN_PRACTICIEN_ID = 88888888; // <-- adapte à ton ID
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo '<div class="container py-5"><div class="alert alert-danger">'
|
||||
. htmlspecialchars($translations['admin_access_denied'] ?? 'Доступ запрещён.', ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// =====MAILER PHP =====
|
||||
require_once __DIR__ . '/../../includes/mailer.php';
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
// CSRF robuste
|
||||
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token']) || strlen($_SESSION['csrf_token']) < 32) {
|
||||
try { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); }
|
||||
catch (Throwable $e) { $_SESSION['csrf_token'] = bin2hex(openssl_random_pseudo_bytes(32)); }
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
// ==================== Paramètres ====================
|
||||
$DATE_FMT_TABLE = $translations['date_format_table'] ?? 'd.m.Y';
|
||||
$DATE_FMT_MAIL = $translations['date_format_mail'] ?? 'd.m.Y';
|
||||
$RENEW_URL = 'https://uslugi.fr/public/praticien/resiliation.php'; // <- adapte si besoin
|
||||
|
||||
// ==================== Helpers ====================
|
||||
function e($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
||||
|
||||
function latestPaymentFor(PDO $pdo, int $praticienId): ?array {
|
||||
$sql = "
|
||||
SELECT p.id, p.statut, p.methode, p.date_paiement
|
||||
FROM paiement p
|
||||
WHERE p.praticien_id = :pid
|
||||
ORDER BY p.date_paiement DESC, p.id DESC
|
||||
LIMIT 1
|
||||
";
|
||||
$st = $pdo->prepare($sql);
|
||||
$st->execute([':pid'=>$praticienId]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
function computeAboState(?string $dateExpiration, ?string $lastPayStatus, int $warningDays = 10): string {
|
||||
if (!$dateExpiration) return 'expired';
|
||||
|
||||
try {
|
||||
$today = new DateTimeImmutable('today');
|
||||
$exp = new DateTimeImmutable($dateExpiration);
|
||||
} catch (Throwable $e) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
if ($exp < $today) return 'expired';
|
||||
|
||||
$diff = $today->diff($exp);
|
||||
$daysLeft = (int)$diff->days;
|
||||
|
||||
if ($daysLeft <= $warningDays) return 'expiring';
|
||||
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function buildMailHtml(array $t, string $headlineKey, string $leadKey, ?string $dateFinFmt, string $renewUrl, string $btnLabel): string {
|
||||
$headline = htmlspecialchars($t[$headlineKey] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$leadRaw = $t[$leadKey] ?? '';
|
||||
if ($dateFinFmt) $leadRaw = str_replace('{DATE}', $dateFinFmt, $leadRaw);
|
||||
$lead = nl2br(htmlspecialchars($leadRaw, ENT_QUOTES, 'UTF-8'));
|
||||
|
||||
$ctaLabel = htmlspecialchars($btnLabel, ENT_QUOTES, 'UTF-8');
|
||||
$help = nl2br(htmlspecialchars($t['mail_footer_help'] ?? '', ENT_QUOTES, 'UTF-8'));
|
||||
|
||||
$labelAbo = htmlspecialchars($t['mail_label_abo'] ?? 'Абонемент', ENT_QUOTES, 'UTF-8');
|
||||
$labelDate = htmlspecialchars($t['mail_label_date_fin'] ?? 'Дата окончания', ENT_QUOTES, 'UTF-8');
|
||||
$labelState = htmlspecialchars($t['mail_label_statut'] ?? 'Статус', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$valueAbo = htmlspecialchars($t['mail_value_abo'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$valueDate = htmlspecialchars($dateFinFmt ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$valueState = htmlspecialchars($t['mail_value_statut'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$brand = '<img src="https://uslugi.fr/public/uploads/img/logo_mail.png" width="150" alt="USLUGI.FR">';
|
||||
$brandBas = 'УСЛУГИ.ФР';
|
||||
$merciLine = htmlspecialchars($t['merci_simple'] ?? 'Спасибо', ENT_QUOTES, 'UTF-8');
|
||||
$renewUrl = htmlspecialchars($renewUrl, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$rowsHtml = '';
|
||||
if ($valueAbo !== '') {
|
||||
$rowsHtml .= '<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;border-bottom:1px solid #e5e7eb;">'.$labelAbo.'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.$valueAbo.'</td></tr>';
|
||||
}
|
||||
if ($valueDate !== '') {
|
||||
$rowsHtml .= '<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;'.($valueState !== '' ? 'border-bottom:1px solid #e5e7eb;' : '').'">'.$labelDate.'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.$valueDate.'</td></tr>';
|
||||
}
|
||||
if ($valueState !== '') {
|
||||
$rowsHtml .= '<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.$labelState.'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.$valueState.'</td></tr>';
|
||||
}
|
||||
|
||||
$cardHtml = ($rowsHtml !== '')
|
||||
? '<tr><td style="padding:16px 24px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;">'.$rowsHtml.'</table></td></tr>'
|
||||
: '';
|
||||
|
||||
return '<!DOCTYPE html><html lang="ru"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>'.$headline.'</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,Helvetica,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background:#38378B;color:#fff;padding:20px 24px;font-size:18px;font-weight:600;">'.$brand.'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 24px 8px 24px;color:#111;font-size:18px;font-weight:600;line-height:1.4;">'.$headline.'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 24px;color:#111;font-size:16px;line-height:1.6;">'.$lead.'</td>
|
||||
</tr>
|
||||
'.$cardHtml.'
|
||||
<tr>
|
||||
<td align="center" style="padding:8px 24px 24px 24px;">
|
||||
<a href="'.$renewUrl.'" target="_blank" style="display:inline-block;text-decoration:none;padding:12px 20px;border-radius:999px;background:#E03C3F;color:#fff;font-weight:600;">'.$ctaLabel.'</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 24px 24px 24px;color:#111;font-size:14px;line-height:1.6;">'.$help.'</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="margin-top:12px;">
|
||||
<tr>
|
||||
<td style="text-align:center;color:#9ca3af;font-size:12px;padding:8px 0;">© '.date('Y').' '.$brandBas.' — '.$merciLine.'</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>';
|
||||
}
|
||||
|
||||
function sendReminderEmailPhpMailer(string $toEmail, string $toName, string $subject, string $html, string $text = ''): bool {
|
||||
global $MAILCFG;
|
||||
|
||||
if (!filter_var($toEmail, FILTER_VALIDATE_EMAIL)) return false;
|
||||
if (empty($MAILCFG['smtp']['host'])) return false;
|
||||
|
||||
$mail = new \PHPMailer\PHPMailer\PHPMailer(true);
|
||||
|
||||
try {
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $MAILCFG['smtp']['host'];
|
||||
$mail->Port = (int)($MAILCFG['smtp']['port'] ?? 587);
|
||||
$mail->SMTPAuth = (bool)($MAILCFG['smtp']['auth'] ?? true);
|
||||
|
||||
$sec = $MAILCFG['smtp']['secure'] ?? 'tls';
|
||||
if ($sec === 'ssl') $mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS;
|
||||
else $mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
|
||||
|
||||
$mail->Username = $MAILCFG['smtp']['username'] ?? '';
|
||||
$mail->Password = $MAILCFG['smtp']['password'] ?? '';
|
||||
|
||||
$mail->Timeout = 10;
|
||||
$mail->SMTPKeepAlive = false;
|
||||
$mail->SMTPAutoTLS = true;
|
||||
$mail->SMTPDebug = 0;
|
||||
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Encoding = 'base64';
|
||||
$mail->XMailer = '';
|
||||
|
||||
if (!empty($MAILCFG['bounce'])) $mail->Sender = $MAILCFG['bounce'];
|
||||
|
||||
$from = $MAILCFG['from'] ?? ['contact@uslugi.fr', 'USLUGI.FR'];
|
||||
$rpto = $MAILCFG['reply_to'] ?? ['contact@uslugi.fr', 'USLUGI.FR'];
|
||||
|
||||
$mail->setFrom($from[0], $from[1] ?? '');
|
||||
if (!empty($rpto[0])) $mail->addReplyTo($rpto[0], $rpto[1] ?? '');
|
||||
|
||||
$mail->addAddress($toEmail, $toName ?: '');
|
||||
|
||||
if (!empty($MAILCFG['suppress_auto'])) {
|
||||
$mail->addCustomHeader('Auto-Submitted', 'auto-generated');
|
||||
$mail->addCustomHeader('X-Auto-Response-Suppress', 'All');
|
||||
}
|
||||
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $html;
|
||||
|
||||
if (trim($text) === '') {
|
||||
$text = trim(strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html)));
|
||||
}
|
||||
$mail->AltBody = $text;
|
||||
|
||||
return $mail->send();
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
error_log('PHPMailer reminder send exception: ' . $e->getMessage());
|
||||
if (!empty($mail->ErrorInfo)) error_log('PHPMailer reminder ErrorInfo: ' . $mail->ErrorInfo);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Traitement POST: envoi mail ====================
|
||||
$flash = null;
|
||||
if (($_POST['do'] ?? '') === 'send_mail') {
|
||||
try {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) { throw new Exception('CSRF'); }
|
||||
$pid = (int)($_POST['id'] ?? 0);
|
||||
if ($pid <= 0) throw new Exception('ID');
|
||||
|
||||
$st = $pdo->prepare("
|
||||
SELECT pr.id, pr.nom, pr.prenom, pr.email, pr.date_expiration, ab.nom AS abo_nom
|
||||
FROM praticien pr
|
||||
LEFT JOIN abonnement ab ON ab.id = pr.abonnement_id
|
||||
WHERE pr.id = ?
|
||||
LIMIT 1
|
||||
");
|
||||
$st->execute([$pid]);
|
||||
$pr = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$pr) throw new Exception('Not found');
|
||||
|
||||
$last = latestPaymentFor($pdo, $pid);
|
||||
$state = computeAboState($pr['date_expiration'] ?? null, $last['statut'] ?? null);
|
||||
|
||||
if ($state === 'active') {
|
||||
$flash = ['type'=>'warning','msg'=> e($translations['flash_pas_necessaire'] ?? 'Pas nécessaire.')];
|
||||
} else {
|
||||
if ($state === 'expired') {
|
||||
$subjectKey='mail_subject_expired'; $headlineKey='mail_headline_expired'; $leadKey='mail_lead_expired';
|
||||
} else {
|
||||
$subjectKey='mail_subject_expiring'; $headlineKey='mail_headline_expiring'; $leadKey='mail_lead_expiring';
|
||||
}
|
||||
|
||||
$subject = $translations[$subjectKey] ?? ($translations['mail_subject_default'] ?? 'Abonnement');
|
||||
$btn = $translations['mail_btn_renew'] ?? 'Renouveler';
|
||||
|
||||
$dateFinFmt = '';
|
||||
if (!empty($pr['date_expiration'])) {
|
||||
try { $dateFinFmt = (new DateTimeImmutable($pr['date_expiration']))->format($DATE_FMT_MAIL); } catch(Throwable $e){}
|
||||
}
|
||||
|
||||
$html = buildMailHtml($translations, $headlineKey, $leadKey, $dateFinFmt ?: null, $RENEW_URL, $btn);
|
||||
|
||||
$destName = trim(($pr['prenom'] ?? '').' '.($pr['nom'] ?? ''));
|
||||
if ($destName === '') $destName = ($translations['mail_default_name'] ?? 'Collègue');
|
||||
|
||||
$toEmail = (string)$pr['email'];
|
||||
$toName = $destName;
|
||||
|
||||
if (sendReminderEmailPhpMailer($toEmail, $toName, $subject, $html)) {
|
||||
$msg = ($translations['flash_envoi_ok'] ?? 'Envoyé: {EMAIL}');
|
||||
$msg = str_replace('{EMAIL}', e($pr['email']), $msg);
|
||||
$flash = ['type'=>'success','msg'=>$msg];
|
||||
} else {
|
||||
$flash = ['type'=>'danger','msg'=> e($translations['flash_envoi_echec'] ?? 'Échec envoi.')];
|
||||
}
|
||||
}
|
||||
} catch(Throwable $e) {
|
||||
$flash = ['type'=>'danger','msg'=> e($translations['flash_erreur'] ?? 'Erreur')];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Récupération liste praticiens ====================
|
||||
$sql = "
|
||||
SELECT
|
||||
pr.id, pr.titre, pr.nom, pr.prenom, pr.email, pr.photo,
|
||||
pr.date_expiration, ab.nom AS abo_nom,
|
||||
(
|
||||
SELECT p.statut FROM paiement p
|
||||
WHERE p.praticien_id = pr.id
|
||||
ORDER BY p.date_paiement DESC, p.id DESC
|
||||
LIMIT 1
|
||||
) AS pay_statut
|
||||
FROM praticien pr
|
||||
LEFT JOIN abonnement ab ON ab.id = pr.abonnement_id
|
||||
ORDER BY pr.nom ASC, pr.prenom ASC
|
||||
";
|
||||
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ==================== Vue ====================
|
||||
?>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?= e($translations['titre_page_gerer_praticiens'] ?? 'Gestion praticiens') ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
.searchbar { display:flex; gap:.5rem; align-items:center; margin-bottom:1rem; }
|
||||
.badge { display:inline-block; padding:.25rem .5rem; border-radius:9999px; font-size:.85rem; border:1px solid transparent; }
|
||||
.badge-active { background:#198754; border-color:#27ae60; color: #2e07a3ff;}
|
||||
.badge-expiring{ background:#fff8e6; border-color:#f39c12; color: #2e07a3ff; }
|
||||
.badge-expired { background:#fdecea; border-color:#c0392b; color: #2e07a3ff; }
|
||||
.minw-120{ min-width:120px; }
|
||||
.photo { width:40px; height:40px; object-fit:cover; border-radius:8px; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-3 text-center"><?= e($translations['h2_gerer_abos'] ?? '👩⚕️ Rappels d’abonnement') ?></h2>
|
||||
|
||||
<?php if ($flash): ?>
|
||||
<div class="alert alert-<?= e($flash['type']) ?>"><?= $flash['msg'] ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mb-3">
|
||||
<input type="text" class="form-control" id="filter"
|
||||
placeholder="<?= e($translations['ph_recherche'] ?? 'Recherche (nom, prénom, e-mail, abonnement)') ?>">
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover align-middle mobile-cards" id="tab">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:56px;"></th>
|
||||
<th><?= e($translations['th_nom'] ?? 'Nom') ?></th>
|
||||
<th><?= e($translations['th_prenom'] ?? 'Prénom') ?></th>
|
||||
<th><?= e($translations['th_email'] ?? 'E-mail') ?></th>
|
||||
<th class="minw-120"><?= e($translations['th_abonnement'] ?? 'Tarif') ?></th>
|
||||
<th class="minw-120"><?= e($translations['th_statut_paiement'] ?? 'Statut paiement') ?></th>
|
||||
<th class="minw-120"><?= e($translations['th_date_fin'] ?? 'Fin') ?></th>
|
||||
<th class="minw-120"><?= e($translations['th_statut'] ?? 'Статус') ?></th>
|
||||
<th class="text-center" style="width:160px;"><?= e($translations['th_action'] ?? 'Action') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r):
|
||||
$name = trim(($r['titre'] ? $r['titre'].' ' : '').$r['prenom'].' '.$r['nom']);
|
||||
$photo = !empty($r['photo']) ? '/public/uploads/'.e($r['photo']) : '/public/uploads/img/user_placeholder.png';
|
||||
$state = computeAboState($r['date_expiration'] ?? null, $r['pay_statut'] ?? null);
|
||||
|
||||
$labelAboState = [
|
||||
'active' => $translations['statut_active'] ?? 'Active',
|
||||
'expiring' => $translations['statut_expiring'] ?? 'Bientôt',
|
||||
'expired' => $translations['statut_expired'] ?? 'Expirée',
|
||||
][$state] ?? ($translations['statut_unknown'] ?? '—');
|
||||
|
||||
$badgePay = (function($st,$t){
|
||||
if ($st === 'payé') return '<span class="badge badge-active">'.e($t['pay_ok'] ?? 'Payé').'</span>';
|
||||
if ($st === 'en_attente') return '<span class="badge badge-expiring">'.e($t['pay_wait']?? 'En attente').'</span>';
|
||||
if ($st === 'échoué') return '<span class="badge badge-expired">'.e($t['pay_ko'] ?? 'Échoué').'</span>';
|
||||
return '<span class="badge">'.e((string)$st).'</span>';
|
||||
})($r['pay_statut'] ?? '', $translations);
|
||||
|
||||
$dateFinFmt = '';
|
||||
if (!empty($r['date_expiration'])) {
|
||||
try { $dateFinFmt = (new DateTimeImmutable($r['date_expiration']))->format($DATE_FMT_TABLE); } catch(Throwable $e){}
|
||||
}
|
||||
|
||||
$hay = mb_strtolower($name.' '.($r['email']??'').' '.($r['abo_nom']??''), 'UTF-8');
|
||||
?>
|
||||
<tr data-hay="<?= e($hay) ?>">
|
||||
<td><img src="<?= $photo ?>" alt="" class="photo"></td>
|
||||
<td><?= e($r['nom']) ?></td>
|
||||
<td><?= e($r['prenom']) ?></td>
|
||||
<td><?= e($r['email']) ?></td>
|
||||
<td><?php $k=trim((string)($r['abo_nom']??'')); echo e($translations[$k] ?? $k); ?></td>
|
||||
|
||||
<td><?= $badgePay ?></td>
|
||||
<td><?= e($dateFinFmt) ?></td>
|
||||
<td>
|
||||
<?php if ($state === 'active'): ?>
|
||||
<span class="badge badge-active"><?= e($labelAboState) ?></span>
|
||||
<?php elseif ($state === 'expiring'): ?>
|
||||
<span class="badge badge-expiring"><?= e($labelAboState) ?></span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-expired"><?= e($labelAboState) ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
|
||||
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
|
||||
<button name="do" value="send_mail" class="btn btn-sm btn-outline-primary"
|
||||
onclick="return confirm('<?= e($translations['confirm_envoyer_rappel'] ?? 'Envoyer un e-mail de rappel ?') ?>')">
|
||||
<?= e($translations['btn_envoyer_rappel'] ?? 'Envoyer rappel') ?>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (empty($rows)): ?>
|
||||
<tr><td colspan="9" class="text-muted"><?= e($translations['aucun_praticien'] ?? 'Aucun praticien.') ?></td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Filtre client simple
|
||||
document.getElementById('filter').addEventListener('input', function(){
|
||||
const q = (this.value || '').trim().toLowerCase();
|
||||
document.querySelectorAll('#tab tbody tr').forEach(tr=>{
|
||||
const hay = (tr.dataset.hay || '').toLowerCase();
|
||||
tr.style.display = !q || hay.includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
/**
|
||||
* /admin/tools/logs.php
|
||||
* Viewer de logs "admin-friendly" :
|
||||
* - Affiche /logs/php_errors.log (et /logs/app.log si présent)
|
||||
* - Tail (dernieres N lignes), Download, Clear (truncate)
|
||||
* - CSRF + garde-fous; pas de fonctions globales (closures only)
|
||||
* - S'affiche uniquement sur ?page=logs
|
||||
*/
|
||||
|
||||
if (($_GET['page'] ?? '') !== 'logs') { return; }
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
|
||||
// Lang (pour $translations)
|
||||
require_once __DIR__ . '/../../public/includes/lang.php';
|
||||
|
||||
$roles = (array)($_SESSION['role'] ?? []);
|
||||
if (!in_array('admin', $roles, true)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['purge_admin_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
// reauth (10 min)
|
||||
$mustReauth=function(int $ttl=600){$t=(int)($_SESSION['admin_reauth_at']??0);return ($t===0||(time()-$t)>$ttl);};
|
||||
if ($mustReauth()){ $qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]); header('Location:?'.$qs); exit; }
|
||||
if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); }
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
$e = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
$siteRoot = realpath(__DIR__ . '/../../');
|
||||
$logsDir = $siteRoot . '/logs';
|
||||
@is_dir($logsDir) || @mkdir($logsDir, 0755, true);
|
||||
|
||||
// Fichiers autorisés
|
||||
$files = [
|
||||
'php_errors' => $logsDir . '/php_errors.log',
|
||||
'app' => $logsDir . '/app.log',
|
||||
];
|
||||
// Sélection
|
||||
$key = $_GET['file'] ?? 'php_errors';
|
||||
if (!isset($files[$key])) { $key = 'php_errors'; }
|
||||
$path = $files[$key];
|
||||
|
||||
// Actions pré-dispatch
|
||||
$op = $_GET['op'] ?? '';
|
||||
if ($op === 'download' && hash_equals($CSRF, $_GET['csrf'] ?? '')) {
|
||||
if (is_file($path) && is_readable($path)) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="'.$key.'.log"');
|
||||
readfile($path);
|
||||
} else {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo (string)($translations['purge_logs_file_unavailable_plain'] ?? 'Fichier indisponible.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'truncate') {
|
||||
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. $e($translations['purge_csrf_invalid'] ?? 'Token CSRF invalide.')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
if (is_file($path) && is_writable($path)) {
|
||||
$fp = @fopen($path, 'w'); if ($fp) { fclose($fp); }
|
||||
} else if (!file_exists($path)) {
|
||||
// crée le fichier si absent
|
||||
@touch($path);
|
||||
}
|
||||
// redirection douce (évite le repost)
|
||||
$qs = http_build_query(['page'=>'logs','file'=>$key]);
|
||||
header('Location: ?'.$qs);
|
||||
return;
|
||||
}
|
||||
|
||||
// Helpers
|
||||
$humanSize = static function(int $bytes): string {
|
||||
$units = ['B','KB','MB','GB','TB']; $i=0;
|
||||
while ($bytes >= 1024 && $i < count($units)-1) { $bytes/=1024; $i++; }
|
||||
return number_format($bytes, ($i?1:0)).' '.$units[$i];
|
||||
};
|
||||
$tail = static function(string $file, int $lines=300, int $maxBytes=1048576): string {
|
||||
if (!is_file($file) || !is_readable($file)) return '';
|
||||
$size = filesize($file);
|
||||
if ($size === 0) return '';
|
||||
$bytesToRead = min($size, $maxBytes);
|
||||
$fh = fopen($file, 'rb');
|
||||
if (!$fh) return '';
|
||||
// positionne le pointeur en fin-moins-bytesToRead
|
||||
fseek($fh, -$bytesToRead, SEEK_END);
|
||||
$data = fread($fh, $bytesToRead);
|
||||
fclose($fh);
|
||||
// Normalise fin de fichier + découpe
|
||||
$data = ltrim($data); // évite premier morceau tronqué
|
||||
$linesArr = preg_split('/\R/', $data);
|
||||
$linesArr = array_slice($linesArr, -$lines);
|
||||
return implode("\n", $linesArr);
|
||||
};
|
||||
|
||||
$exists = is_file($path);
|
||||
$readable = $exists && is_readable($path);
|
||||
$writable = $exists ? is_writable($path) : is_writable(dirname($path));
|
||||
$size = $exists ? filesize($path) : 0;
|
||||
$mtime = $exists ? date('Y-m-d H:i:s', filemtime($path)) : '—';
|
||||
|
||||
$content = $readable ? $tail($path, (int)($_GET['n'] ?? 300)) : '';
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
|
||||
<h3 class="m-0"><?= $e($translations['purge_logs_title'] ?? 'Journaux & erreurs') ?></h3>
|
||||
<span class="badge text-bg-<?= $readable ? 'success':'secondary' ?>">
|
||||
<?= $readable ? $e($translations['purge_logs_readable'] ?? 'Lisible') : $e($translations['purge_logs_unavailable'] ?? 'Indisponible') ?>
|
||||
</span>
|
||||
<span class="badge text-bg-info">
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_logs_file_badge'] ?? 'Fichier: {KEY}');
|
||||
echo $e(str_replace('{KEY}', (string)$key, $tpl));
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<input type="hidden" name="page" value="logs">
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label"><?= $e($translations['purge_logs_select_label'] ?? 'Sélection du log') ?></label>
|
||||
<select class="form-select" name="file" onchange="this.form.submit()">
|
||||
<?php foreach ($files as $k=>$p): ?>
|
||||
<option value="<?= $e($k) ?>" <?= $k===$key?'selected':'' ?>>
|
||||
<?= $e($k) ?> (
|
||||
<?= is_file($p)
|
||||
? $e($humanSize(filesize($p)))
|
||||
: $e($translations['purge_logs_absent'] ?? 'absent')
|
||||
?>
|
||||
)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label"><?= $e($translations['purge_logs_last_lines_label'] ?? 'Dernières lignes') ?></label>
|
||||
<input type="number" min="50" max="5000" step="50" name="n" class="form-control" value="<?= (int)($_GET['n'] ?? 300) ?>">
|
||||
</div>
|
||||
<div class="col-12 col-md-6 d-flex align-items-end gap-2">
|
||||
<a class="btn btn-outline-primary"
|
||||
href="/admin/tools/logs_download.php?file=<?= $e($key) ?>&csrf=<?= $e($CSRF) ?>">
|
||||
<?= $e($translations['purge_logs_btn_download_short'] ?? 'Téléchar.') ?>
|
||||
</a>
|
||||
<button type="submit" class="btn btn-outline-secondary">
|
||||
<?= $e($translations['purge_logs_btn_refresh'] ?? 'Actualiser') ?>
|
||||
</button>
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="csrf" value="<?= $e($CSRF) ?>">
|
||||
<button formaction="?page=logs&file=<?= $e($key) ?>" name="action" value="truncate"
|
||||
class="btn btn-danger"
|
||||
onclick="return confirm('<?= $e($translations['purge_logs_confirm_truncate'] ?? 'Vider le contenu de ce log ?') ?>');"
|
||||
<?= $writable ? '' : 'disabled' ?>>
|
||||
<?= $e($translations['purge_logs_btn_truncate'] ?? 'Vider') ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer small text-muted">
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_logs_footer_path'] ?? 'Emplacement: {PATH}');
|
||||
echo $e(str_replace('{PATH}', (string)$path, $tpl));
|
||||
?>
|
||||
—
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_logs_footer_size'] ?? 'Taille: {SIZE}');
|
||||
echo $e(str_replace('{SIZE}', (string)$humanSize($size), $tpl));
|
||||
?>
|
||||
—
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_logs_footer_mtime'] ?? 'Dernière modif: {TIME}');
|
||||
echo $e(str_replace('{TIME}', (string)$mtime, $tpl));
|
||||
?>
|
||||
<?php if (!$exists): ?>
|
||||
— <em><?= $e($translations['purge_logs_footer_missing_note'] ?? 'Le fichier n’existe pas (il sera créé au premier log).') ?></em>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><?= $e($translations['purge_logs_preview_header'] ?? 'Aperçu (tail)') ?></div>
|
||||
<div class="card-body">
|
||||
<?php if (!$readable): ?>
|
||||
<div class="text-muted">
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_logs_no_content_rights_hint'] ?? 'Aucun contenu disponible. Vérifie les droits sur <code>{DIR}</code> (0755) et le fichier (0644).');
|
||||
echo str_replace('{DIR}', $e($logsDir), $tpl);
|
||||
?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<pre class="small mb-0" style="white-space:pre-wrap;"><?= $e($content ?: ($translations['purge_logs_empty'] ?? '(vide)')) ?></pre>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
// /admin/tools/logs_download.php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
|
||||
// 1) Sécurité: admin requis
|
||||
if (empty($_SESSION['role']) || !in_array('admin', (array)$_SESSION['role'], true)) {
|
||||
http_response_code(403);
|
||||
echo 'Forbidden';
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2) CSRF GET
|
||||
if (empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_GET['csrf'] ?? '')) {
|
||||
http_response_code(400);
|
||||
echo 'CSRF invalid';
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3) Map fichiers autorisés
|
||||
$siteRoot = realpath(__DIR__ . '/../../');
|
||||
$logsDir = $siteRoot . '/logs';
|
||||
$map = [
|
||||
'php_errors' => $logsDir . '/php_errors.log',
|
||||
'app' => $logsDir . '/app.log',
|
||||
];
|
||||
$key = $_GET['file'] ?? 'php_errors';
|
||||
$path = $map[$key] ?? $map['php_errors'];
|
||||
|
||||
// 4) S'assurer que le dossier/fichier existe
|
||||
if (!is_dir($logsDir)) { @mkdir($logsDir, 0755, true); }
|
||||
if (!file_exists($path)) { @touch($path); } // crée un .log vide si absent
|
||||
|
||||
// 5) Lecture sûre
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
http_response_code(404);
|
||||
echo 'Log file not found or unreadable';
|
||||
exit;
|
||||
}
|
||||
|
||||
// 6) Purge des buffers et envoi
|
||||
while (ob_get_level()) { ob_end_clean(); }
|
||||
|
||||
$size = filesize($path) ?: 0;
|
||||
$filename = $key.'_'.date('Ymd_His').'.log';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="'.$filename.'"');
|
||||
header('Content-Length: '.$size);
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$fp = fopen($path, 'rb');
|
||||
if ($fp) { fpassthru($fp); fclose($fp); } else { readfile($path); }
|
||||
exit;
|
||||
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
const ADMIN_PRACTICIEN_ID = 88888888;
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo '<div class="container py-5"><div class="alert alert-danger">'
|
||||
. htmlspecialchars($translations['non_autorise'] ?? 'Доступ запрещён.', ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
exit;
|
||||
}
|
||||
$mustReauth=function(int $ttl=600){$t=(int)($_SESSION['admin_reauth_at']??0);return ($t===0||(time()-$t)>$ttl);};
|
||||
if ($mustReauth()){ $qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]); header('Location:?'.$qs); exit; }
|
||||
|
||||
|
||||
|
||||
if (!isset($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
function e($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
||||
|
||||
// --------- Template HTML du mail ---------
|
||||
function buildAdminMailHtml(string $subject, string $bodyHtml): string {
|
||||
global $translations;
|
||||
|
||||
$brand = '<img src="https://uslugi.fr/public/uploads/img/logo_mail.png" width="150" alt="USLUGI.FR">';
|
||||
$brandBas = $translations['mailing_praticiens_brand_bas'] ?? 'УСЛУГИ.ФР';
|
||||
$ctaUrl = 'https://uslugi.fr/';
|
||||
$ctaLabel = $translations['mailing_praticiens_cta_open_site'] ?? 'Открыть сайт';
|
||||
$thanks = $translations['mailing_praticiens_footer_thanks'] ?? 'Спасибо';
|
||||
|
||||
return '<!DOCTYPE html><html lang="ru"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>'.e($subject).'</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,Helvetica,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background:#38378B;color:#fff;padding:20px 24px;font-size:18px;font-weight:600;">'.$brand.'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 24px 8px 24px;color:#111;font-size:16px;line-height:1.6;">'.$bodyHtml.'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding:8px 24px 24px 24px;">
|
||||
<a href="'.e($ctaUrl).'" target="_blank"
|
||||
style="display:inline-block;text-decoration:none;padding:12px 20px;border-radius:999px;background:#E03C3F;color:#fff;font-weight:600;">
|
||||
'.e($ctaLabel).'
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="margin-top:12px;">
|
||||
<tr>
|
||||
<td style="text-align:center;color:#9ca3af;font-size:12px;padding:8px 0;">© '.date('Y').' '.$brandBas.' — '.e($thanks).'</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>';
|
||||
}
|
||||
|
||||
function sendPraticienMail(string $to, string $subject, string $html): bool {
|
||||
// Optionnel: définir From/Reply-To ici, sinon mail_config.php fait foi
|
||||
[$ok, $err] = uslugi_mail($to, $subject, $html, null, [
|
||||
// 'from' => ['no-reply@uslugi.fr', 'USLUGI.FR'],
|
||||
// 'reply_to' => ['support@uslugi.fr', 'USLUGI.FR'],
|
||||
// 'headers' => ['X-USLUGI-Source' => 'admin_mailing_praticiens'],
|
||||
]);
|
||||
|
||||
if (!$ok && $err) {
|
||||
error_log('mailing_praticiens send error to '.$to.' : '.$err);
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
// --------- Traitement POST ---------
|
||||
$flash = null;
|
||||
|
||||
if (($_POST['do'] ?? '') === 'send_mailing') {
|
||||
try {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
|
||||
throw new Exception('CSRF');
|
||||
}
|
||||
|
||||
$subject = trim($_POST['subject'] ?? '');
|
||||
$body = trim($_POST['body'] ?? '');
|
||||
$ids = $_POST['recipients'] ?? [];
|
||||
|
||||
if ($subject === '' || $body === '') {
|
||||
throw new Exception('EMPTY');
|
||||
}
|
||||
|
||||
if (!is_array($ids) || count($ids) === 0) {
|
||||
throw new Exception('NO_RECIPIENTS');
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v)=>$v>0)));
|
||||
if (!$ids) {
|
||||
throw new Exception('NO_RECIPIENTS');
|
||||
}
|
||||
|
||||
// Récupérer les emails
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
$sql = "SELECT id, prenom, nom, email FROM praticien WHERE id IN ($in) AND email <> ''";
|
||||
$st = $pdo->prepare($sql);
|
||||
$st->execute($ids);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$rows) {
|
||||
throw new Exception('NO_EMAILS');
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$total = count($rows);
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$email = trim($r['email'] ?? '');
|
||||
if ($email === '') continue;
|
||||
|
||||
// On laisse TinyMCE produire le HTML, on l’utilise tel quel
|
||||
$html = buildAdminMailHtml($subject, $body);
|
||||
if (sendPraticienMail($email, $subject, $html)) {
|
||||
$sent++;
|
||||
}
|
||||
// usleep(100000); // 0.1s
|
||||
}
|
||||
|
||||
$flash = [
|
||||
'type' => 'success',
|
||||
'msg' => ($translations['mailing_praticiens_sent_count'] ?? 'Отправлено писем: %d / %d')
|
||||
? sprintf((string)($translations['mailing_praticiens_sent_count'] ?? 'Отправлено писем: %d / %d'), $sent, $total)
|
||||
: ('Отправлено писем: '.$sent.' / '.$total)
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
$code = $e->getMessage();
|
||||
$msg = $translations['mailing_praticiens_err_send'] ?? 'Ошибка при отправке.';
|
||||
|
||||
if ($code === 'EMPTY') $msg = $translations['mailing_praticiens_err_empty'] ?? 'Тема и текст письма обязательны.';
|
||||
if ($code === 'NO_RECIPIENTS') $msg = $translations['mailing_praticiens_err_no_recipients'] ?? 'Выберите хотя бы одного практикующего.';
|
||||
if ($code === 'CSRF') $msg = $translations['mailing_praticiens_err_csrf'] ?? 'Ошибка безопасности. Обновите страницу.';
|
||||
|
||||
$flash = ['type'=>'danger','msg'=>$msg];
|
||||
}
|
||||
}
|
||||
|
||||
// --------- Récupération des praticiens ---------
|
||||
$sql = "
|
||||
SELECT
|
||||
pr.id,
|
||||
pr.nom,
|
||||
pr.prenom,
|
||||
pr.email,
|
||||
pr.abonnement_id,
|
||||
pr.date_expiration,
|
||||
ab.nom AS abo_nom
|
||||
FROM praticien pr
|
||||
LEFT JOIN abonnement ab ON ab.id = pr.abonnement_id
|
||||
ORDER BY pr.nom ASC, pr.prenom ASC
|
||||
";
|
||||
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Préparer options de filtre par abonnement
|
||||
$aboOptions = [];
|
||||
foreach ($rows as $r) {
|
||||
if (!empty($r['abonnement_id'])) {
|
||||
$aid = (int)$r['abonnement_id'];
|
||||
$aboOptions[$aid] = $r['abo_nom'] ?: (($translations['mailing_praticiens_tarif_id'] ?? 'ID %d') ? sprintf((string)($translations['mailing_praticiens_tarif_id'] ?? 'ID %d'), $aid) : ('ID '.$aid));
|
||||
}
|
||||
}
|
||||
ksort($aboOptions);
|
||||
|
||||
$today = new DateTimeImmutable('today');
|
||||
?>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-3"><?= e($translations['mailing_praticiens_title'] ?? 'Массовая рассылка практикующим') ?></h2>
|
||||
|
||||
<?php if ($flash): ?>
|
||||
<div class="alert alert-<?= e($flash['type']) ?>"><?= e($flash['msg']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" id="mailingForm">
|
||||
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
|
||||
<input type="hidden" name="do" value="send_mailing">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= e($translations['mailing_praticiens_subject_label'] ?? 'Тема письма') ?></label>
|
||||
<input type="text" name="subject" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label"><?= e($translations['mailing_praticiens_body_label'] ?? 'Текст письма') ?></label>
|
||||
<textarea id="mailBody" name="body" class="form-control" rows="8"></textarea>
|
||||
<div class="form-text">
|
||||
<?= e($translations['mailing_praticiens_body_help'] ?? 'Текст будет отправлен как HTML-письмо с шапкой USLUGI.FR и красной кнопкой «Открыть сайт».') ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<h4 class="mb-3"><?= e($translations['mailing_praticiens_list_title'] ?? 'Список практикующих') ?></h4>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><?= e($translations['mailing_praticiens_filter_status'] ?? 'Фильтр по статусу') ?></label>
|
||||
<select id="filter_status" class="form-select">
|
||||
<option value="all"><?= e($translations['mailing_praticiens_status_all'] ?? 'Все практикующие') ?></option>
|
||||
<option value="active"><?= e($translations['mailing_praticiens_status_active'] ?? 'С активной подпиской') ?></option>
|
||||
<option value="expired"><?= e($translations['mailing_praticiens_status_expired'] ?? 'С истекшей подпиской') ?></option>
|
||||
<option value="noabo"><?= e($translations['mailing_praticiens_status_noabo'] ?? 'Без подписки') ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><?= e($translations['mailing_praticiens_filter_tarif'] ?? 'Фильтр по тарифу') ?></label>
|
||||
<select id="filter_abo" class="form-select">
|
||||
<option value=""><?= e($translations['mailing_praticiens_tarif_all'] ?? 'Все тарифы') ?></option>
|
||||
<?php foreach ($aboOptions as $aid => $name): ?>
|
||||
<option value="<?= (int)$aid ?>">
|
||||
<?= e($translations[$name] ?? $name) ?>
|
||||
(<?= e(sprintf(
|
||||
$translations['mailing_praticiens_tarif_id_label'] ?? 'ID %d',
|
||||
(int)$aid
|
||||
)) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label"><?= e($translations['mailing_praticiens_search_label'] ?? 'Поиск') ?></label>
|
||||
<input type="text" id="filter_search" class="form-control" placeholder="<?= e($translations['mailing_praticiens_search_ph'] ?? 'Имя, фамилия, email...') ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="chk_all" class="form-check-input">
|
||||
<label for="chk_all" class="form-check-label"><?= e($translations['mailing_praticiens_select_all_visible'] ?? 'Выбрать всех в текущем списке') ?></label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger" style="border-radius:999px;">
|
||||
<?= e($translations['mailing_praticiens_send_btn'] ?? 'Отправить письмо выбранным') ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover align-middle" id="tabPraticiens">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:40px;"></th>
|
||||
<th><?= e($translations['mailing_praticiens_th_prenom'] ?? 'Имя') ?></th>
|
||||
<th><?= e($translations['mailing_praticiens_th_nom'] ?? 'Фамилия') ?></th>
|
||||
<th><?= e($translations['mailing_praticiens_th_email'] ?? 'Email') ?></th>
|
||||
<th><?= e($translations['mailing_praticiens_th_tarif'] ?? 'Тариф') ?></th>
|
||||
<th><?= e($translations['mailing_praticiens_th_exp'] ?? 'Окончание') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r):
|
||||
$expRaw = $r['date_expiration'] ?? null;
|
||||
$status = 'noabo';
|
||||
$dateFmt = '';
|
||||
|
||||
if ($expRaw) {
|
||||
try {
|
||||
$exp = new DateTimeImmutable($expRaw);
|
||||
$dateFmt = $exp->format('Y-m-d H:i');
|
||||
$status = ($exp < $today) ? 'expired' : 'active';
|
||||
} catch (Throwable $e) {
|
||||
$status = 'noabo';
|
||||
}
|
||||
}
|
||||
|
||||
$hay = mb_strtolower(
|
||||
trim(($r['prenom'] ?? '').' '.($r['nom'] ?? '').' '.($r['email'] ?? '').' '.($r['abo_nom'] ?? '')),
|
||||
'UTF-8'
|
||||
);
|
||||
$aboId = (int)($r['abonnement_id'] ?? 0);
|
||||
?>
|
||||
<tr data-status="<?= e($status) ?>" data-abo="<?= $aboId ?: '' ?>" data-hay="<?= e($hay) ?>">
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
class="form-check-input row-chk"
|
||||
name="recipients[]"
|
||||
value="<?= (int)$r['id'] ?>">
|
||||
</td>
|
||||
<td><?= e($r['prenom']) ?></td>
|
||||
<td><?= e($r['nom']) ?></td>
|
||||
<td><?= e($r['email']) ?></td>
|
||||
<td><?php $k=trim((string)($r['abo_nom']??'')); echo e($translations[$k] ?? $k); ?></td>
|
||||
<td><?= e($dateFmt) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($rows)): ?>
|
||||
<tr><td colspan="6" class="text-muted text-center"><?= e($translations['mailing_praticiens_none'] ?? 'Нет практикующих.') ?></td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- TinyMCE -->
|
||||
<script src="https://cdn.tiny.cloud/1/l9t0aozxqrwrd6cv3gbopqgt7bi299cjtwwxvyhylwa1kjey/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>
|
||||
<script>
|
||||
tinymce.init({
|
||||
selector: '#mailBody',
|
||||
menubar: false,
|
||||
height: 260,
|
||||
language: 'ru',
|
||||
plugins: 'link lists',
|
||||
toolbar: 'undo redo | bold italic underline | bullist numlist | link removeformat',
|
||||
branding: false
|
||||
});
|
||||
|
||||
// Filtres JS
|
||||
(function(){
|
||||
const statusSel = document.getElementById('filter_status');
|
||||
const aboSel = document.getElementById('filter_abo');
|
||||
const searchInp = document.getElementById('filter_search');
|
||||
const rows = Array.from(document.querySelectorAll('#tabPraticiens tbody tr'));
|
||||
const chkAll = document.getElementById('chk_all');
|
||||
|
||||
function applyFilters(){
|
||||
const st = statusSel.value;
|
||||
const abo = aboSel.value;
|
||||
const q = (searchInp.value || '').trim().toLowerCase();
|
||||
|
||||
rows.forEach(tr => {
|
||||
let ok = true;
|
||||
const trStatus = tr.dataset.status || '';
|
||||
const trAbo = tr.dataset.abo || '';
|
||||
const hay = (tr.dataset.hay || '').toLowerCase();
|
||||
|
||||
if (st !== 'all' && trStatus !== st) ok = false;
|
||||
if (abo && trAbo !== abo) ok = false;
|
||||
if (q && !hay.includes(q)) ok = false;
|
||||
|
||||
tr.style.display = ok ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
statusSel.addEventListener('change', applyFilters);
|
||||
aboSel.addEventListener('change', applyFilters);
|
||||
searchInp.addEventListener('input', applyFilters);
|
||||
|
||||
// Sélectionner tous les visibles
|
||||
if (chkAll) {
|
||||
chkAll.addEventListener('change', function(){
|
||||
const checked = this.checked;
|
||||
rows.forEach(tr => {
|
||||
if (tr.style.display === 'none') return;
|
||||
const cb = tr.querySelector('.row-chk');
|
||||
if (cb) cb.checked = checked;
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
if (($_GET['page'] ?? '') !== 'maintenance') return;
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
|
||||
$roles = (array)($_SESSION['role'] ?? []);
|
||||
if (!in_array('admin', $roles, true)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'.htmlspecialchars(($translations['non_autorise'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8').'</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$e = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
$siteRoot = realpath(__DIR__ . '/../../');
|
||||
|
||||
$statDir = function($path){
|
||||
$n=0; $s=0;
|
||||
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS));
|
||||
foreach($it as $f){
|
||||
if($f->isFile()){ $n++; $s += $f->getSize(); }
|
||||
}
|
||||
return [$n,$s];
|
||||
};
|
||||
|
||||
$uploads = $siteRoot.'/messages/uploads';
|
||||
$backups = $siteRoot.'/backups/db';
|
||||
$logsDir = $siteRoot.'/logs';
|
||||
@is_dir($backups) || @mkdir($backups,0755,true);
|
||||
|
||||
$uploadsStat = is_dir($uploads) ? $statDir($uploads) : [0,0];
|
||||
$backupsList = array_map('basename', glob($backups.'/db_*.sql*') ?: []);
|
||||
usort($backupsList, fn($a,$b)=>strcmp($b,$a));
|
||||
$lastBackup = $backupsList[0] ?? ($translations['maint_none'] ?? '(aucun)');
|
||||
?>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<h3 class="mb-3"><?= $e($translations['maintenance'] ?? 'Centre de maintenance') ?></h3>
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><?= $e($translations['backup_db'] ?? 'Sauvegardes DB') ?></h5>
|
||||
<p class="mb-1">
|
||||
<strong><?= $e($translations['maint_last_backup'] ?? 'Dernier backup:') ?></strong>
|
||||
<?= $e($lastBackup) ?>
|
||||
</p>
|
||||
<p class="text-muted small">
|
||||
<?= $e($translations['maint_folder'] ?? 'Dossier:') ?>
|
||||
<code><?= $e($backups) ?></code>
|
||||
</p>
|
||||
<a class="btn btn-dark" href="?page=backup_db">
|
||||
<?= $e($translations['maint_manage_backups'] ?? 'Gérer les sauvegardes') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><?= $e($translations['maint_uploads_messaging'] ?? 'Uploads messagerie') ?></h5>
|
||||
<p class="mb-1">
|
||||
<strong><?= $e($translations['maint_files'] ?? 'Fichiers:') ?></strong>
|
||||
<?= (int)$uploadsStat[0] ?>
|
||||
— <strong><?= $e($translations['maint_size'] ?? 'Taille:') ?></strong>
|
||||
<?= number_format($uploadsStat[1]/1024/1024, 2) ?> MB
|
||||
</p>
|
||||
<p class="text-muted small">
|
||||
<?= $e($translations['maint_folder'] ?? 'Dossier:') ?>
|
||||
<code><?= $e($uploads) ?></code>
|
||||
</p>
|
||||
<a class="btn btn-outline-dark" href="?page=purger_mess_upload">
|
||||
<?= $e($translations['purger_mess_upload'] ?? 'Purger orphelins') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><?= $e($translations['purger_messages'] ?? 'Conversations supprimées') ?></h5>
|
||||
<p class="text-muted small"><?= $e($translations['maint_purge_conversations_help'] ?? 'Supprimer définitivement les conversations retirées des deux côtés.') ?></p>
|
||||
<a class="btn btn-outline-dark" href="?page=purge_conversations">
|
||||
<?= $e($translations['maint_open'] ?? 'Ouvrir') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><?= $e($translations['afficher_logs'] ?? 'Journaux & erreurs') ?></h5>
|
||||
<p class="text-muted small"><?= $e($translations['maint_logs_help'] ?? 'Consulter, télécharger, vider, appliquer la rotation.') ?></p>
|
||||
<a class="btn btn-outline-dark" href="?page=logs">
|
||||
<?= $e($translations['maint_view_logs'] ?? 'Voir les logs') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><?= $e($translations['cleanup_rdv'] ?? 'Nettoyage RDV') ?></h5>
|
||||
<p class="text-muted small"><?= $e($translations['maint_cleanup_rdv_help'] ?? 'Lister & purger les rendez-vous non désirés.') ?></p>
|
||||
<a class="btn btn-outline-dark" href="?page=cleanup_rdv">
|
||||
<?= $e($translations['maint_open'] ?? 'Ouvrir') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><?= $e($translations['audit'] ?? 'Journal d’audit') ?></h5>
|
||||
<p class="text-muted small"><?= $e($translations['maint_audit_help'] ?? 'Historique des actions administrateur.') ?></p>
|
||||
<a class="btn btn-outline-dark" href="?page=audit">
|
||||
<?= $e($translations['maint_consult'] ?? 'Consulter') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,552 @@
|
||||
<?php
|
||||
// admin/tools/new_clients.php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
|
||||
// ID "super admin" (как в add_video.php)
|
||||
const ADMIN_PRACTICIEN_ID = 88888888;
|
||||
$me = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if ($me !== ADMIN_PRACTICIEN_ID) {
|
||||
http_response_code(403);
|
||||
echo '<div class="container py-5"><div class="alert alert-danger">'
|
||||
. htmlspecialchars($translations['non_autorise'] ?? 'Доступ запрещён.', ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
$mustReauth=function(int $ttl=600){$t=(int)($_SESSION['admin_reauth_at']??0);return ($t===0||(time()-$t)>$ttl);};
|
||||
if ($mustReauth()){ $qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]); header('Location:?'.$qs); exit; }
|
||||
|
||||
// -- PDO si absent
|
||||
// DB
|
||||
|
||||
// CSRF
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
// helpers
|
||||
function e($s) {
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
// ---- Параметры фильтрации ----
|
||||
$q = trim($_GET['q'] ?? '');
|
||||
$role = $_GET['role'] ?? 'all'; // all|client|praticien|secretaire
|
||||
$period = $_GET['period'] ?? 'today'; // today|week|month|30days|custom
|
||||
$dateFrom = trim($_GET['date_from'] ?? '');
|
||||
$dateTo = trim($_GET['date_to'] ?? '');
|
||||
|
||||
// ❗ Pagination: utiliser "p" pour НЕ конфликтовать с ?page=new_inscrits (router)
|
||||
$pageNum = max(1, (int)($_GET['p'] ?? 1));
|
||||
$perPage = 25;
|
||||
|
||||
$validRoles = ['client', 'praticien', 'secretaire'];
|
||||
|
||||
// Период: если указаны даты "С/По", считаем custom
|
||||
if ($dateFrom !== '' || $dateTo !== '') {
|
||||
$period = 'custom';
|
||||
}
|
||||
|
||||
// Диапазон дат по cree_le
|
||||
$now = new DateTimeImmutable('now');
|
||||
|
||||
switch ($period) {
|
||||
case 'today':
|
||||
default:
|
||||
$start = (new DateTimeImmutable('today'))->setTime(0,0,0);
|
||||
$end = $start->modify('+1 day');
|
||||
break;
|
||||
|
||||
case 'week':
|
||||
$start = (new DateTimeImmutable('monday this week'))->setTime(0,0,0);
|
||||
$end = $start->modify('+1 week');
|
||||
break;
|
||||
|
||||
case 'month':
|
||||
$start = (new DateTimeImmutable('first day of this month'))->setTime(0,0,0);
|
||||
$end = $start->modify('first day of next month');
|
||||
break;
|
||||
|
||||
case '30days':
|
||||
$end = $now->setTime(23,59,59);
|
||||
$start = $end->modify('-30 days')->setTime(0,0,0);
|
||||
break;
|
||||
|
||||
case 'custom':
|
||||
if ($dateFrom !== '') {
|
||||
$tmp = DateTimeImmutable::createFromFormat('Y-m-d', $dateFrom) ?: new DateTimeImmutable('today');
|
||||
$start = $tmp->setTime(0,0,0);
|
||||
} else {
|
||||
$start = (new DateTimeImmutable('2000-01-01'))->setTime(0,0,0);
|
||||
}
|
||||
if ($dateTo !== '') {
|
||||
$tmp = DateTimeImmutable::createFromFormat('Y-m-d', $dateTo) ?: $now;
|
||||
$end = $tmp->modify('+1 day')->setTime(0,0,0);
|
||||
} else {
|
||||
$end = $now->modify('+1 day')->setTime(0,0,0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$dtFrom = $start->format('Y-m-d H:i:s');
|
||||
$dtTo = $end->format('Y-m-d H:i:s');
|
||||
|
||||
// ---- Сортировка ----
|
||||
$allowedSort = [
|
||||
'nom' => 'nom',
|
||||
'prenom' => 'prenom',
|
||||
'email' => 'email',
|
||||
'telephone' => 'telephone',
|
||||
'role' => 'role',
|
||||
'cree_le' => 'cree_le',
|
||||
];
|
||||
|
||||
$sort = $_GET['sort'] ?? 'cree_le';
|
||||
$dir = strtolower($_GET['dir'] ?? 'desc');
|
||||
|
||||
if (!isset($allowedSort[$sort])) {
|
||||
$sort = 'cree_le';
|
||||
}
|
||||
if (!in_array($dir, ['asc','desc'], true)) {
|
||||
$dir = 'desc';
|
||||
}
|
||||
|
||||
$orderBySql = $allowedSort[$sort] . ' ' . strtoupper($dir);
|
||||
|
||||
// ---- WHERE + параметры ----
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
// только client, praticien, secretaire
|
||||
$where[] = "c.role IN ('client','praticien','secretaire')";
|
||||
|
||||
if (in_array($role, $validRoles, true)) {
|
||||
$where[] = "c.role = :role";
|
||||
$params[':role'] = $role;
|
||||
}
|
||||
|
||||
// поиск по строке
|
||||
if ($q !== '') {
|
||||
$where[] = "(
|
||||
COALESCE(p.nom, cl.nom, '') LIKE :q_nom
|
||||
OR COALESCE(p.prenom, cl.prenom, '') LIKE :q_prenom
|
||||
OR c.email LIKE :q_email
|
||||
OR COALESCE(p.telephone, cl.telephone, '') LIKE :q_tel
|
||||
)";
|
||||
|
||||
$like = '%' . $q . '%';
|
||||
$params[':q_nom'] = $like;
|
||||
$params[':q_prenom'] = $like;
|
||||
$params[':q_email'] = $like;
|
||||
$params[':q_tel'] = $like;
|
||||
|
||||
// 👉 Опция: когда ищем по строке, НЕ ограничиваем по дате
|
||||
} else {
|
||||
// фильтр по периоду только если нет q
|
||||
$where[] = "c.cree_le >= :from AND c.cree_le < :to";
|
||||
$params[':from'] = $dtFrom;
|
||||
$params[':to'] = $dtTo;
|
||||
}
|
||||
|
||||
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
|
||||
|
||||
// ---- Подсчет общего количества ----
|
||||
$sqlCount = "
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM compte c
|
||||
LEFT JOIN praticien p ON (c.role IN ('praticien','secretaire') AND c.reference_id = p.id)
|
||||
LEFT JOIN client cl ON (c.role = 'client' AND c.reference_id = cl.id)
|
||||
$whereSql
|
||||
";
|
||||
|
||||
try {
|
||||
$st = $pdo->prepare($sqlCount);
|
||||
foreach ($params as $k=>$v) {
|
||||
$st->bindValue($k, $v);
|
||||
}
|
||||
$st->execute();
|
||||
$totalRows = (int)$st->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
echo '<div class="alert alert-danger">'.e($translations['admin_new_clients_sql_err_count'] ?? 'Ошибка SQL (count): ').e($e->getMessage()).'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$totalPages = max(1, (int)ceil($totalRows / $perPage));
|
||||
if ($pageNum > $totalPages) {
|
||||
$pageNum = $totalPages;
|
||||
}
|
||||
$offset = ($pageNum - 1) * $perPage;
|
||||
|
||||
// ---- Основной SELECT ----
|
||||
$sql = "
|
||||
SELECT
|
||||
c.id AS compte_id,
|
||||
c.email,
|
||||
c.role,
|
||||
c.cree_le,
|
||||
c.email_valide,
|
||||
c.token_validation,
|
||||
COALESCE(p.nom, cl.nom) AS nom,
|
||||
COALESCE(p.prenom, cl.prenom) AS prenom,
|
||||
COALESCE(p.telephone, cl.telephone) AS telephone,
|
||||
CASE
|
||||
WHEN c.role='praticien' THEN p.accepted_cgu
|
||||
WHEN c.role='client' THEN cl.accepted_cgu
|
||||
ELSE NULL
|
||||
END AS accepted_cgu
|
||||
FROM compte c
|
||||
LEFT JOIN praticien p ON (c.role IN ('praticien','secretaire') AND c.reference_id = p.id)
|
||||
LEFT JOIN client cl ON (c.role = 'client' AND c.reference_id = cl.id)
|
||||
$whereSql
|
||||
ORDER BY $orderBySql
|
||||
LIMIT :limit OFFSET :offset
|
||||
";
|
||||
|
||||
try {
|
||||
$st = $pdo->prepare($sql);
|
||||
foreach ($params as $k=>$v) {
|
||||
$st->bindValue($k, $v);
|
||||
}
|
||||
$st->bindValue(':limit', (int)$perPage, PDO::PARAM_INT);
|
||||
$st->bindValue(':offset', (int)$offset, PDO::PARAM_INT);
|
||||
$st->execute();
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable $e) {
|
||||
echo '<div class="alert alert-danger">'.e($translations['admin_new_clients_sql_err_select'] ?? 'Ошибка SQL (select): ').e($e->getMessage()).'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// параметры для ссылок сортировки/пагинации (без sort/dir/p)
|
||||
$baseParams = [
|
||||
'q' => $q,
|
||||
'role' => $role,
|
||||
'period' => $period,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
];
|
||||
|
||||
// helper для построения URL сортировки
|
||||
function sortLink($field, $label, $currentSort, $currentDir, $extraParams) {
|
||||
$params = $extraParams;
|
||||
$params['sort'] = $field;
|
||||
if ($currentSort === $field) {
|
||||
$params['dir'] = ($currentDir === 'asc') ? 'desc' : 'asc';
|
||||
} else {
|
||||
$params['dir'] = 'asc';
|
||||
}
|
||||
|
||||
$url = admin_url('new_inscrits', $params);
|
||||
|
||||
$arrow = '';
|
||||
if ($currentSort === $field) {
|
||||
$arrow = $currentDir === 'asc' ? ' ▲' : ' ▼';
|
||||
}
|
||||
|
||||
return '<a href="'.e($url).'">'.e($label.$arrow).'</a>';
|
||||
}
|
||||
?>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-3"><?= e($translations['admin_new_clients_title'] ?? 'Новые регистрации') ?></h2>
|
||||
|
||||
<form class="row g-3 mb-3" method="get" action="index.php">
|
||||
<input type="hidden" name="page" value="new_inscrits">
|
||||
<input type="hidden" name="period" value="<?= e($period) ?>">
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"><?= e($translations['admin_new_clients_search_label'] ?? 'Поиск') ?></label>
|
||||
<input type="text" name="q" class="form-control"
|
||||
placeholder="<?= e($translations['admin_new_clients_search_ph'] ?? 'Имя, фамилия, e-mail, телефон…') ?>"
|
||||
value="<?= e($q) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label"><?= e($translations['admin_new_clients_role_label'] ?? 'Роль') ?></label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="all" <?= $role==='all' ? 'selected' : '' ?>><?= e($translations['admin_new_clients_role_all'] ?? 'Все') ?></option>
|
||||
<option value="client" <?= $role==='client' ? 'selected' : '' ?>><?= e($translations['admin_new_clients_role_clients'] ?? 'Клиенты') ?></option>
|
||||
<option value="praticien" <?= $role==='praticien' ? 'selected' : '' ?>><?= e($translations['admin_new_clients_role_praticiens'] ?? 'Специалисты') ?></option>
|
||||
<option value="secretaire" <?= $role==='secretaire' ? 'selected' : '' ?>><?= e($translations['admin_new_clients_role_secretaires'] ?? 'Секретари') ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<br><br>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label d-block"><?= e($translations['admin_new_clients_period_label'] ?? 'Период') ?></label>
|
||||
<div class="btn-group" role="group">
|
||||
<?php
|
||||
$periods = [
|
||||
'today' => ($translations['admin_new_clients_period_today'] ?? 'Сегодня'),
|
||||
'week' => ($translations['admin_new_clients_period_week'] ?? 'Неделя'),
|
||||
'month' => ($translations['admin_new_clients_period_month'] ?? 'Месяц'),
|
||||
'30days' => ($translations['admin_new_clients_period_30days'] ?? '30 дней'),
|
||||
];
|
||||
foreach ($periods as $key=>$lbl):
|
||||
$btnParams = $baseParams;
|
||||
$btnParams['period'] = $key;
|
||||
$btnParams['p'] = 1;
|
||||
$url = admin_url('new_inscrits', $btnParams);
|
||||
?>
|
||||
<a href="<?= e($url) ?>"
|
||||
class="btn btn-sm <?= $period === $key ? 'btn-danger' : 'btn-outline-danger' ?>">
|
||||
<?= e($lbl) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label"><?= e($translations['admin_new_clients_date_from'] ?? 'С даты') ?></label>
|
||||
<input type="date" name="date_from" class="form-control"
|
||||
value="<?= e($dateFrom) ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label"><?= e($translations['admin_new_clients_date_to'] ?? 'По дату') ?></label>
|
||||
<input type="date" name="date_to" class="form-control"
|
||||
value="<?= e($dateTo) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-danger me-2" style="border-radius:999px;" type="submit">
|
||||
<?= e($translations['admin_new_clients_apply'] ?? 'Применить') ?>
|
||||
</button>
|
||||
<?php
|
||||
$resetUrl = admin_url('new_inscrits', [
|
||||
'period' => 'today',
|
||||
'role' => 'all',
|
||||
'p' => 1,
|
||||
]);
|
||||
?>
|
||||
<a href="<?= e($resetUrl) ?>" class="btn btn-outline-secondary" style="border-radius:999px;">
|
||||
<?= e($translations['admin_new_clients_reset'] ?? 'Сброс') ?>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mb-2 text-muted">
|
||||
<?= e($translations['admin_new_clients_found'] ?? 'Найдено записей:') ?>
|
||||
<strong><?= (int)$totalRows ?></strong>.
|
||||
<?= e($translations['admin_new_clients_page'] ?? 'Страница') ?>
|
||||
<?= (int)$pageNum ?>
|
||||
<?= e($translations['admin_new_clients_page_of'] ?? 'из') ?>
|
||||
<?= (int)$totalPages ?>.
|
||||
</div>
|
||||
|
||||
<style>
|
||||
table.table thead th a {
|
||||
color: #38378B;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
table.table td a {
|
||||
color: #38378B;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
table.table thead th a:hover {
|
||||
color: #E03C3F;
|
||||
}
|
||||
table.table td a:hover {
|
||||
color: #E03C3F;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th><?= sortLink('nom', ($translations['admin_new_clients_th_nom'] ?? 'Фамилия'), $sort, $dir, $baseParams + ['p'=>$pageNum]) ?></th>
|
||||
<th><?= sortLink('prenom', ($translations['admin_new_clients_th_prenom'] ?? 'Имя'), $sort, $dir, $baseParams + ['p'=>$pageNum]) ?></th>
|
||||
<th><?= sortLink('email', ($translations['admin_new_clients_th_email'] ?? 'E-mail'), $sort, $dir, $baseParams + ['p'=>$pageNum]) ?></th>
|
||||
<th><?= sortLink('telephone', ($translations['admin_new_clients_th_telephone'] ?? 'Телефон'), $sort, $dir, $baseParams + ['p'=>$pageNum]) ?></th>
|
||||
<th><?= sortLink('role', ($translations['admin_new_clients_th_role'] ?? 'Роль'), $sort, $dir, $baseParams + ['p'=>$pageNum]) ?></th>
|
||||
<th><?= sortLink('cree_le', ($translations['admin_new_clients_th_cree_le'] ?? 'Дата регистрации'), $sort, $dir, $baseParams + ['p'=>$pageNum]) ?></th>
|
||||
<th><?= e($translations['admin_new_clients_th_email_status'] ?? 'E-mail') ?></th>
|
||||
<th><?= e($translations['admin_new_clients_th_cgu'] ?? 'CGU') ?></th>
|
||||
<th style="width:120px;"><?= e($translations['admin_new_clients_th_actions'] ?? 'Действия') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($rows)): ?>
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-muted py-4">
|
||||
<?= e($translations['admin_new_clients_empty'] ?? 'Нет регистраций за выбранный период.') ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($rows as $r): ?>
|
||||
<?php
|
||||
if ($r['role'] === 'client') $roleLabel = ($translations['admin_new_clients_role_client_one'] ?? 'Клиент');
|
||||
elseif ($r['role'] === 'praticien')$roleLabel = ($translations['admin_new_clients_role_praticien_one'] ?? 'Специалист');
|
||||
elseif ($r['role'] === 'secretaire') $roleLabel = ($translations['admin_new_clients_role_secretaire_one'] ?? 'Секретарь');
|
||||
else $roleLabel = $r['role'];
|
||||
|
||||
$creeLe = $r['cree_le'] ? (new DateTime($r['cree_le']))->format('d.m.Y H:i') : '';
|
||||
?>
|
||||
<tr>
|
||||
<td><?= e($r['nom']) ?></td>
|
||||
<td><?= e($r['prenom']) ?></td>
|
||||
<td><a href="mailto:<?= e($r['email']) ?>"><?= e($r['email']) ?></a></td>
|
||||
<td><?= e($r['telephone']) ?></td>
|
||||
<td><?= e($roleLabel) ?></td>
|
||||
<td><?= e($creeLe) ?></td>
|
||||
<td>
|
||||
<?php if ((int)$r['email_valide'] === 1): ?>
|
||||
<button class="btn btn-sm btn-outline-success" disabled><?= e($translations['admin_new_clients_email_ok'] ?? 'Подтверждён') ?></button>
|
||||
<?php else: ?>
|
||||
<button class="btn btn-sm btn-outline-danger" disabled><?= e($translations['admin_new_clients_email_ko'] ?? 'KO') ?></button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($r['accepted_cgu'] === null): ?>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled><?= e($translations['admin_new_clients_dash'] ?? '—') ?></button>
|
||||
<?php elseif ((int)$r['accepted_cgu'] === 1): ?>
|
||||
<button class="btn btn-sm btn-outline-success" disabled><?= e($translations['admin_new_clients_cgu_yes'] ?? 'Приняты') ?></button>
|
||||
<?php else: ?>
|
||||
<button class="btn btn-sm btn-outline-danger" disabled><?= e($translations['admin_new_clients_cgu_no'] ?? 'Не приняты') ?></button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-danger js-show-details"
|
||||
data-id="<?= (int)$r['compte_id'] ?>">
|
||||
<?= e($translations['admin_new_clients_details'] ?? 'Подробно') ?>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<nav aria-label="<?= e($translations['admin_new_clients_pages_aria'] ?? 'Страницы') ?>">
|
||||
<ul class="pagination justify-content-center mt-3">
|
||||
<?php
|
||||
$prevPage = max(1, $pageNum - 1);
|
||||
$nextPage = min($totalPages, $pageNum + 1);
|
||||
|
||||
$prevParams = $baseParams + ['p'=>$prevPage, 'sort'=>$sort, 'dir'=>$dir];
|
||||
$nextParams = $baseParams + ['p'=>$nextPage, 'sort'=>$sort, 'dir'=>$dir];
|
||||
|
||||
$prevUrl = admin_url('new_inscrits', $prevParams);
|
||||
$nextUrl = admin_url('new_inscrits', $nextParams);
|
||||
?>
|
||||
<li class="page-item <?= $pageNum <= 1 ? 'disabled' : '' ?>">
|
||||
<a class="page-link" href="<?= e($prevUrl) ?>"><?= e($translations['admin_new_clients_prev'] ?? '« Предыдущая') ?></a>
|
||||
</li>
|
||||
|
||||
<?php
|
||||
$startPage = max(1, $pageNum - 2);
|
||||
$endPage = min($totalPages, $pageNum + 2);
|
||||
for ($i = $startPage; $i <= $endPage; $i++):
|
||||
$pParams = $baseParams + ['p'=>$i, 'sort'=>$sort, 'dir'=>$dir];
|
||||
$pUrl = admin_url('new_inscrits', $pParams);
|
||||
?>
|
||||
<li class="page-item <?= $i === $pageNum ? 'active' : '' ?>">
|
||||
<a class="page-link" href="<?= e($pUrl) ?>"><?= (int)$i ?></a>
|
||||
</li>
|
||||
<?php endfor; ?>
|
||||
|
||||
<li class="page-item <?= $pageNum >= $totalPages ? 'disabled' : '' ?>">
|
||||
<a class="page-link" href="<?= e($nextUrl) ?>"><?= e($translations['admin_new_clients_next'] ?? 'Следующая »') ?></a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const NEWCLIENTS_CSRF = <?= json_encode($CSRF, JSON_UNESCAPED_UNICODE) ?>;
|
||||
|
||||
function postForm(url, data) {
|
||||
const body = new URLSearchParams(data);
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).then(r => r.json());
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
const btnSend = e.target.closest('.js-send-validation');
|
||||
if (btnSend) {
|
||||
const id = btnSend.dataset.id;
|
||||
if (!id) return;
|
||||
if (!confirm(<?= json_encode($translations['admin_new_clients_confirm_send_link'] ?? 'Отправить ссылку для подтверждения регистрации на этот e-mail?', JSON_UNESCAPED_UNICODE) ?>)) return;
|
||||
|
||||
postForm('tools/ajax_send_validation_link.php', {
|
||||
csrf: NEWCLIENTS_CSRF,
|
||||
id: id
|
||||
}).then(data => {
|
||||
if (data && data.ok) {
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({ icon: 'success', title: <?= json_encode($translations['admin_new_clients_done'] ?? 'Готово', JSON_UNESCAPED_UNICODE) ?>, text: data.message || <?= json_encode($translations['admin_new_clients_link_sent'] ?? 'Ссылка отправлена.', JSON_UNESCAPED_UNICODE) ?> });
|
||||
} else {
|
||||
alert(data.message || <?= json_encode($translations['admin_new_clients_link_sent'] ?? 'Ссылка отправлена.', JSON_UNESCAPED_UNICODE) ?>);
|
||||
}
|
||||
} else {
|
||||
const msg = (data && data.error) ? data.error : <?= json_encode($translations['admin_new_clients_err_send_link'] ?? 'Ошибка при отправке ссылки.', JSON_UNESCAPED_UNICODE) ?>;
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({ icon: 'error', title: <?= json_encode($translations['admin_new_clients_error'] ?? 'Ошибка', JSON_UNESCAPED_UNICODE) ?>, text: msg });
|
||||
} else {
|
||||
alert(msg);
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({ icon: 'error', title: <?= json_encode($translations['admin_new_clients_error'] ?? 'Ошибка', JSON_UNESCAPED_UNICODE) ?>, text: <?= json_encode($translations['admin_new_clients_err_request'] ?? 'Произошла ошибка запроса.', JSON_UNESCAPED_UNICODE) ?> });
|
||||
} else {
|
||||
alert(<?= json_encode($translations['admin_new_clients_err_request'] ?? 'Произошла ошибка запроса.', JSON_UNESCAPED_UNICODE) ?>);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const btnDetails = e.target.closest('.js-show-details');
|
||||
if (btnDetails) {
|
||||
const id = btnDetails.dataset.id;
|
||||
if (!id) return;
|
||||
|
||||
postForm('tools/ajax_fiche_user.php', {
|
||||
csrf: NEWCLIENTS_CSRF,
|
||||
id: id
|
||||
}).then(data => {
|
||||
if (data && data.ok && data.html) {
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
title: <?= json_encode($translations['admin_new_clients_user_card_title'] ?? 'Карточка пользователя', JSON_UNESCAPED_UNICODE) ?>,
|
||||
html: data.html,
|
||||
width: '600px',
|
||||
confirmButtonText: <?= json_encode($translations['admin_new_clients_close'] ?? 'Закрыть', JSON_UNESCAPED_UNICODE) ?>
|
||||
});
|
||||
} else {
|
||||
const w = window.open('', '_blank', 'width=600,height=600');
|
||||
if (w) { w.document.write(data.html); w.document.close(); }
|
||||
else { alert(<?= json_encode($translations['admin_new_clients_err_popup'] ?? 'Не удалось открыть окно с подробностями.', JSON_UNESCAPED_UNICODE) ?>); }
|
||||
}
|
||||
} else {
|
||||
const msg = (data && data.error) ? data.error : <?= json_encode($translations['admin_new_clients_err_load'] ?? 'Не удалось загрузить данные.', JSON_UNESCAPED_UNICODE) ?>;
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({ icon: 'error', title: <?= json_encode($translations['admin_new_clients_error'] ?? 'Ошибка', JSON_UNESCAPED_UNICODE) ?>, text: msg });
|
||||
} else {
|
||||
alert(msg);
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({ icon: 'error', title: <?= json_encode($translations['admin_new_clients_error'] ?? 'Ошибка', JSON_UNESCAPED_UNICODE) ?>, text: <?= json_encode($translations['admin_new_clients_err_request'] ?? 'Произошла ошибка запроса.', JSON_UNESCAPED_UNICODE) ?> });
|
||||
} else {
|
||||
alert(<?= json_encode($translations['admin_new_clients_err_request'] ?? 'Произошла ошибка запроса.', JSON_UNESCAPED_UNICODE) ?>);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
/**
|
||||
* /admin/tools/purge_conversations.php
|
||||
* Purge des conversations où deleted_for_praticien = 1 ET deleted_for_client = 1.
|
||||
* - Affichage Bootstrap
|
||||
* - DRY-RUN par défaut ; purge LIVE via POST + CSRF
|
||||
* - AUCUNE fonction globale déclarée (helpers en closures) -> pas de collision avec index.php
|
||||
*/
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
if (($_GET['page'] ?? '') !== 'purge_conversations') { return; }
|
||||
|
||||
// Sécurité (l'admin/index.php a normalement déjà vérifié ça)
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
|
||||
// Lang (pour $translations)
|
||||
|
||||
|
||||
$roles = (array)($_SESSION['role'] ?? []);
|
||||
if (!in_array('admin', $roles, true)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['admin_access_denied'] ?? 'Accès refusé.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
// reauth (10 min)
|
||||
$mustReauth=function(int $ttl=600){$t=(int)($_SESSION['admin_reauth_at']??0);return ($t===0||(time()-$t)>$ttl);};
|
||||
if ($mustReauth()){ $qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]); header('Location:?'.$qs); exit; }
|
||||
// PDO si absent
|
||||
if (!isset($pdo)) {
|
||||
$mb = realpath(__DIR__ . '/../../messages/_bootstrap.php');
|
||||
if ($mb && is_file($mb)) { require_once $mb; }
|
||||
}
|
||||
|
||||
if (!isset($pdo) || !($pdo instanceof PDO)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars((string)($translations['admin_db_unavailable'] ?? 'Connexion DB indisponible.'), ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (\Throwable $e) {}
|
||||
|
||||
// CSRF
|
||||
if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); }
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
// -------- Helpers en closures (pas de fonctions globales !) ----------
|
||||
$e = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
$logs = [];
|
||||
$log = static function(string $m) use (&$logs) { $logs[] = $m; };
|
||||
$siteRoot = realpath(__DIR__ . '/../../');
|
||||
$safeWithin = static function(string $path, string $base) {
|
||||
$rp = realpath($path); $rb = realpath($base);
|
||||
return $rp && $rb && strpos($rp, $rb) === 0;
|
||||
};
|
||||
$tableExists = static function(PDO $pdo, string $table): bool {
|
||||
$sql = "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?";
|
||||
$st = $pdo->prepare($sql); $st->execute([$table]); return (bool)$st->fetchColumn();
|
||||
};
|
||||
$placeholders = static function(int $n): string { return implode(',', array_fill(0,$n,'?')); };
|
||||
$chunked = static function(array $ids, int $size=500): array {
|
||||
$out=[]; $chunk=[]; foreach ($ids as $id) { $chunk[]=(int)$id; if (count($chunk)>=$size){$out[]=$chunk;$chunk=[];} }
|
||||
if ($chunk) $out[]=$chunk; return $out;
|
||||
};
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
$retentionDays = isset($_REQUEST['days']) ? max(0, (int)$_REQUEST['days']) : 0; // ex 30
|
||||
$doLive = false;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'purge') {
|
||||
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. $e($translations['csrf_invalid'] ?? 'Token CSRF invalide.')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
$doLive = true;
|
||||
}
|
||||
$dry = !$doLive;
|
||||
|
||||
$errors = [];
|
||||
$stats = ['conversations'=>0,'messages'=>0,'attachments'=>0,'counters'=>0,'read_markers'=>0,'duration'=>0.0];
|
||||
$candidates = []; // [id, updated_at]
|
||||
$attachList = []; // max 50
|
||||
|
||||
$start = microtime(true);
|
||||
|
||||
try {
|
||||
// Tables
|
||||
foreach (['conversations','messages'] as $t) {
|
||||
if (!$tableExists($pdo, $t)) {
|
||||
$tpl = (string)($translations['purge_table_missing'] ?? "Table <code>{TABLE}</code> introuvable.");
|
||||
$errors[] = str_replace('{TABLE}', $e($t), $tpl);
|
||||
}
|
||||
}
|
||||
$hasCounters = $tableExists($pdo, 'conversation_counters');
|
||||
$hasReadMk = $tableExists($pdo, 'read_markers');
|
||||
|
||||
if (empty($errors)) {
|
||||
// 1) Candidats
|
||||
$lastSql = "SELECT id, updated_at FROM conversations WHERE deleted_for_praticien=1 AND deleted_for_client=1";
|
||||
if ($retentionDays > 0) { $lastSql .= " AND updated_at < (NOW() - INTERVAL {$retentionDays} DAY)"; }
|
||||
$rows = $pdo->query($lastSql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $r) { $candidates[] = [(int)$r['id'], (string)$r['updated_at']]; }
|
||||
$stats['conversations'] = count($candidates);
|
||||
|
||||
if ($stats['conversations'] > 0) {
|
||||
$ids = array_column($candidates, 0);
|
||||
|
||||
// 2) Stats pré-purge
|
||||
foreach ($chunked($ids, 500) as $batch) {
|
||||
$ph = $placeholders(count($batch));
|
||||
|
||||
// messages
|
||||
$st = $pdo->prepare("SELECT COUNT(*) FROM messages WHERE conversation_id IN ($ph)");
|
||||
$st->execute($batch);
|
||||
$stats['messages'] += (int)$st->fetchColumn();
|
||||
|
||||
// attachments (sample)
|
||||
$st = $pdo->prepare("SELECT attachment_path FROM messages WHERE conversation_id IN ($ph)
|
||||
AND attachment_path IS NOT NULL AND attachment_path<>''");
|
||||
$st->execute($batch);
|
||||
while ($row = $st->fetch(PDO::FETCH_ASSOC)) {
|
||||
$p = (string)$row['attachment_path'];
|
||||
if ($p !== '') {
|
||||
$stats['attachments']++;
|
||||
if (count($attachList) < 50) { $attachList[] = $p; }
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasCounters) {
|
||||
$st = $pdo->prepare("SELECT COUNT(*) FROM conversation_counters WHERE conversation_id IN ($ph)");
|
||||
$st->execute($batch);
|
||||
$stats['counters'] += (int)$st->fetchColumn();
|
||||
}
|
||||
if ($hasReadMk) {
|
||||
$st = $pdo->prepare("SELECT COUNT(*) FROM read_markers WHERE conversation_id IN ($ph)");
|
||||
$st->execute($batch);
|
||||
$stats['read_markers'] += (int)$st->fetchColumn();
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Purge LIVE
|
||||
if ($doLive) {
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// supprimer fichiers de pièces jointes
|
||||
foreach ($chunked($ids, 500) as $batch) {
|
||||
$ph = $placeholders(count($batch));
|
||||
$st = $pdo->prepare("SELECT attachment_path FROM messages WHERE conversation_id IN ($ph)
|
||||
AND attachment_path IS NOT NULL AND attachment_path<>''");
|
||||
$st->execute($batch);
|
||||
while ($row = $st->fetch(PDO::FETCH_ASSOC)) {
|
||||
$rel = (string)$row['attachment_path']; // ex: messages/uploads/xxx
|
||||
$abs = $siteRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $rel);
|
||||
if ($safeWithin($abs, $siteRoot) && is_file($abs)) {
|
||||
if (@unlink($abs)) { $log("[OK] ".$e($translations['purge_log_file_deleted'] ?? 'Fichier supprimé').": {$rel}"); }
|
||||
else { $log("[WARN] ".$e($translations['purge_log_file_delete_failed'] ?? 'Échec suppression fichier').": {$rel}"); }
|
||||
} else {
|
||||
$log("[INFO] ".$e($translations['purge_log_file_missing_or_outside'] ?? 'Fichier introuvable/hors périmètre').": {$rel}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dépendances
|
||||
foreach ($chunked($ids, 500) as $batch) {
|
||||
$ph = $placeholders(count($batch));
|
||||
$st = $pdo->prepare("DELETE FROM messages WHERE conversation_id IN ($ph)");
|
||||
$st->execute($batch);
|
||||
$log("[OK] ".$e($translations['purge_log_messages_deleted_batch'] ?? 'messages supprimés (batch)').": ".$st->rowCount());
|
||||
|
||||
if ($hasCounters) {
|
||||
$st = $pdo->prepare("DELETE FROM conversation_counters WHERE conversation_id IN ($ph)");
|
||||
$st->execute($batch);
|
||||
$log("[OK] ".$e($translations['purge_log_counters_deleted_batch'] ?? 'conversation_counters supprimés (batch)').": ".$st->rowCount());
|
||||
}
|
||||
if ($hasReadMk) {
|
||||
$st = $pdo->prepare("DELETE FROM read_markers WHERE conversation_id IN ($ph)");
|
||||
$st->execute($batch);
|
||||
$log("[OK] ".$e($translations['purge_log_read_markers_deleted_batch'] ?? 'read_markers supprimés (batch)').": ".$st->rowCount());
|
||||
}
|
||||
}
|
||||
|
||||
// parent
|
||||
foreach ($chunked($ids, 500) as $batch) {
|
||||
$ph = $placeholders(count($batch));
|
||||
$st = $pdo->prepare("DELETE FROM conversations WHERE id IN ($ph)");
|
||||
$st->execute($batch);
|
||||
$log("[OK] ".$e($translations['purge_log_conversations_deleted_batch'] ?? 'conversations supprimées (batch)').": ".$st->rowCount());
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
$log($translations['purge_done_ok'] ?? 'Purge effectuée avec succès.');
|
||||
} catch (\Throwable $ex) {
|
||||
$pdo->rollBack();
|
||||
$tpl = (string)($translations['purge_error'] ?? 'Erreur purge: {ERR}');
|
||||
$errors[] = str_replace('{ERR}', $e($ex->getMessage()), $tpl);
|
||||
}
|
||||
} else {
|
||||
$log($translations['purge_dry_run_hint'] ?? 'DRY-RUN: aucune suppression; utilisez le bouton Purger en LIVE pour exécuter.');
|
||||
}
|
||||
} else {
|
||||
$log($translations['purge_none'] ?? 'Aucune conversation à purger.');
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $ex) {
|
||||
$tpl = (string)($translations['purge_exception'] ?? 'Exception: {ERR}');
|
||||
$errors[] = str_replace('{ERR}', $e($ex->getMessage()), $tpl);
|
||||
}
|
||||
|
||||
$stats['duration'] = microtime(true) - $start;
|
||||
?>
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-4">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
|
||||
<h3 class="m-0"><?= $e($translations['purge_title'] ?? 'Purger les conversations supprimées des deux côtés') ?></h3>
|
||||
<span class="badge text-bg-<?= $dry ? 'secondary' : 'danger' ?>">
|
||||
<?= $dry ? $e($translations['purge_mode_dry'] ?? 'DRY-RUN (simulation)') : $e($translations['purge_mode_live'] ?? 'LIVE (suppression activée)') ?>
|
||||
</span>
|
||||
<span class="badge text-bg-info">
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_retention_badge'] ?? 'Rétention: {N} jour(s)');
|
||||
echo $e(str_replace('{N}', (string)((int)$retentionDays), $tpl));
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($errors)): ?>
|
||||
<div class="alert alert-danger">
|
||||
<strong><?= $e($translations['errors_title'] ?? 'Erreurs') ?></strong>
|
||||
<ul class="mb-0">
|
||||
<?php foreach ($errors as $er): ?><li><?= $er ?></li><?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="csrf" value="<?= $e($CSRF) ?>">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label"><?= $e($translations['purge_retention_label'] ?? 'Rétention (jours) avant purge') ?></label>
|
||||
<input type="number" class="form-control" name="days" min="0" value="<?= (int)$retentionDays ?>">
|
||||
</div>
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button type="submit" name="action" value="scan" class="btn btn-outline-secondary">
|
||||
<?= $e($translations['btn_scan'] ?? 'Scanner') ?>
|
||||
</button>
|
||||
<button type="submit" name="action" value="purge" class="btn btn-danger"
|
||||
onclick="return confirm('<?= $e($translations['confirm_purge_definitive'] ?? 'Confirmer la purge définitive ?') ?>');">
|
||||
<?= $e($translations['btn_purge'] ?? 'Purger') ?>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><?= $e($translations['purge_summary'] ?? 'Résumé') ?></div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['purge_targeted_conversations'] ?? 'Conversations ciblées') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)$stats['conversations'] ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['purge_impacted_messages'] ?? 'Messages impactés') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)$stats['messages'] ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['purge_attachments_files'] ?? 'Pièces jointes (fichiers)') ?>
|
||||
<span class="badge text-bg-<?= $stats['attachments'] ? 'warning':'secondary' ?>">
|
||||
<?= (int)$stats['attachments'] ?>
|
||||
</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['purge_counters_to_delete'] ?? 'Counters à supprimer') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)$stats['counters'] ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['purge_read_markers_to_delete'] ?? 'Read markers à supprimer') ?>
|
||||
<span class="badge text-bg-secondary"><?= (int)$stats['read_markers'] ?></span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<?= $e($translations['purge_duration'] ?? 'Durée') ?>
|
||||
<span class="badge text-bg-secondary"><?= number_format($stats['duration'], 3) ?> s</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><?= $e($translations['purge_candidates_preview'] ?? 'Conversations candidates (aperçu)') ?></div>
|
||||
<div class="card-body p-0">
|
||||
<?php if (empty($candidates)): ?>
|
||||
<div class="p-3 text-muted"><?= $e($translations['purge_none'] ?? 'Aucune conversation à purger.') ?></div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= $e($translations['th_id'] ?? 'ID') ?></th>
|
||||
<th><?= $e($translations['th_last_update'] ?? 'Dernière MAJ') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach (array_slice($candidates, 0, 100) as [$cid, $upd]): ?>
|
||||
<tr>
|
||||
<td><?= (int)$cid ?></td>
|
||||
<td><time><?= $e($upd) ?></time></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php if (count($candidates) > 100): ?>
|
||||
<div class="p-2 small text-muted">
|
||||
<?php
|
||||
$tpl = (string)($translations['purge_more_count'] ?? '(+ {N} de plus...)');
|
||||
echo $e(str_replace('{N}', (string)(count($candidates)-100), $tpl));
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><?= $e($translations['purge_attachments_examples'] ?? 'Exemples de pièces jointes (max 50)') ?></div>
|
||||
<div class="card-body">
|
||||
<?php if (empty($attachList)): ?>
|
||||
<div class="text-muted"><?= $e($translations['purge_no_attachments_found'] ?? 'Aucune pièce jointe trouvée pour ces conversations.') ?></div>
|
||||
<?php else: ?>
|
||||
<ul class="mb-0">
|
||||
<?php foreach ($attachList as $p): ?>
|
||||
<li><code><?= $e($p) ?></code></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><?= $e($translations['purge_log_title'] ?? 'Journal') ?></div>
|
||||
<div class="card-body">
|
||||
<pre class="small mb-0" style="white-space:pre-wrap;"><?= $e(implode("\n", $logs)) ?></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
Usage supprimer les conversations.
|
||||
|
||||
Test (dry-run) : /messages/tools/purge_deleted.php
|
||||
|
||||
Purge réelle : /messages/tools/purge_deleted.php?dry=0
|
||||
|
||||
Avec rétention 30 jours : /messages/tools/purge_deleted.php?dry=0&days=30
|
||||
|
||||
Si tu as mis une FK messages.conversation_id ON DELETE CASCADE, tu peux supprimer directement dans conversations et la base effacera messages automatiquement. Le script ci-dessus est “safe” même sans cascade.
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../public/includes/init_admin.php';
|
||||
if (($_GET['page'] ?? '') !== 'reauth') return;
|
||||
|
||||
|
||||
$roles = (array)($_SESSION['role'] ?? []);
|
||||
if (!in_array('admin', $roles, true)) {
|
||||
echo '<div class="container my-4"><div class="alert alert-danger">'
|
||||
. htmlspecialchars($translations['non_autorise'] ?? 'Accès refusé.', ENT_QUOTES, 'UTF-8')
|
||||
. '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (Throwable) {}
|
||||
|
||||
$e = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
$msg = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'reauth') {
|
||||
if (!hash_equals($CSRF, $_POST['csrf'] ?? '')) {
|
||||
$msg = $translations['reauth_csrf_invalid'] ?? 'Token CSRF invalide.';
|
||||
} else {
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
$uid = (int)($_SESSION['compte_id'] ?? $_SESSION['user_id'] ?? 0);
|
||||
|
||||
// fallback email
|
||||
if ($uid <= 0 && !empty($_SESSION['email'])) {
|
||||
$st = $pdo->prepare("SELECT id FROM compte WHERE email = ? LIMIT 1");
|
||||
$st->execute([(string)$_SESSION['email']]);
|
||||
$uid = (int)$st->fetchColumn();
|
||||
}
|
||||
|
||||
$ok = false;
|
||||
if ($uid > 0) {
|
||||
$st = $pdo->prepare("SELECT mot_de_passe FROM compte WHERE id = ? AND role = 'admin' LIMIT 1");
|
||||
$st->execute([$uid]);
|
||||
$hash = $st->fetchColumn();
|
||||
|
||||
if ($hash && password_verify($password, $hash)) {
|
||||
$ok = true;
|
||||
|
||||
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
|
||||
$new = password_hash($password, PASSWORD_DEFAULT);
|
||||
$up = $pdo->prepare("UPDATE compte SET mot_de_passe = ? WHERE id = ?");
|
||||
$up->execute([$new, $uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
$_SESSION['admin_reauth_at'] = time();
|
||||
$redir = $_GET['redir'] ?? 'index.php?page=maintenance';
|
||||
header('Location: '.$redir);
|
||||
exit;
|
||||
} else {
|
||||
$msg = $translations['reauth_invalid_password'] ?? 'Неверный пароль.';
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<div class="container my-5" style="max-width:560px">
|
||||
<h3><?= $e($translations['reauth_title'] ?? 'Подтвердите свою личность') ?></h3>
|
||||
<p class="text-muted"><?= $e($translations['reauth_intro'] ?? 'Чтобы продолжить выполнение чувствительного действия, пожалуйста, введите ваш пароль.') ?></p>
|
||||
|
||||
<?php if ($msg): ?>
|
||||
<div class="alert alert-danger"><?= $e($msg) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" class="card card-body">
|
||||
<input type="hidden" name="csrf" value="<?= $e($CSRF) ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= $e($translations['reauth_password_label'] ?? 'Пароль') ?></label>
|
||||
<input type="password"
|
||||
name="password"
|
||||
class="form-control"
|
||||
required
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-danger"
|
||||
style="border-radius:999px"
|
||||
name="action"
|
||||
value="reauth">
|
||||
<?= $e($translations['reauth_confirm'] ?? 'Подтвердить') ?>
|
||||
</button>
|
||||
|
||||
<a class="btn btn-outline-secondary"
|
||||
style="border-radius:999px"
|
||||
href="index.php?page=maintenance">
|
||||
<?= $e($translations['btn_annuler'] ?? 'Отмена') ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="small text-muted mt-3">
|
||||
<?= $e($translations['reauth_footer_notice'] ?? 'Внимание! Усиленная безопасность, ведётся аудит.') ?>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
// admin/tools/retour_admin.php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
@session_start();
|
||||
}
|
||||
|
||||
// On ne met pas require_login_or_redirect ici, car on peut être sous un compte client/praticien
|
||||
// On se contente de vérifier qu'on a bien une info d’admin d’origine.
|
||||
if (!empty($_SESSION['admin_impersonate']) && is_array($_SESSION['admin_impersonate'])) {
|
||||
$orig = $_SESSION['admin_impersonate'];
|
||||
|
||||
if (isset($orig['admin_user_id'])) {
|
||||
$_SESSION['user_id'] = $orig['admin_user_id'];
|
||||
}
|
||||
if (isset($orig['admin_reference_id'])) {
|
||||
$_SESSION['reference_id'] = $orig['admin_reference_id'];
|
||||
}
|
||||
if (isset($orig['admin_role'])) {
|
||||
$_SESSION['role'] = $orig['admin_role'];
|
||||
}
|
||||
|
||||
unset($_SESSION['admin_impersonate']);
|
||||
}
|
||||
|
||||
// Retour à l’admin
|
||||
header('Location: /admin/index.php');
|
||||
exit;
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
require_once APP_HTDOCS . '/includes/avatar_initiales.php';
|
||||
|
||||
|
||||
$praticien_id = (int)($_GET['praticien_id'] ?? 0);
|
||||
if ($praticien_id <= 0) { echo "<div class='container py-5'>ID invalide.</div>"; exit; }
|
||||
|
||||
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
||||
function percent_from_5($n){ $n=max(0,min(5,(float)$n)); return (int)round($n/5*100); }
|
||||
function pseudo_from($prenom,$nom){
|
||||
$p = trim((string)$prenom); $n = trim((string)$nom);
|
||||
$p = $p!=='' ? mb_convert_case($p, MB_CASE_TITLE, 'UTF-8') : 'Client';
|
||||
$i = $n!=='' ? mb_strtoupper(mb_substr($n,0,1,'UTF-8'),'UTF-8') : '';
|
||||
return $i ? ($p.'.'.$i) : $p;
|
||||
}
|
||||
|
||||
// Titre + stats
|
||||
$pp = $pdo->prepare("SELECT titre, prenom, nom FROM praticien WHERE id=?");
|
||||
$pp->execute([$praticien_id]);
|
||||
$pr = $pp->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$pr) { echo "<div class='container py-5'>".$translations['praticien_introuvable']."</div>"; exit; }
|
||||
|
||||
$avg = $pdo->prepare("SELECT ROUND(AVG(note),1) AS m, COUNT(*) AS c FROM avis_praticien WHERE praticien_id=? AND statut='publie'");
|
||||
$avg->execute([$praticien_id]); $stats = $avg->fetch(PDO::FETCH_ASSOC) ?: ['m'=>null,'c'=>0];
|
||||
|
||||
// Form POST
|
||||
$flash = null;
|
||||
if ($_SERVER['REQUEST_METHOD']==='POST' && ($_POST['do'] ?? '')==='add') {
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
|
||||
$flash = ['type'=>'danger','msg'=>$translations['avis_form_err']];
|
||||
} elseif ($_SESSION['role'] !== 'client') {
|
||||
$flash = ['type'=>'danger','msg'=>$translations['non_autorise'] ?? 'Non autorisé'];
|
||||
} else {
|
||||
$note = (int)($_POST['note'] ?? 0);
|
||||
$titre = trim((string)($_POST['titre'] ?? ''));
|
||||
$commentaire = trim((string)($_POST['commentaire'] ?? ''));
|
||||
$visit_date = trim((string)($_POST['visit_date'] ?? ''));
|
||||
|
||||
$ok = $note>=1 && $note<=5 && mb_strlen($commentaire,'UTF-8')>=10;
|
||||
$date_ok = true;
|
||||
if ($visit_date !== '') {
|
||||
$ts = strtotime($visit_date.' 00:00:00');
|
||||
$date_ok = $ts && $ts <= time()+86400; // pas dans le futur
|
||||
}
|
||||
if ($ok && $date_ok) {
|
||||
$st = $pdo->prepare("INSERT INTO avis_praticien (praticien_id, client_id, note, titre, commentaire, visit_date, statut)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'publie')");
|
||||
$st->execute([$praticien_id, (int)$_SESSION['reference_id'], $note,
|
||||
($titre!==''?$titre:null), ($commentaire!==''?$commentaire:null),
|
||||
($visit_date!==''?$visit_date:null)]);
|
||||
$flash = ['type'=>'success','msg'=>$translations['avis_form_ok']];
|
||||
// refresh stats
|
||||
$avg->execute([$praticien_id]); $stats = $avg->fetch(PDO::FETCH_ASSOC) ?: ['m'=>null,'c'=>0];
|
||||
} else {
|
||||
$flash = ['type'=>'danger','msg'=>$translations['avis_form_err']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
$perPage = 10;
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$cnt = $pdo->prepare("SELECT COUNT(*) FROM avis_praticien WHERE praticien_id = :pid AND statut = 'publie'");
|
||||
$cnt->execute([':pid' => $praticien_id]);
|
||||
$total = (int)$cnt->fetchColumn();
|
||||
$pages = max(1, (int)ceil($total / $perPage));
|
||||
|
||||
$sql = "SELECT a.note, a.titre, a.commentaire, a.visit_date, a.created_at,
|
||||
c.prenom, c.nom
|
||||
FROM avis_praticien a
|
||||
LEFT JOIN client c ON c.id = a.client_id
|
||||
WHERE a.praticien_id = :pid AND a.statut = 'publie'
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT :o, :l";
|
||||
|
||||
$ls = $pdo->prepare($sql);
|
||||
$ls->bindValue(':pid', $praticien_id, PDO::PARAM_INT);
|
||||
$ls->bindValue(':o', (int)$offset, PDO::PARAM_INT);
|
||||
$ls->bindValue(':l', (int)$perPage, PDO::PARAM_INT);
|
||||
$ls->execute();
|
||||
|
||||
$avis = $ls->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ID depuis l'URL (on passe bien ?praticien_id=...)
|
||||
$praticien_id = (int)($_GET['praticien_id'] ?? 0);
|
||||
if ($praticien_id <= 0) { echo "<div class='container py-5'>ID invalide.</div>"; exit; }
|
||||
|
||||
// Charger le praticien
|
||||
$pp = $pdo->prepare("SELECT titre, prenom, nom FROM praticien WHERE id = :id");
|
||||
$pp->execute([':id' => $praticien_id]);
|
||||
$pr = $pp->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$pr) {
|
||||
echo "<div class='container py-5'>".$translations['praticien_introuvable']."</div>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Définir $name AVANT de l'utiliser
|
||||
$name = trim(
|
||||
($pr['prenom'] ?? '')
|
||||
. ' '
|
||||
. ($pr['nom'] ?? '')
|
||||
);
|
||||
$CSRF = $_SESSION['csrf_token'] ?? '';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?= htmlspecialchars($translations['seo_avis_title'], ENT_QUOTES, 'UTF-8') ?></title>
|
||||
<meta name="description" content="<?= htmlspecialchars($translations['seo_avis_desc'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta name="keywords" content="<?= htmlspecialchars($translations['seo_avis_keywords'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta name="author" content="Uslugi.fr">
|
||||
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<link href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/site_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/custom_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/mobil_style.css') ?>">
|
||||
<?php include_once __DIR__ . '/includes/tracking.php'; ?>
|
||||
</head>
|
||||
<body class="layout">
|
||||
|
||||
<?php if ($logged):
|
||||
include_header_clean(APP_HTDOCS . '/public/includes/header.php');
|
||||
else: ?>
|
||||
<!-- Header public (Connexion + “?”) -->
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo_u.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-mobile"></a>
|
||||
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-desktop"></a>
|
||||
</div>
|
||||
<div class="lang-switch">
|
||||
<?php require __DIR__ . '/includes/menu_langue.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-actions">
|
||||
<a class="btn-login" href="/connexion.php">
|
||||
<span class="d-sm-none"><?php echo $translations['btn_se_connecter']?></span>
|
||||
<span class="d-none d-sm-inline"><?php echo $translations['ja_spec']?></span>
|
||||
</a>
|
||||
<!--<a class="btn-login" href="/connexion.php"><?php echo $translations['ja_spec']?></a>-->
|
||||
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-logo rounded-circle d-inline-flex align-items-center justify-content-center question-btn"
|
||||
type="button" data-bs-toggle="dropdown" data-bs-auto-close="true"
|
||||
aria-expanded="false" aria-label="Помощь">?</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow help-menu menu-panel">
|
||||
<li><a href="/how-it-work.php"><?= $translations['kak_rabotaet'] ?></a></a></li>
|
||||
<li><a href="/about.php"><?= $translations['pro_nas'] ?></a></li>
|
||||
<li><a href="/faq.php"><?= $translations['faq'] ?></a></li>
|
||||
<li><a href="/contact.php"><?= $translations['nam_pisat'] ?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="main">
|
||||
<div class="container py-5">
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-2"><?= h($translations['avis_titre_page']) ?> — <?= h($name) ?></h2>
|
||||
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<?php $m=(float)($stats['m'] ?? 0); $c=(int)($stats['c'] ?? 0); $w=percent_from_5($m); ?>
|
||||
<div class="rating" aria-label="Note">
|
||||
<div class="stars-base">★★★★★</div>
|
||||
<div class="stars-fill" style="width:<?= $w ?>%">★★★★★</div>
|
||||
</div>
|
||||
<div class="text-muted"><?= $m ? number_format($m,1,',',' ') : '—' ?>/5 • <?= $c.' '.h($translations['avis_total']) ?></div>
|
||||
</div>
|
||||
|
||||
<?php if ($flash): ?>
|
||||
<div class="alert alert-<?= h($flash['type']) ?>"><?= h($flash['msg']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($_SESSION['role']==='client'): ?>
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-3"><?= h($translations['avis_form_titre']) ?></h5>
|
||||
<form method="post" class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label"><?= h($translations['avis_form_note']) ?></label>
|
||||
<select name="note" class="form-select" required>
|
||||
<?php for($i=5;$i>=1;$i--): ?>
|
||||
<option value="<?= $i ?>"> <?= $i ?> ★</option>
|
||||
<?php endfor; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label"><?= h($translations['avis_form_date_visite']) ?></label>
|
||||
<input type="date" name="visit_date" class="form-control">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= h($translations['avis_form_titre_lbl']) ?></label>
|
||||
<input type="text" name="titre" class="form-control" maxlength="150">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= h($translations['avis_form_commentaire']) ?></label>
|
||||
<textarea name="commentaire" class="form-control" rows="4" minlength="10" required></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="hidden" name="csrf" value="<?= h($CSRF) ?>">
|
||||
<input type="hidden" name="do" value="add">
|
||||
<button class="btn btn-primary"><?= h($translations['avis_form_envoyer']) ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$avis): ?>
|
||||
<div class="text-muted"><?= h($translations['avis_aucun']) ?></div>
|
||||
<?php else: foreach ($avis as $a): $pw=percent_from_5((float)$a['note']); ?>
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<div>
|
||||
<div class="rating mb-1">
|
||||
<div class="stars-base">★★★★★</div>
|
||||
<div class="stars-fill" style="width:<?= $pw ?>%">★★★★★</div>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
<strong><?= h(pseudo_from($a['prenom'] ?? '', $a['nom'] ?? '')) ?></strong>
|
||||
<?php if(!empty($a['visit_date'])): ?> • <?= date('d/m/Y', strtotime($a['visit_date'])) ?><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small text-muted"><?= date('d/m/Y', strtotime($a['created_at'])) ?></div>
|
||||
</div>
|
||||
<?php if(!empty($a['titre'])): ?><div class="fw-semibold mb-1"><?= h($a['titre']) ?></div><?php endif; ?>
|
||||
<div><?= nl2br(h($a['commentaire'] ?? '')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; endif; ?>
|
||||
|
||||
<?php if ($pages>1): ?>
|
||||
<nav class="mt-3">
|
||||
<?php for ($i=1;$i<=$pages;$i++):
|
||||
$href = '/public/client/avis.php?praticien_id='.$praticien_id.'&page='.$i;
|
||||
$cls = $i===$page ? 'btn btn-sm btn-primary me-1' : 'btn btn-sm btn-outline-primary me-1';
|
||||
?>
|
||||
<a class="<?= $cls ?>" href="<?= h($href) ?>"><?= $i ?></a>
|
||||
<?php endfor; ?>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mt-4">
|
||||
<a class="btn btn-outline-secondary" href="/detail_public.php?id=<?= (int)$praticien_id ?>">⬅️ <?= $translations['retour_avis_profil'] ?></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?= $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
@@ -0,0 +1 @@
|
||||
[{"url":"https:\/\/www.sovetnik.fr\/adaptacziya-za-graniczej-ot-sebya-ne\/","title":"АДАПТАЦИЯ ЗА ГРАНИЦЕЙ: ОТ СЕБЯ НЕ СПРЯТАТЬСЯ. ПСИХОЛОГИЯ ЭМИГРАЦИИ","desc":"Переезд за границу почти всегда сопровождается стрессом и трудностями адаптации, даже если он был добровольным и хорошо подготовленным. В новой стране меняется не только внешняя среда, но и внутреннее состояние человека. Психоаналитик из Франции Ирина Стензель объясняет, почему адаптация редко проходит легко, какие психологические сложности возникают у иммигрантов и когда стоит обращаться за профессиональной поддержкой. […]","img":"https:\/\/www.sovetnik.fr\/wp-content\/uploads\/2026\/01\/adaptacziya-za-graniczej-sovetnik-1769612677.jpg"},{"url":"https:\/\/www.sovetnik.fr\/entry-exit-system-ees-novye-pravila\/","title":"ENTRY EXIT SYSTEM (EES): НОВЫЕ ПРАВИЛА ВЪЕЗДА И ВЫЕЗДА В ШЕНГЕНСКУЮ ЗОНУ","desc":"Этот материал подготовлен для сайта «СОВЕТНИК.ФР». Если после прочтения у вас остаются вопросы по визам, ВНЖ, отказам во въезде или другим миграционным ситуациям во Франции, вы можете записаться на консультацию к профильным специалистам на платформе УСЛУГИ.ФР. С 2024 года Франция и другие страны ЕС поэтапно внедряют автоматизированную систему Entry Exit System (EES). Она заменяет штампы […]","img":"https:\/\/www.sovetnik.fr\/wp-content\/uploads\/2026\/01\/entry-exit-system-sovetnik-1768299548.png"},{"url":"https:\/\/www.sovetnik.fr\/nasledovanie-vo-franczii-chto-poluchit\/","title":"НАСЛЕДОВАНИЕ ВО ФРАНЦИИ: ЧТО ПОЛУЧИТ СУПРУГ, А ЧТО — ДЕТИ","desc":"Алла Дюка — адвокат в Париже, специализируется на международном семейном праве. Это интервью — часть проекта «Советник», где эксперты объясняют сложные юридические вопросы простым языком, на примерах из реальной жизни. После интервью можно задать вопросы и записаться на консультацию к Алле Дюка напрямую через платформу uslugi.fr Видео-версия интервью здесь Предположим конкретную ситуацию. Умирает француз. У […]","img":"https:\/\/www.sovetnik.fr\/wp-content\/uploads\/2026\/01\/nasledstvo-vo-franczii-sovetnik-1767968144.png"},{"url":"https:\/\/www.sovetnik.fr\/vygodno-li-pokupat-nedvizhimost-vo\/","title":"ВЫГОДНО ЛИ ПОКУПАТЬ НЕДВИЖИМОСТЬ ВО ФРАНЦИИ В 2026 ГОДУ?","desc":"Элен Бекетов — агент по недвижимости во Франции. В интервью Марии Юсаковой на YouTube канале @sovetnikfrance, Элен рассказывает о текущем состоянии рынка недвижимости, типичных ошибках покупателей, скрытых расходах и о том, на что стоит обратить внимание при покупке жилья во Франции в 2026 году. Полную видео-версию интервью можно посмотреть здесь Как изменилась ситуация на рынке […]","img":"https:\/\/www.sovetnik.fr\/wp-content\/uploads\/2026\/01\/vygodno-li-pokupat-sovetnik-1767891774.jpg"}]
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
|
||||
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
|
||||
http_response_code(400);
|
||||
exit('ID praticien invalide.');
|
||||
}
|
||||
|
||||
$praticien_id = intval($_GET['id']);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT rv.*, c.prenom, c.nom
|
||||
FROM rendez_vous rv
|
||||
JOIN client c ON rv.client_id = c.id
|
||||
WHERE rv.praticien_id = ? AND rv.statut IN ('confirmé', 'prévu')
|
||||
ORDER BY rv.date_rdv
|
||||
");
|
||||
$stmt->execute([$praticien_id]);
|
||||
$rdvs = $stmt->fetchAll();
|
||||
|
||||
header('Content-Type: text/calendar; charset=utf-8');
|
||||
header('Content-Disposition: inline; filename="agenda.ics"');
|
||||
|
||||
echo "BEGIN:VCALENDAR
|
||||
";
|
||||
echo "VERSION:2.0
|
||||
";
|
||||
echo "CALSCALE:GREGORIAN
|
||||
";
|
||||
echo "METHOD:PUBLISH
|
||||
";
|
||||
echo "X-WR-CALNAME:Agenda Praticien
|
||||
";
|
||||
echo "X-WR-TIMEZONE:Europe/Paris
|
||||
";
|
||||
|
||||
foreach ($rdvs as $r) {
|
||||
// Génération et stockage d’un UUID stable pour éviter les doublons
|
||||
if (empty($r['uuid_ical'])) {
|
||||
$uuid = bin2hex(random_bytes(16));
|
||||
$update = $pdo->prepare("UPDATE rendez_vous SET uuid_ical = ? WHERE id = ?");
|
||||
$update->execute([$uuid, $r['id']]);
|
||||
} else {
|
||||
$uuid = $r['uuid_ical'];
|
||||
}
|
||||
|
||||
$start = new DateTime($r['date_rdv'], new DateTimeZone('Europe/Paris'));
|
||||
$end = clone $start;
|
||||
$end->modify("+" . intval($r['duree_minutes'] ?? 60) . " minutes");
|
||||
|
||||
$summary = "RDV avec {$r['prenom']} {$r['nom']}";
|
||||
|
||||
echo "BEGIN:VEVENT
|
||||
";
|
||||
echo "UID:$uuid
|
||||
";
|
||||
echo "DTSTAMP:" . gmdate('Ymd\THis\Z') . "
|
||||
";
|
||||
echo "DTSTART;TZID=Europe/Paris:" . $start->format('Ymd\THis') . "
|
||||
";
|
||||
echo "DTEND;TZID=Europe/Paris:" . $end->format('Ymd\THis') . "
|
||||
";
|
||||
echo "SUMMARY:$summary
|
||||
";
|
||||
echo "STATUS:CONFIRMED
|
||||
";
|
||||
echo "END:VEVENT
|
||||
";
|
||||
}
|
||||
|
||||
echo "END:VCALENDAR
|
||||
";
|
||||
exit;
|
||||
?>
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
// /choisir_abb.php (BLOC EMBED - pas de <html>, pas de <body>, pas de form Stripe)
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
|
||||
// Abonnement gratuit (id=1)
|
||||
$FREE_PLAN_ID = 1;
|
||||
|
||||
// (Optionnel) Déjà utilisé ? -> on retire le gratuit
|
||||
$hasUsedFree = false;
|
||||
$praticien_id = (int)($_SESSION['praticien_id'] ?? 0); // si tu as déjà un id en session, sinon 0
|
||||
|
||||
if ($praticien_id > 0) {
|
||||
$sql = "SELECT 1
|
||||
FROM paiement
|
||||
WHERE praticien_id = ?
|
||||
AND abonnement_id = ?
|
||||
AND (statut IS NULL OR statut <> 'échoué')
|
||||
LIMIT 1";
|
||||
$st = $pdo->prepare($sql);
|
||||
$st->execute([$praticien_id, $FREE_PLAN_ID]);
|
||||
$hasUsedFree = (bool)$st->fetchColumn();
|
||||
}
|
||||
|
||||
// Abonnements actifs
|
||||
$stmt = $pdo->query("SELECT * FROM abonnement WHERE actif = 1 ORDER BY prix ASC, duree_jours ASC");
|
||||
$abonnements = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($hasUsedFree) {
|
||||
$abonnements = array_values(array_filter($abonnements, fn($ab) => (int)$ab['id'] !== 1));
|
||||
}
|
||||
|
||||
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
||||
?>
|
||||
|
||||
<style>
|
||||
.abo-wrap{
|
||||
max-width: 480px;
|
||||
margin: 24px auto 10px;
|
||||
background:#fff;
|
||||
border:1px solid #e5e7eb;
|
||||
border-radius:18px;
|
||||
box-shadow:0 8px 20px rgba(0,0,0,.06);
|
||||
padding:18px;
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 768px){ .abo-grid{ grid-template-columns:1fr 1fr; } }
|
||||
|
||||
.abo-item{
|
||||
border:1px solid #e5e7eb;
|
||||
border-radius:16px;
|
||||
padding:12px 12px 12px;
|
||||
margin-bottom: 10px;
|
||||
display:flex; gap:10px;
|
||||
cursor:pointer;
|
||||
transition: .15s ease;
|
||||
align-items:flex-start;
|
||||
}
|
||||
.abo-item:hover{ border-color:#c7d2fe; box-shadow:0 6px 16px rgba(0,0,0,.05); }
|
||||
.abo-item.active{ border-color:#38378B; box-shadow:0 10px 22px rgba(56,55,139,.12); }
|
||||
|
||||
.abo-radio{ margin-top:2px; }
|
||||
.abo-name{ font-weight:700; color:#38378B; margin:0; font-size:16px;}
|
||||
.abo-desc{ margin:6px 0 0; color:#555; font-size:13px; line-height:1.35; }
|
||||
.abo-meta{ margin-top:8px; font-size:14px; font-weight:700; color:#38378B; display:flex; gap:10px; flex-wrap:wrap; }
|
||||
|
||||
.abo-error{ display:none; margin-top:10px; color:#E03C3F; font-size:13px; text-align:center; }
|
||||
</style>
|
||||
|
||||
<div class="abo-wrap" id="abo-block">
|
||||
<h3 class="mb-4 text-center"><?= h($translations['choisissez_votre_abonnement'] ?? 'Выберите ваш тариф') ?></h4>
|
||||
<p class="abo-sub"><?= h($translations['abo_obligatoire_inscription'] ?? "Выберите тариф: без этого регистрация невозможна.") ?></p>
|
||||
|
||||
<?php if (!$abonnements): ?>
|
||||
<div class="alert alert-warning text-center mb-0"><?= h($translations['aucun_abonnement_dispo'] ?? 'Нет доступных тарифов.') ?></div>
|
||||
<?php else: ?>
|
||||
<div class="abo-grid" role="radiogroup" aria-label="<?= h($translations['choisissez_votre_abonnement'] ?? 'Выберите ваш тариф') ?>">
|
||||
<?php foreach ($abonnements as $ab): ?>
|
||||
<?php
|
||||
$isFree = ((float)$ab['prix'] == 0.0);
|
||||
|
||||
$prixTxt = $isFree
|
||||
? ($translations['gratuit'] ?? 'Бесплатно')
|
||||
: number_format((float)$ab['prix'], 2, ',', ' ') . ' €';
|
||||
|
||||
$nomKey = trim((string)($ab['nom'] ?? ''));
|
||||
$descKey = trim((string)($ab['description'] ?? ''));
|
||||
|
||||
$nomTxt = $translations[$nomKey] ?? $nomKey;
|
||||
$descTxt = $translations[$descKey] ?? $descKey;
|
||||
|
||||
$id = (int)$ab['id'];
|
||||
?>
|
||||
|
||||
<label class="abo-item" data-abo-item="1">
|
||||
<input class="abo-radio"
|
||||
type="radio"
|
||||
name="abonnement_id_ui"
|
||||
value="<?= $id ?>"
|
||||
aria-label="<?= h($nomTxt) ?>">
|
||||
<div>
|
||||
<p class="abo-name"><?= h($nomTxt) ?></p>
|
||||
|
||||
<?php if ($descKey !== ''): ?>
|
||||
<p class="abo-desc"><?= nl2br(h($descTxt)) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="abo-meta">
|
||||
|
||||
<?= h($translations['col_duree'] ?? 'Длительность:') ?>
|
||||
<?= (int)$ab['duree_jours'] ?> <?= h($translations['dnei'] ?? 'дней') ?><br>
|
||||
|
||||
<?= h($prixTxt) ?>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="abo-error" id="abo-error">
|
||||
<?= h($translations['abonnement_obligatoire'] ?? "Пожалуйста, выберите тариф.") ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const block = document.getElementById('abo-block');
|
||||
if (!block) return;
|
||||
|
||||
function refreshActive(){
|
||||
const items = block.querySelectorAll('.abo-item');
|
||||
const err = document.getElementById('abo-error');
|
||||
const hidden = document.getElementById('abonnement_id'); // ✅ maintenant il existe
|
||||
|
||||
items.forEach(it => it.classList.remove('active'));
|
||||
|
||||
const checked = block.querySelector('input[name="abonnement_id_ui"]:checked');
|
||||
if (checked) {
|
||||
checked.closest('.abo-item')?.classList.add('active');
|
||||
if (hidden) hidden.value = checked.value; // ✅ rempli bien le hidden
|
||||
if (err) err.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ un seul listener robuste
|
||||
block.addEventListener('change', function(e){
|
||||
if (e.target && e.target.name === 'abonnement_id_ui') refreshActive();
|
||||
});
|
||||
|
||||
// init
|
||||
refreshActive();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><?= htmlspecialchars($translations['seo_choix_inscription_title'], ENT_QUOTES, 'UTF-8') ?></title>
|
||||
<meta name="description" content="<?= htmlspecialchars($translations['seo_choix_inscription_desc'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta name="keywords" content="<?= htmlspecialchars($translations['seo_choix_inscription_keywords'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta name="author" content="Uslugi.fr">
|
||||
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="<?= asset('/includes/site_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/custom_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/mobil_style.css') ?>">
|
||||
<?php include_once __DIR__ . '/includes/tracking.php'; ?>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo_u.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-mobile"></a>
|
||||
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-desktop"></a>
|
||||
</div>
|
||||
<div class="lang-switch">
|
||||
<?php require __DIR__ . '/includes/menu_langue.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-actions">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-logo rounded-circle d-inline-flex align-items-center justify-content-center question-btn"
|
||||
type="button" data-bs-toggle="dropdown" data-bs-auto-close="true"
|
||||
aria-expanded="false" aria-label="Помощь">?</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow help-menu menu-panel">
|
||||
<li><a href="/how-it-work.php"><?= $translations['kak_rabotaet'] ?></a></a></li>
|
||||
<li><a href="/about.php"><?= $translations['pro_nas'] ?></a></li>
|
||||
<li><a href="/faq.php"><?= $translations['faq'] ?></a></li>
|
||||
<li><a href="/contact.php"><?= $translations['nam_pisat'] ?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<style>
|
||||
.btn-secondary {
|
||||
background-color: #38378B !important; /* ton bleu avatar */
|
||||
border-color: #38378B !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
<div class="main">
|
||||
<div class="container p-4 shadow-sm bg-white rounded-3" style="min-width: 380px; width: 100%; margin:40px auto;">
|
||||
<h2 class="text-center mb-4"><?= $translations['choix_type_inscription'] ?></h2>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title"><?= $translations['choix_client_titre'] ?></h5>
|
||||
<p class="card-text"><?= $translations['choix_client_texte'] ?></p>
|
||||
<a href="/inscription_client.php" class="btn btn-primary"><?= $translations['btn_inscription_client'] ?></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mt-4 mt-md-0">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title"><?= $translations['choix_pro_titre'] ?></h5>
|
||||
<p class="card-text"><?= $translations['choix_pro_texte'] ?></p>
|
||||
<a href="/inscription_praticien.php" class="btn btn-secondary"><?= $translations['btn_inscription_pro'] ?></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?= $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"require": {
|
||||
"google/apiclient": "^2.14",
|
||||
"google/apiclient-services": "^0.327",
|
||||
"phpmailer/phpmailer": "^6.10",
|
||||
"stripe/stripe-php": "^17.6",
|
||||
"endroid/qr-code": "^5.0",
|
||||
"phpseclib/phpseclib": "^3",
|
||||
"minishlink/web-push": "^10.0"
|
||||
}
|
||||
}
|
||||
Generated
+2035
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
|
||||
$e = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
$title = $translations['delete_account_done_title'] ?? 'Compte supprimé';
|
||||
$doneText = $translations['delete_account_done_text'] ?? 'Votre compte a été supprimé. Nous sommes désolés de vous voir partir.';
|
||||
$backLabel = $translations['delete_account_back_home'] ?? 'Revenir sur uslugi.fr';
|
||||
$loginLabel = $translations['delete_account_back_login'] ?? 'Aller à la page de connexion';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= $e($title) ?></title>
|
||||
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/site_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/custom_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/mobil_style.css') ?>">
|
||||
<style>
|
||||
body {
|
||||
background:#8CA9FF;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="h4 mb-3"><?= $e($title) ?></h1>
|
||||
<p class="mb-4"><?= nl2br($e($doneText)) ?></p>
|
||||
|
||||
<div class="d-flex gap-2 justify-content-center">
|
||||
<a href="https://uslugi.fr" class="btn btn-primary">
|
||||
<?= $e($backLabel) ?>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/public/includes/init_auth.php';
|
||||
$erreur = '';
|
||||
$email = '';
|
||||
$mot_de_passe = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$mot_de_passe = $_POST['mot_de_passe'] ?? '';
|
||||
|
||||
if ($email === '' || $mot_de_passe === '') {
|
||||
$erreur = $translations['erreur_champs_vides'];
|
||||
} else {
|
||||
// 1) On vérifie l’utilisateur
|
||||
$stmt = $pdo->prepare("SELECT * FROM compte WHERE email = :email LIMIT 1");
|
||||
$stmt->execute(['email' => $email]);
|
||||
$compte = $stmt->fetch();
|
||||
|
||||
if ($compte && password_verify($mot_de_passe, $compte['mot_de_passe'])) {
|
||||
if (empty($compte['email_valide'])) {
|
||||
$erreur = $translations['erreur_email_non_valide'];
|
||||
} else {
|
||||
session_regenerate_id(true); // ✅ anti session fixation
|
||||
// 2) Connexion OK → on initialise la session
|
||||
$_SESSION['compte_id'] = $compte['id'];
|
||||
$_SESSION['role'] = strtolower(trim($compte['role'] ?? ''));
|
||||
$_SESSION['reference_id'] = $compte['reference_id'];
|
||||
$_SESSION['email'] = $compte['email'];
|
||||
$_SESSION['last_activity'] = time();
|
||||
|
||||
// 3) On gère la redirection prioritaire vers la page demandée
|
||||
$next = $_POST['next'] ?? $_GET['next'] ?? ($_SESSION['redirect_to'] ?? null);
|
||||
$next = next_safe($next);
|
||||
if ($next) {
|
||||
unset($_SESSION['redirect_to']);
|
||||
header('Location: ' . $next);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4) Sinon, fallback par rôle
|
||||
if ($_SESSION['role'] === 'client') {
|
||||
header('Location: /public/client/mes_rendez_vous.php');exit;
|
||||
} elseif ($_SESSION['role'] === 'admin') {
|
||||
header('Location: /admin/index.php');exit;
|
||||
} elseif ($_SESSION['role'] === 'secretaire') {
|
||||
header('Location: /public/cabinet/dashboard.php');exit;
|
||||
} else {
|
||||
header('Location: /public/praticien/agenda.php');exit;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$erreur = $translations['erreur_identifiants'];
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><?php echo htmlspecialchars($translations['seo_connexion_title'], ENT_QUOTES, 'UTF-8')?></title>
|
||||
<meta name="description" content="<?php echo htmlspecialchars($translations['seo_connexion_desc'], ENT_QUOTES, 'UTF-8')?>">
|
||||
<meta name="keywords" content="<?php echo htmlspecialchars($translations['seo_connexion_keywords'], ENT_QUOTES, 'UTF-8')?>">
|
||||
<meta name="author" content="Uslugi.fr">
|
||||
|
||||
|
||||
<!-- Recommandé pour une page de login -->
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
|
||||
<link rel="stylesheet" href="<?= asset('/includes/site_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/custom_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/mobil_style.css') ?>">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo_u.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-mobile"></a>
|
||||
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-desktop"></a>
|
||||
</div>
|
||||
<div class="lang-switch">
|
||||
<?php require __DIR__ . '/includes/menu_langue.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-actions">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-logo rounded-circle d-inline-flex align-items-center justify-content-center question-btn"
|
||||
type="button" data-bs-toggle="dropdown" data-bs-auto-close="true"
|
||||
aria-expanded="false" aria-label="Помощь">?</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow help-menu menu-panel">
|
||||
<li><a href="/how-it-work.php"><?= $translations['kak_rabotaet'] ?></a></a></li>
|
||||
<li><a href="/about.php"><?= $translations['pro_nas'] ?></a></li>
|
||||
<li><a href="/faq.php"><?= $translations['faq'] ?></a></li>
|
||||
<li><a href="/contact.php"><?= $translations['nam_pisat'] ?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="card shadow-sm p-4" style="min-width:380px; max-width:520px; margin:40px auto;">
|
||||
<h3 class="mb-4 text-center text-dark"><?php echo $translations['connexion_titre']?></h3>
|
||||
|
||||
<?php if ($erreur): ?>
|
||||
<div class="alert alert-danger py-2"><?php echo htmlspecialchars($erreur)?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<input type="email" name="email" class="form-control"
|
||||
placeholder="<?php echo $translations['email_placeholder']?>"
|
||||
value="<?php echo htmlspecialchars($email)?>" required>
|
||||
</div>
|
||||
<div class="mb-3 position-relative">
|
||||
<input id="mot_de_passe" type="password" name="mot_de_passe" class="form-control"
|
||||
placeholder="<?php echo $translations['motdepasse_placeholder']?>"
|
||||
required autocomplete="current-password">
|
||||
<span class="toggle-password"
|
||||
id="eyePwd"
|
||||
onmouseenter="revealPwd('mot_de_passe')"
|
||||
onmouseleave="hidePwd('mot_de_passe')"
|
||||
onmousedown="revealPwd('mot_de_passe')"
|
||||
onmouseup="hidePwd('mot_de_passe')"
|
||||
ontouchstart="revealPwd('mot_de_passe')"
|
||||
ontouchend="hidePwd('mot_de_passe')"
|
||||
title="Наведите, чтобы показать">
|
||||
👁️
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- on propage le "next" -->
|
||||
<input type="hidden" name="next"
|
||||
value="<?php echo htmlspecialchars($_GET['next'] ?? ($_SESSION['redirect_to'] ?? ''), ENT_QUOTES)?>">
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100"><?php echo $translations['btn_se_connecter']?></button>
|
||||
<div class="text-center mt-3">
|
||||
<a href="mdp_oublie.php" class="me-2"><?php echo $translations['lien_mdp_oublie']?></a> |
|
||||
<a href="choix_inscr.php" class="ms-2"><?php echo $translations['lien_inscription']?></a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?php echo $translations['copyr']?></p>
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
function revealPwd(id){ var el = document.getElementById(id); if(el) el.type = 'text'; }
|
||||
function hidePwd(id){ var el = document.getElementById(id); if(el) el.type = 'password'; }
|
||||
(function(){
|
||||
var eye = document.getElementById('eyePwd');
|
||||
var input = document.getElementById('mot_de_passe');
|
||||
if(!eye || !input) return;
|
||||
|
||||
// Mobile (iOS/Android) — empêche sélection/callout et révèle/masque
|
||||
eye.addEventListener('touchstart', function(e){ e.preventDefault(); revealPwd('mot_de_passe'); }, {passive:false});
|
||||
eye.addEventListener('touchend', function(e){ e.preventDefault(); hidePwd('mot_de_passe'); }, {passive:false});
|
||||
eye.addEventListener('touchcancel',function(){ hidePwd('mot_de_passe'); });
|
||||
|
||||
// Desktop / trackpad — empêche sélection au maintien
|
||||
eye.addEventListener('mousedown', function(e){ e.preventDefault(); revealPwd('mot_de_passe'); });
|
||||
eye.addEventListener('mouseup', function(e){ e.preventDefault(); hidePwd('mot_de_passe'); });
|
||||
eye.addEventListener('mouseleave',function(){ hidePwd('mot_de_passe'); });
|
||||
})();
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
|
||||
// ===== Helpers header (identiques à index.php) =====
|
||||
function is_client_logged_in(): bool {
|
||||
$role = $_SESSION['role'] ?? ($_SESSION['user_type'] ?? '');
|
||||
if (!empty($_SESSION['user_id']) && $role === 'client') return true;
|
||||
if (!empty($_SESSION['client_id'])) return true;
|
||||
if (!empty($_SESSION['reference_id']) && $role === 'client') return true;
|
||||
if (!empty($_SESSION['user_id']) && $role === 'praticien') return true;
|
||||
if (!empty($_SESSION['praticien_id'])) return true;
|
||||
if (!empty($_SESSION['reference_id']) && $role === 'praticien') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function include_header_clean(string $absPath): void {
|
||||
global $translations, $lang;
|
||||
ob_start();
|
||||
include $absPath; // /public/includes/header.php
|
||||
$buf = ob_get_clean() ?: '';
|
||||
|
||||
// 0) DOCTYPE + <html>…</html>
|
||||
$buf = preg_replace('~<!doctype\s+html[^>]*>~i', '', $buf);
|
||||
$buf = preg_replace('~</?html\b[^>]*>~i', '', $buf);
|
||||
|
||||
// 1) retirer <head>…</head> et <body> ouverts/fermés
|
||||
$buf = preg_replace('~<head\b[^>]*>.*?</head>~is', '', $buf);
|
||||
$buf = preg_replace('~</?body\b[^>]*>~i', '', $buf);
|
||||
|
||||
// 2) retirer les <link> CSS “globaux” pour éviter d’écraser la home
|
||||
$buf = preg_replace('~<link[^>]+href="[^"]*(bootstrap[^"]*\.css|mobil_style\.css|site_style\.css|fonts\.googleapis)[^"]*"[^>]*>~i', '', $buf);
|
||||
|
||||
// 3) retirer le <script> Bootstrap CDN (on le charge déjà en bas de la page)
|
||||
$buf = preg_replace('~<script[^>]+src="[^"]*bootstrap[^"]*bundle[^"]*\.js[^"]*"[^>]*>\s*</script>~i', '', $buf);
|
||||
|
||||
echo $buf; // garde <header> + tes scripts inline utiles
|
||||
}
|
||||
|
||||
/* ---------- Utils ---------- */
|
||||
function envget(string $k): string {
|
||||
return trim((string)($_ENV[$k] ?? $_SERVER[$k] ?? getenv($k) ?? ''));
|
||||
}
|
||||
|
||||
function t(array $translations, string $key, string $fallback = ''): string {
|
||||
$val = $translations[$key] ?? '';
|
||||
if ($val === '') return $fallback;
|
||||
return (string)$val;
|
||||
}
|
||||
|
||||
$LOG_DIR = __DIR__ . '/tmp';
|
||||
$LOG_FILE = $LOG_DIR . '/contact.log';
|
||||
if (!is_dir($LOG_DIR)) { @mkdir($LOG_DIR, 0770, true); } // dossier local, pas servi publiquement
|
||||
|
||||
/* ---------- CSRF ---------- */
|
||||
if (empty($_SESSION['pub_csrf'])) {
|
||||
$_SESSION['pub_csrf'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$CSRF = $_SESSION['pub_csrf'];
|
||||
|
||||
// --- reCAPTCHA v2 (lecture robuste) ---
|
||||
$rcCfg = [];
|
||||
$cfgPath = __DIR__ . '/includes/recaptcha.php';
|
||||
if (is_file($cfgPath)) {
|
||||
$rcCfg = require $cfgPath; // ['site' => '...', 'secret' => '...']
|
||||
}
|
||||
|
||||
$RECAPTCHA_SITE_KEY = trim((string)($rcCfg['site'] ?? envget('RECAPTCHA_SITE_KEY') ?? ''));
|
||||
$RECAPTCHA_SECRET_KEY = trim((string)($rcCfg['secret'] ?? envget('RECAPTCHA_SECRET_KEY') ?? ''));
|
||||
|
||||
// Considère la clé "valide" si longueur réaliste (>= 20) – évite les faux négatifs
|
||||
$HAS_SITE = (strlen($RECAPTCHA_SITE_KEY) >= 20);
|
||||
$HAS_SECRET = (strlen($RECAPTCHA_SECRET_KEY) >= 20);
|
||||
|
||||
// (optionnel) forcer l’affichage en test: /contact.php?testcaptcha=1
|
||||
if (($_GET['testcaptcha'] ?? '') === '1') $HAS_SITE = true;
|
||||
|
||||
/* ---------- État page ---------- */
|
||||
$errors = [];
|
||||
$sent = false;
|
||||
|
||||
/* ---------- POST ---------- */
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
|
||||
|
||||
// CSRF
|
||||
if (!hash_equals($_SESSION['pub_csrf'] ?? '', $_POST['csrf'] ?? '')) {
|
||||
$errors[] = t($translations, 'contact_err_security', 'Security error. Refresh and try again.');
|
||||
}
|
||||
|
||||
// Honeypot anti-bot
|
||||
if (!empty($_POST['website'] ?? '')) {
|
||||
$errors[] = t($translations, 'contact_err_spam', 'Spam suspected.');
|
||||
}
|
||||
|
||||
// Données utilisateur
|
||||
$prenom = trim($_POST['prenom'] ?? '');
|
||||
$nom = trim($_POST['nom'] ?? '');
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$sujet = trim($_POST['sujet'] ?? '');
|
||||
$message = trim($_POST['message'] ?? '');
|
||||
|
||||
// Validations
|
||||
if (mb_strlen($prenom) < 2) $errors[] = t($translations, 'contact_err_prenom', 'Please enter your first name.');
|
||||
if (mb_strlen($nom) < 2) $errors[] = t($translations, 'contact_err_nom', 'Please enter your last name.');
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = t($translations, 'contact_err_email', 'Please enter a valid email.');
|
||||
if (mb_strlen($sujet) < 3) $errors[] = t($translations, 'contact_err_sujet', 'Subject is too short.');
|
||||
if (mb_strlen($message) < 10) $errors[] = t($translations, 'contact_err_message', 'Message is too short.');
|
||||
|
||||
// reCAPTCHA serveur
|
||||
if ($HAS_SECRET) {
|
||||
$token = $_POST['g-recaptcha-response'] ?? '';
|
||||
if ($token === '') {
|
||||
$errors[] = t($translations, 'contact_err_captcha_required', 'Please confirm you are not a robot.');
|
||||
} else {
|
||||
$resp = @file_get_contents(
|
||||
'https://www.google.com/recaptcha/api/siteverify?secret=' . urlencode($RECAPTCHA_SECRET_KEY) .
|
||||
'&response=' . urlencode($token) .
|
||||
'&remoteip=' . urlencode($_SERVER['REMOTE_ADDR'] ?? '')
|
||||
);
|
||||
$ver = $resp ? json_decode($resp, true) : ['success' => false];
|
||||
if (empty($ver['success'])) $errors[] = t($translations, 'contact_err_captcha_failed', 'reCAPTCHA verification failed.');
|
||||
}
|
||||
}
|
||||
|
||||
// Envoi
|
||||
if (!$errors) {
|
||||
$to = 'contact@uslugi.fr';
|
||||
|
||||
$subjectPrefix = t($translations, 'contact_send_subject_prefix', 'Contact form message — ');
|
||||
$subject = $subjectPrefix . $sujet;
|
||||
|
||||
$mailH3 = t($translations, 'contact_mail_h3', 'New message from uslugi.fr');
|
||||
$lblName = t($translations, 'contact_mail_label_name', 'Name');
|
||||
$lblEmail = t($translations, 'contact_mail_label_email', 'Email');
|
||||
$lblSubject = t($translations, 'contact_mail_label_subject', 'Subject');
|
||||
$lblMessage = t($translations, 'contact_mail_label_message', 'Message');
|
||||
|
||||
$html = '
|
||||
<h3>'.htmlspecialchars($mailH3, ENT_QUOTES, 'UTF-8').'</h3>
|
||||
<p><strong>'.htmlspecialchars($lblName, ENT_QUOTES, 'UTF-8').':</strong> '.htmlspecialchars($prenom).' '.htmlspecialchars($nom).'</p>
|
||||
<p><strong>'.htmlspecialchars($lblEmail, ENT_QUOTES, 'UTF-8').':</strong> '.htmlspecialchars($email).'</p>
|
||||
<p><strong>'.htmlspecialchars($lblSubject, ENT_QUOTES, 'UTF-8').':</strong> '.htmlspecialchars($sujet).'</p>
|
||||
<p><strong>'.htmlspecialchars($lblMessage, ENT_QUOTES, 'UTF-8').':</strong><br>'.nl2br(htmlspecialchars($message)).'</p>
|
||||
';
|
||||
|
||||
$text = $mailH3 . "\n"
|
||||
. $lblName . ": $prenom $nom\n"
|
||||
. $lblEmail . ": $email\n"
|
||||
. $lblSubject . ": $sujet\n\n"
|
||||
. $lblMessage . ":\n$message\n";
|
||||
|
||||
try {
|
||||
// From et Reply-To (tableaux) comme attendus par ton wrapper
|
||||
$fromEmail = 'no-reply@uslugi.fr';
|
||||
$fromName = 'USLUGI.FR';
|
||||
|
||||
$replyEmail = filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : 'contact@uslugi.fr';
|
||||
$replyName = trim($prenom.' '.$nom) ?: 'USLUGI.FR';
|
||||
|
||||
// Appel wrapper: retourne [bool $ok, ?string $err]
|
||||
[$ok, $err] = uslugi_mail(
|
||||
$to,
|
||||
$subject,
|
||||
$html,
|
||||
$text,
|
||||
[
|
||||
'from' => [$fromEmail, $fromName],
|
||||
'reply_to' => [$replyEmail, $replyName],
|
||||
// 'bcc' => ['tonmail@gmail.com'],
|
||||
]
|
||||
);
|
||||
|
||||
if ($ok !== true) {
|
||||
throw new RuntimeException('Wrapper mail send failed.');
|
||||
}
|
||||
|
||||
// OK
|
||||
$sent = true;
|
||||
$_POST = [];
|
||||
@file_put_contents($LOG_FILE, sprintf(
|
||||
"[%s] OK to=%s subj=%s reply=%s ip=%s\n",
|
||||
date('c'), $to, $sujet, $email, $_SERVER['REMOTE_ADDR'] ?? '-'
|
||||
), FILE_APPEND);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('MAIL ERROR: ' . $e->getMessage());
|
||||
@file_put_contents($LOG_FILE, sprintf(
|
||||
"[%s] ERR to=%s subj=%s\n",
|
||||
date('c'), $to, $sujet
|
||||
), FILE_APPEND);
|
||||
|
||||
// Message utilisateur générique (jamais d’info sensible à l’écran)
|
||||
$errors[] = t($translations, 'contact_err_send_failed', 'Failed to send message. Please try later.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lang HTML
|
||||
$langHtml = preg_match('~^[a-z]{2}(-[A-Z]{2})?$~', (string)($lang ?? '')) ? (string)$lang : 'ru';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?= htmlspecialchars($langHtml, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><?= htmlspecialchars($translations['seo_contact_title'], ENT_QUOTES, 'UTF-8') ?></title>
|
||||
<meta name="description" content="<?= htmlspecialchars($translations['seo_contact_desc'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta name="keywords" content="<?= htmlspecialchars($translations['seo_contact_keywords'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta name="author" content="Uslugi.fr">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
|
||||
<link rel="stylesheet" href="<?= asset('/includes/site_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/custom_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/mobil_style.css') ?>">
|
||||
<style>
|
||||
/* HERO bleu et cadre */
|
||||
.hero-contact{ background:#8CA9FF; padding: clamp(16px,4vw,32px); }
|
||||
.contact-wrap{ max-width:980px; margin:0 auto; font-family:"Montserrat",system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif; }
|
||||
.contact-frame{
|
||||
background:#fff; border:1px solid #DFE4FF; border-radius:22px;
|
||||
box-shadow:0 16px 40px rgba(56,55,139,.14);
|
||||
padding:clamp(16px,3vw,28px);
|
||||
}
|
||||
.contact h1{ font-size:clamp(24px,5.6vw,34px); margin:0 0 10px; }
|
||||
.contact p.muted{ color:#6b7280; margin:0 0 14px; }
|
||||
/* Forme */
|
||||
.form-label{ font-weight:600; }
|
||||
.form-control{ border-radius:12px; padding:12px 14px; }
|
||||
textarea.form-control{ min-height: 180px; }
|
||||
/* Bouton rouge arrondi */
|
||||
.btn-red{
|
||||
--bs-btn-bg:#E03C3F; --bs-btn-border-color:#E03C3F;
|
||||
--bs-btn-hover-bg:#c53637; --bs-btn-hover-border-color:#c53637;
|
||||
--bs-btn-active-bg:#a62e2f; --bs-btn-active-border-color:#a62e2f;
|
||||
--bs-btn-hover-color:#fff;
|
||||
color:#fff; border-radius:9999px; padding:12px 22px;
|
||||
}
|
||||
</style>
|
||||
<?php include_once __DIR__ . '/includes/tracking.php'; ?>
|
||||
</head>
|
||||
<body>
|
||||
<?php if (is_client_logged_in()): ?>
|
||||
<?php
|
||||
require_once __DIR__ . '/public/includes/init_pr_cl.php';
|
||||
include_header_clean(__DIR__ . '/public/includes/header.php'); ?>
|
||||
<?php else: ?>
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo_u.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-mobile"></a>
|
||||
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-desktop"></a>
|
||||
</div>
|
||||
<div class="lang-switch">
|
||||
<?php require __DIR__ . '/includes/menu_langue.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-actions">
|
||||
<a class="btn-login" href="/connexion.php">
|
||||
<span class="d-sm-none"><?php echo htmlspecialchars($translations['btn_se_connecter'] ?? 'Connexion', ENT_QUOTES, 'UTF-8')?></span>
|
||||
<span class="d-none d-sm-inline"><?php echo htmlspecialchars($translations['ja_spec'] ?? 'Je suis spécialiste', ENT_QUOTES, 'UTF-8')?></span>
|
||||
</a>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-logo rounded-circle d-inline-flex align-items-center justify-content-center question-btn"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="true"
|
||||
aria-expanded="false"
|
||||
aria-label="<?php echo htmlspecialchars(t($translations, 'howitworks_help_aria', 'Help'), ENT_QUOTES, 'UTF-8')?>">
|
||||
?
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow help-menu menu-panel">
|
||||
<li><a href="/how-it-work.php"><?php echo htmlspecialchars($translations['kak_rabotaet'] ?? 'Как это работает', ENT_QUOTES, 'UTF-8')?></a></li>
|
||||
<li><a href="/about.php"><?php echo htmlspecialchars($translations['pro_nas'] ?? 'О нас', ENT_QUOTES, 'UTF-8')?></a></li>
|
||||
<li><a href="/faq.php"><?php echo htmlspecialchars($translations['faq'] ?? 'FAQ', ENT_QUOTES, 'UTF-8')?></a></li>
|
||||
<li><a href="/contact.php"><?php echo htmlspecialchars($translations['nam_pisat'] ?? 'Написать нам', ENT_QUOTES, 'UTF-8')?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<?php endif; ?>
|
||||
|
||||
<main class="contact">
|
||||
<section class="hero-contact">
|
||||
<div class="container contact-wrap">
|
||||
<div class="contact-frame">
|
||||
<h1><?= htmlspecialchars(t($translations, 'contact_h1', 'Contact us'), ENT_QUOTES, 'UTF-8') ?></h1>
|
||||
<p class="muted"><?= htmlspecialchars(t($translations, 'contact_intro', 'Fill the form — we will reply as soon as possible.'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
|
||||
<?php if (!empty($errors)): ?>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<?= implode('<br>', array_map(static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'), $errors)) ?>
|
||||
</div>
|
||||
<?php elseif ($sent): ?>
|
||||
<div class="alert alert-success" role="alert">
|
||||
<?= htmlspecialchars(t($translations, 'contact_success_sent', 'Thank you! Your message has been sent.'), ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="" novalidate>
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars($CSRF, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<!-- Honeypot invisible -->
|
||||
<input type="text" name="website" autocomplete="off" tabindex="-1"
|
||||
style="position:absolute;left:-9999px;opacity:0" aria-hidden="true">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= htmlspecialchars(t($translations, 'contact_label_nom', 'Last name'), ENT_QUOTES, 'UTF-8') ?></label>
|
||||
<input type="text" name="nom" class="form-control"
|
||||
value="<?= htmlspecialchars($_POST['nom'] ?? '', ENT_QUOTES, 'UTF-8') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= htmlspecialchars(t($translations, 'contact_label_prenom', 'First name'), ENT_QUOTES, 'UTF-8') ?></label>
|
||||
<input type="text" name="prenom" class="form-control"
|
||||
value="<?= htmlspecialchars($_POST['prenom'] ?? '', ENT_QUOTES, 'UTF-8') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= htmlspecialchars(t($translations, 'contact_label_email', 'Email'), ENT_QUOTES, 'UTF-8') ?></label>
|
||||
<input type="email" name="email" class="form-control" inputmode="email"
|
||||
placeholder="<?= htmlspecialchars(t($translations, 'contact_email_placeholder', 'name@example.com'), ENT_QUOTES, 'UTF-8') ?>"
|
||||
value="<?= htmlspecialchars($_POST['email'] ?? '', ENT_QUOTES, 'UTF-8') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= htmlspecialchars(t($translations, 'contact_label_sujet', 'Subject'), ENT_QUOTES, 'UTF-8') ?></label>
|
||||
<input type="text" name="sujet" class="form-control"
|
||||
value="<?= htmlspecialchars($_POST['sujet'] ?? '', ENT_QUOTES, 'UTF-8') ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label"><?= htmlspecialchars(t($translations, 'contact_label_message', 'Message'), ENT_QUOTES, 'UTF-8') ?></label>
|
||||
<textarea name="message" class="form-control" required><?= htmlspecialchars($_POST['message'] ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
|
||||
</div>
|
||||
|
||||
<?php if ($HAS_SITE): ?>
|
||||
<div class="col-12">
|
||||
<div class="g-recaptcha" data-sitekey="<?= htmlspecialchars($RECAPTCHA_SITE_KEY, ENT_QUOTES, 'UTF-8') ?>"></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="col-12 d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-red">
|
||||
<?= htmlspecialchars(t($translations, 'contact_btn_submit', 'Send'), ENT_QUOTES, 'UTF-8') ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?= $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<?php if ($HAS_SITE): ?>
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
<?php endif; ?>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+11798
File diff suppressed because it is too large.
Load diff
Vendored
+1
@@ -0,0 +1 @@
|
||||
:root{--swal2-outline: 0 0 0 3px rgba(100, 150, 200, 0.5);--swal2-container-padding: 0.625em;--swal2-backdrop: rgba(0, 0, 0, 0.4);--swal2-backdrop-transition: background-color 0.15s;--swal2-width: 32em;--swal2-padding: 0 0 1.25em;--swal2-border: none;--swal2-border-radius: 0.3125rem;--swal2-background: white;--swal2-color: #545454;--swal2-show-animation: swal2-show 0.3s;--swal2-hide-animation: swal2-hide 0.15s forwards;--swal2-icon-zoom: 1;--swal2-icon-animations: true;--swal2-title-padding: 0.8em 1em 0;--swal2-html-container-padding: 1em 1.6em 0.3em;--swal2-input-border: 1px solid #d9d9d9;--swal2-input-border-radius: 0.1875em;--swal2-input-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.06), 0 0 0 3px transparent;--swal2-input-background: transparent;--swal2-input-transition: border-color 0.2s, box-shadow 0.2s;--swal2-input-hover-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.06), 0 0 0 3px transparent;--swal2-input-focus-border: 1px solid #b4dbed;--swal2-input-focus-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.06), 0 0 0 3px rgba(100, 150, 200, 0.5);--swal2-progress-step-background: #add8e6;--swal2-validation-message-background: #f0f0f0;--swal2-validation-message-color: #666;--swal2-footer-border-color: #eee;--swal2-footer-background: transparent;--swal2-footer-color: inherit;--swal2-timer-progress-bar-background: rgba(0, 0, 0, 0.3);--swal2-close-button-position: initial;--swal2-close-button-inset: auto;--swal2-close-button-font-size: 2.5em;--swal2-close-button-color: #ccc;--swal2-close-button-transition: color 0.2s, box-shadow 0.2s;--swal2-close-button-outline: initial;--swal2-close-button-box-shadow: inset 0 0 0 3px transparent;--swal2-close-button-focus-box-shadow: inset var(--swal2-outline);--swal2-close-button-hover-transform: none;--swal2-actions-justify-content: center;--swal2-actions-width: auto;--swal2-actions-margin: 1.25em auto 0;--swal2-actions-padding: 0;--swal2-actions-border-radius: 0;--swal2-actions-background: transparent;--swal2-action-button-transition: background-color 0.2s, box-shadow 0.2s;--swal2-action-button-hover: black 10%;--swal2-action-button-active: black 10%;--swal2-confirm-button-box-shadow: none;--swal2-confirm-button-border-radius: 0.25em;--swal2-confirm-button-background-color: #7066e0;--swal2-confirm-button-color: #fff;--swal2-deny-button-box-shadow: none;--swal2-deny-button-border-radius: 0.25em;--swal2-deny-button-background-color: #dc3741;--swal2-deny-button-color: #fff;--swal2-cancel-button-box-shadow: none;--swal2-cancel-button-border-radius: 0.25em;--swal2-cancel-button-background-color: #6e7881;--swal2-cancel-button-color: #fff;--swal2-toast-show-animation: swal2-toast-show 0.5s;--swal2-toast-hide-animation: swal2-toast-hide 0.1s forwards;--swal2-toast-border: none;--swal2-toast-box-shadow: 0 0 1px hsl(0deg 0% 0% / 0.075), 0 1px 2px hsl(0deg 0% 0% / 0.075), 1px 2px 4px hsl(0deg 0% 0% / 0.075), 1px 3px 8px hsl(0deg 0% 0% / 0.075), 2px 4px 16px hsl(0deg 0% 0% / 0.075)}[data-swal2-theme=dark]{--swal2-dark-theme-black: #19191a;--swal2-dark-theme-white: #e1e1e1;--swal2-background: var(--swal2-dark-theme-black);--swal2-color: var(--swal2-dark-theme-white);--swal2-footer-border-color: #555;--swal2-input-background: color-mix(in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10%);--swal2-validation-message-background: color-mix( in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10% );--swal2-validation-message-color: var(--swal2-dark-theme-white);--swal2-timer-progress-bar-background: rgba(255, 255, 255, 0.7)}@media(prefers-color-scheme: dark){[data-swal2-theme=auto]{--swal2-dark-theme-black: #19191a;--swal2-dark-theme-white: #e1e1e1;--swal2-background: var(--swal2-dark-theme-black);--swal2-color: var(--swal2-dark-theme-white);--swal2-footer-border-color: #555;--swal2-input-background: color-mix(in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10%);--swal2-validation-message-background: color-mix( in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10% );--swal2-validation-message-color: var(--swal2-dark-theme-white);--swal2-timer-progress-bar-background: rgba(255, 255, 255, 0.7)}}body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown){overflow:hidden}body.swal2-height-auto{height:auto !important}body.swal2-no-backdrop .swal2-container{background-color:rgba(0,0,0,0) !important;pointer-events:none}body.swal2-no-backdrop .swal2-container .swal2-popup{pointer-events:all}body.swal2-no-backdrop .swal2-container .swal2-modal{box-shadow:0 0 10px var(--swal2-backdrop)}body.swal2-toast-shown .swal2-container{box-sizing:border-box;width:360px;max-width:100%;background-color:rgba(0,0,0,0);pointer-events:none}body.swal2-toast-shown .swal2-container.swal2-top{inset:0 auto auto 50%;transform:translateX(-50%)}body.swal2-toast-shown .swal2-container.swal2-top-end,body.swal2-toast-shown .swal2-container.swal2-top-right{inset:0 0 auto auto}body.swal2-toast-shown .swal2-container.swal2-top-start,body.swal2-toast-shoLine truncated
|
||||
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
require_once APP_HTDOCS . '/includes/avatar_initiales.php';
|
||||
$SPEC_CANON = require __DIR__ . '/includes/spec_map.php';
|
||||
|
||||
$canon = $SPEC_CANON[$lang] ?? [];
|
||||
|
||||
|
||||
|
||||
// ------- (ton code existant) -------
|
||||
$praticien_id = (int)($_GET['id'] ?? 0);
|
||||
if ($praticien_id <= 0) { echo "<div class='container py-5'>ID invalide.</div>"; exit; }
|
||||
|
||||
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
||||
function percent_from_5($note){ $n = max(0,min(5,(float)$note)); return (int)round($n/5*100); }
|
||||
function pseudo_from($prenom,$nom){
|
||||
$p = trim((string)$prenom); $n = trim((string)$nom);
|
||||
$p = $p!=='' ? mb_convert_case($p, MB_CASE_TITLE, 'UTF-8') : 'Client';
|
||||
$i = $n!=='' ? mb_strtoupper(mb_substr($nom,0,1,'UTF-8'),'UTF-8') : '';
|
||||
return $i ? ($p.'.'.$i) : $p;
|
||||
}
|
||||
function video_thumb_info(?string $url): ?array {
|
||||
if (!$url || !trim($url)) return null; $u = trim($url);
|
||||
if (preg_match('~(?:youtube\.com/(?:watch\?v=|embed/|shorts/)|youtu\.be/)([A-Za-z0-9_-]{6,})~i',$u,$m)){
|
||||
$id=$m[1]; return ['provider'=>'youtube','id'=>$id,'thumb'=>"https://i.ytimg.com/vi/$id/hqdefault.jpg"];
|
||||
}
|
||||
if (preg_match('~dailymotion\.com/video/([A-Za-z0-9]+)|dai\.ly/([A-Za-z0-9]+)~i',$u,$m)){
|
||||
$id=$m[1] ?: $m[2]; return ['provider'=>'dailymotion','id'=>$id,'thumb'=>"https://www.dailymotion.com/thumbnail/video/$id"];
|
||||
}
|
||||
$path=parse_url($u,PHP_URL_PATH)??''; if(preg_match('~\.(mp4|webm|ogg)$~i',$path)){ return ['provider'=>'file','id'=>null,'thumb'=>null]; }
|
||||
if (stripos($u,'vimeo.com')!==false){ return ['provider'=>'vimeo','id'=>null,'thumb'=>null]; }
|
||||
return ['provider'=>'other','id'=>null,'thumb'=>null];
|
||||
}
|
||||
|
||||
// 1) Praticien
|
||||
$st = $pdo->prepare("SELECT id, titre, nom, prenom, telephone, adresse, ville, code_postal, specialite, presentation, photo, video, disponibilite
|
||||
FROM praticien WHERE id = ?");
|
||||
$st->execute([$praticien_id]);
|
||||
$p = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$p) { echo "<div class='container py-5'>".$translations['praticien_introuvable']."</div>"; exit; }
|
||||
|
||||
$name = $name = trim($p['prenom'] . ' ' . $p['nom']);
|
||||
$photo = !empty($p['photo']) ? "/public/uploads/".ltrim($p['photo'],'/') : "/public/uploads/img/user_placeholder.png";
|
||||
|
||||
// 2) Vidéos additionnelles
|
||||
$vs = $pdo->prepare("SELECT id, url, title, ordre FROM praticien_video WHERE praticien_id=? ORDER BY ordre ASC, id DESC");
|
||||
$vs->execute([$praticien_id]);
|
||||
$videos = $vs->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// 3) Avis: moyenne + total + dernier
|
||||
$avg = $pdo->prepare("SELECT ROUND(AVG(note),1) AS m, COUNT(*) AS c FROM avis_praticien WHERE praticien_id=? AND statut='publie'");
|
||||
$avg->execute([$praticien_id]);
|
||||
$stats = $avg->fetch(PDO::FETCH_ASSOC) ?: ['m'=>null,'c'=>0];
|
||||
|
||||
$last = $pdo->prepare("SELECT a.note,a.titre,a.commentaire,a.visit_date,a.created_at,c.prenom,c.nom
|
||||
FROM avis_praticien a
|
||||
LEFT JOIN client c ON c.id=a.client_id
|
||||
WHERE a.praticien_id=? AND a.statut='publie'
|
||||
ORDER BY a.created_at DESC LIMIT 1");
|
||||
$last->execute([$praticien_id]);
|
||||
$lrow = $last->fetch(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?= $name . " - " . $p['specialite'] . " - " . htmlspecialchars($translations['seo_detail_public_title'], ENT_QUOTES, 'UTF-8')?></title>
|
||||
<meta name="description" content="<?= $name . " - " . $p['specialite'] . " - " . $p['presentation'] ?>">
|
||||
<meta name="keywords" content="<?= htmlspecialchars($translations['seo_detail_public_keywords'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta name="author" content="Uslugi.fr">
|
||||
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<link href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/site_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/custom_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/mobil_style.css') ?>">
|
||||
<?php include_once __DIR__ . '/includes/tracking.php'; ?>
|
||||
</head>
|
||||
<body class="layout">
|
||||
|
||||
<?php if ($logged):
|
||||
include_header_clean(APP_HTDOCS . '/public/includes/header.php');
|
||||
else: ?>
|
||||
<!-- Header public (Connexion + “?”) -->
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo_u.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-mobile"></a>
|
||||
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-desktop"></a>
|
||||
</div>
|
||||
<div class="lang-switch">
|
||||
<?php require __DIR__ . '/includes/menu_langue.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-actions">
|
||||
<a class="btn-login" href="/connexion.php">
|
||||
<span class="d-sm-none"><?php echo $translations['btn_se_connecter']?></span>
|
||||
<span class="d-none d-sm-inline"><?php echo $translations['ja_spec']?></span>
|
||||
</a>
|
||||
<!--<a class="btn-login" href="/connexion.php"><?php echo $translations['ja_spec']?></a>-->
|
||||
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-logo rounded-circle d-inline-flex align-items-center justify-content-center question-btn"
|
||||
type="button" data-bs-toggle="dropdown" data-bs-auto-close="true"
|
||||
aria-expanded="false" aria-label="Помощь">?</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow help-menu menu-panel">
|
||||
<li><a href="/how-it-work.php"><?= $translations['kak_rabotaet'] ?></a></a></li>
|
||||
<li><a href="/about.php"><?= $translations['pro_nas'] ?></a></li>
|
||||
<li><a href="/faq.php"><?= $translations['faq'] ?></a></li>
|
||||
<li><a href="/contact.php"><?= $translations['nam_pisat'] ?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="main">
|
||||
<div class="container py-4">
|
||||
<div class="profile-card">
|
||||
<div class="profile-grid profile-top">
|
||||
<!-- Cadre 1 : Infos -->
|
||||
<section class="panel">
|
||||
<div class="info-scroll">
|
||||
<h2 class="pro-name"><?= h($name) ?></h2>
|
||||
<?php if($p['specialite']): ?><div><strong><?= h($translations['dp_specialite']) ?>:</strong> <?= h($p['specialite']) ?></div><?php endif; ?>
|
||||
<!--<?php if($p['telephone']): ?><div><strong><?= h($translations['dp_telephone']) ?>:</strong> <?= h($p['telephone']) ?></div><?php endif; ?>-->
|
||||
<?php if($p['titre']): ?><div><strong><?= h($translations['tarif_consultation']) ?>:</strong> <?= h($p['titre']) ?></div><?php endif; ?>
|
||||
<?php if($p['disponibilite']): ?><div><strong><?= h($translations['moyens_payement']) ?>:</strong> <?= h($p['disponibilite']) ?></div><?php endif; ?>
|
||||
<?php if($p['adresse']): ?><div><strong><?= h($translations['dp_adresse']) ?>:</strong> <?= nl2br(h($p['adresse'])) ?></div><?php endif; ?>
|
||||
<?php if($p['ville'] || $p['code_postal']): ?><div><strong><?= h($translations['dp_ville_cp']) ?>:</strong> <?= h(trim(($p['ville']??'').' '.($p['code_postal']??''))) ?></div><?php endif; ?>
|
||||
<?php if($p['presentation']): ?><div class="mt-3"><strong><?= h($translations['dp_description']) ?>:</strong><br><?= nl2br(h($p['presentation'])) ?></div><?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Centre : grande photo -->
|
||||
<section class="big-photo">
|
||||
<img class="img-fluid w-100" src="<?= h($photo) ?>" alt="Photo">
|
||||
</section>
|
||||
|
||||
<!-- Cadre 2 : Vidéos -->
|
||||
<?php if($p['video'] != ''){ ?>
|
||||
<aside class="panel">
|
||||
<h2 class="pro-name"><?= h($translations['dp_videos']) ?></h2>
|
||||
<div class="vlist">
|
||||
<?php
|
||||
$alt = h($translations['video_alt'] ?? 'Vidéo');
|
||||
$printed = false;
|
||||
|
||||
// Petite fonction d'affichage d’une vignette
|
||||
$printThumb = function($provider, $id, $thumb, $rawUrl) use ($alt){
|
||||
if (in_array($provider, ['youtube','dailymotion']) && $id){
|
||||
echo '<a href="#" class="vitem d-block pro-video"
|
||||
data-bs-toggle="modal" data-bs-target="#videoModal"
|
||||
data-provider="'.h($provider).'" data-id="'.h($id).'">';
|
||||
echo $thumb
|
||||
? '<img src="'.h($thumb).'" alt="'.$alt.'" loading="lazy">'
|
||||
: '<img src="/public/uploads/img/video_placeholder.jpg" alt="'.$alt.'" loading="lazy">';
|
||||
echo '</a>';
|
||||
} else {
|
||||
$safe = h($rawUrl);
|
||||
echo '<a class="vitem d-block" href="'.$safe.'" target="_blank" rel="noopener">';
|
||||
echo $thumb
|
||||
? '<img src="'.h($thumb).'" alt="'.$alt.'" loading="lazy">'
|
||||
: '<img src="/public/uploads/img/video_placeholder.jpg" alt="'.$alt.'" loading="lazy">';
|
||||
echo '</a>';
|
||||
}
|
||||
};
|
||||
|
||||
if (!empty($p['video'])) {
|
||||
$vi = video_thumb_info($p['video']);
|
||||
$thumb = $vi['thumb'] ?? null;
|
||||
$prov = $vi['provider'] ?? null;
|
||||
$vid = $vi['id'] ?? null;
|
||||
$printThumb($prov, $vid, $thumb, $p['video']);
|
||||
$printed = true;
|
||||
}
|
||||
foreach ($videos as $v) {
|
||||
$vi = video_thumb_info($v['url']);
|
||||
$thumb = $vi['thumb'] ?? null;
|
||||
$prov = $vi['provider'] ?? null;
|
||||
$vid = $vi['id'] ?? null;
|
||||
$printThumb($prov, $vid, $thumb, $v['url']);
|
||||
$printed = true;
|
||||
}
|
||||
if (!$printed) {
|
||||
echo '<div class="vitem"><img src="/public/uploads/img/video_placeholder.jpg" alt="'.$alt.'"></div>';
|
||||
//echo '<div class="text-muted small mt-1">'.h($translations['dp_aucune_video']).'</div>';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</aside>
|
||||
<?php } ?>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Ligne du bas : Note / Dernier avis / Bouton RDV -->
|
||||
<div class="bottom-grid mt-4">
|
||||
<!-- Cadre 3 : Note -->
|
||||
<div class="card shadow-sm p-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<?php $m=(float)($stats['m'] ?? 0); $c=(int)($stats['c'] ?? 0); $w=percent_from_5($m); ?>
|
||||
<div class="rating" aria-label="Note moyenne">
|
||||
<div class="stars-base">★★★★★</div>
|
||||
<div class="stars-fill" style="width:<?= $w ?>%">★★★★★</div>
|
||||
</div>
|
||||
<div>
|
||||
<div><strong><?= h($translations['avis_note_moy']) ?>:</strong> <?= $m ? number_format($m,1,',',' ') : '—' ?>/5</div>
|
||||
<div class="text-muted">(<?= $c.' '.h($translations['avis_total']) ?>)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cadre 4 : Dernier avis -->
|
||||
<div class="card shadow-sm p-3 avis-card">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<strong><?= h($translations['avis_dernier']) ?></strong>
|
||||
<a class="btn btn-link p-0" href="/avis_public.php?praticien_id=<?= (int)$praticien_id ?>">
|
||||
<?= h($translations['avis_lire_tous']) ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php if ($lrow): ?>
|
||||
<?php $lw=percent_from_5((float)$lrow['note']); ?>
|
||||
<div class="rating mb-1">
|
||||
<div class="stars-base">★★★★★</div>
|
||||
<div class="stars-fill" style="width:<?= $lw ?>%">★★★★★</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<?= h(pseudo_from($lrow['prenom'] ?? '', $lrow['nom'] ?? '')) ?>
|
||||
<?php if(!empty($lrow['visit_date'])): ?> • <?= date('d/m/Y', strtotime($lrow['visit_date'])) ?><?php endif; ?>
|
||||
</div>
|
||||
<div><?= nl2br(h(mb_strimwidth((string)$lrow['commentaire'],0,320,'…','UTF-8'))) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="text-muted"><?= h($translations['avis_aucun']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Bouton RDV -->
|
||||
<div class="text-end">
|
||||
<a class="btn btn-danger btn-lg" href="/public/client/reserver_rdv.php?praticien_id=<?= (int)$praticien_id ?>">
|
||||
<?= h($translations['dp_btn_rdv']) ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal vidéo -->
|
||||
<div class="modal fade" id="videoModal" tabindex="-1" aria-labelledby="videoModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-xl">
|
||||
<div class="modal-content border-0 shadow-lg rounded-4">
|
||||
<div class="modal-header border-0">
|
||||
<h5 class="modal-title" id="videoModalLabel"><?= h($translations['video_titre'] ?? 'Vidéo') ?></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?= h($translations['fermer'] ?? 'Fermer') ?>"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-0">
|
||||
<div class="ratio ratio-16x9 rounded-3 overflow-hidden bg-black">
|
||||
<iframe id="vPlayer" src="" title="Video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<?= h($translations['fermer'] ?? 'Fermer') ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
const modal = document.getElementById('videoModal');
|
||||
const iframe = document.getElementById('vPlayer');
|
||||
|
||||
function buildEmbed(provider, id){
|
||||
if (!provider || !id) return '';
|
||||
if (provider === 'youtube')
|
||||
return 'https://www.youtube-nocookie.com/embed/' + id + '?autoplay=1&rel=0&modestbranding=1';
|
||||
if (provider === 'dailymotion')
|
||||
return 'https://www.dailymotion.com/embed/video/' + id + '?autoplay=1&ui-start-screen-info=false';
|
||||
return '';
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e){
|
||||
const a = e.target.closest('a.pro-video[data-provider][data-id]');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
const src = buildEmbed(a.dataset.provider, a.dataset.id);
|
||||
if (src) iframe.src = src;
|
||||
}, false);
|
||||
|
||||
modal.addEventListener('hidden.bs.modal', function(){
|
||||
iframe.src = '';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?= $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
|
||||
// ===== Helpers header (identiques à index.php) =====
|
||||
function is_client_logged_in(): bool
|
||||
{
|
||||
$role = $_SESSION['role'] ?? ($_SESSION['user_type'] ?? '');
|
||||
if (! empty($_SESSION['user_id']) && $role === 'client') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! empty($_SESSION['client_id'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! empty($_SESSION['reference_id']) && $role === 'client') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! empty($_SESSION['user_id']) && $role === 'praticien') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! empty($_SESSION['praticien_id'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! empty($_SESSION['reference_id']) && $role === 'praticien') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function include_header_clean(string $absPath): void
|
||||
{
|
||||
global $translations, $lang;
|
||||
ob_start();
|
||||
include $absPath; // /public/includes/header.php
|
||||
$buf = ob_get_clean() ?: '';
|
||||
|
||||
// 0) DOCTYPE + <html>…</html>
|
||||
$buf = preg_replace('~<!doctype\s+html[^>]*>~i', '', $buf);
|
||||
$buf = preg_replace('~</?html\b[^>]*>~i', '', $buf);
|
||||
|
||||
// 1) retirer <head>…</head> et <body> ouverts/fermés
|
||||
$buf = preg_replace('~<head\b[^>]*>.*?</head>~is', '', $buf);
|
||||
$buf = preg_replace('~</?body\b[^>]*>~i', '', $buf);
|
||||
|
||||
// 2) retirer les <link> CSS “globaux” pour éviter d’écraser la home
|
||||
$buf = preg_replace('~<link[^>]+href="[^"]*(bootstrap[^"]*\.css|mobil_style\.css|site_style\.css|fonts\.googleapis)[^"]*"[^>]*>~i', '', $buf);
|
||||
|
||||
// 3) retirer le <script> Bootstrap CDN (on le charge déjà en bas de la page)
|
||||
$buf = preg_replace('~<script[^>]+src="[^"]*bootstrap[^"]*bundle[^"]*\.js[^"]*"[^>]*>\s*</script>~i', '', $buf);
|
||||
|
||||
echo $buf; // garde <header> + tes scripts inline utiles
|
||||
}
|
||||
|
||||
function t(array $translations, string $key, string $fallback = ''): string
|
||||
{
|
||||
$val = $translations[$key] ?? '';
|
||||
if ($val === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return (string) $val;
|
||||
}
|
||||
|
||||
// Lang HTML
|
||||
$langHtml = preg_match('~^[a-z]{2}(-[A-Z]{2})?$~', (string) ($lang ?? '')) ? (string) $lang : 'ru';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?php echo htmlspecialchars($langHtml, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><?php echo htmlspecialchars($translations['seo_how_it_work_title'], ENT_QUOTES, 'UTF-8') ?></title>
|
||||
<meta name="description" content="<?php echo htmlspecialchars($translations['seo_how_it_work_desc'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta name="keywords" content="<?php echo htmlspecialchars($translations['seo_how_it_work_keywords'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<meta name="author" content="Uslugi.fr">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
|
||||
<link rel="stylesheet" href="<?php echo asset('/includes/site_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?php echo asset('/includes/custom_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?php echo asset('/includes/mobil_style.css') ?>">
|
||||
<style>
|
||||
.faq-page { --brand:#38378B; --muted:#4b5563; --border:#e5e7eb; --card:#fff; }
|
||||
.faq-wrap { max-width: 980px; margin: 0 auto; padding: clamp(16px,4vw,32px); font-family: "Montserrat", system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; }
|
||||
.faq-card { background:var(--card); border:1px solid var(--border); border-radius:16px; box-shadow:0 8px 24px rgba(0,0,0,.06); padding: clamp(18px,3.6vw,28px); }
|
||||
h1 { font-size: clamp(26px, 5.5vw, 38px); margin: 0 0 12px; }
|
||||
.faq-intro { color: var(--muted); margin-bottom: 14px; }
|
||||
.faq-block { margin-top: 18px; }
|
||||
.faq-block h2 { font-size: clamp(18px, 3.6vw, 24px); margin: 0 0 10px; font-weight: 700; color: #38378B;}
|
||||
.faq-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
|
||||
.faq-item { border:1px solid var(--border); border-radius:12px; background:#fff; overflow:hidden; }
|
||||
|
||||
.faq-q{
|
||||
width: 100%; text-align: left; background: #fafafa; border: 0;
|
||||
padding: 12px 14px; display:flex; align-items:center; justify-content:space-between;
|
||||
font-weight:600; color:#1f2937; cursor:pointer;
|
||||
}
|
||||
.faq-q:focus { outline: 2px solid color-mix(in oklab, var(--brand) 45%, #fff); outline-offset: 2px; }
|
||||
.faq-q .chev { transition: transform .2s ease; margin-left: 10px; }
|
||||
.faq-q[aria-expanded="true"] .chev { transform: rotate(90deg); }
|
||||
|
||||
.faq-a { display:none; padding: 12px 14px; color:#111; }
|
||||
.faq-a p { margin:0 0 8px; color:#374151; line-height:1.65; }
|
||||
.faq-a a { color: var(--brand); text-decoration: none; border-bottom: 1px dashed color-mix(in oklab, var(--brand) 65%, #fff); }
|
||||
.faq-a a:hover { text-decoration: underline; }
|
||||
|
||||
.site-header .brand img { max-width: 130px; }
|
||||
.btn-pill { border-radius: 999px; }
|
||||
</style>
|
||||
|
||||
<?php include_once __DIR__ . '/includes/tracking.php'; ?>
|
||||
</head>
|
||||
<body>
|
||||
<?php if (is_client_logged_in()): ?>
|
||||
<?php
|
||||
require_once __DIR__ . '/public/includes/init_pr_cl.php';
|
||||
include_header_clean(__DIR__ . '/public/includes/header.php'); ?>
|
||||
<?php else: ?>
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo_u.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-mobile"></a>
|
||||
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-desktop"></a>
|
||||
</div>
|
||||
<div class="lang-switch">
|
||||
<?php require __DIR__ . '/includes/menu_langue.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-actions">
|
||||
<a class="btn-login" href="/connexion.php">
|
||||
<span class="d-sm-none"><?php echo htmlspecialchars($translations['btn_se_connecter'] ?? 'Connexion', ENT_QUOTES, 'UTF-8') ?></span>
|
||||
<span class="d-none d-sm-inline"><?php echo htmlspecialchars($translations['ja_spec'] ?? 'Je suis spécialiste', ENT_QUOTES, 'UTF-8') ?></span>
|
||||
</a>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-logo rounded-circle d-inline-flex align-items-center justify-content-center question-btn"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="true"
|
||||
aria-expanded="false"
|
||||
aria-label="<?php echo htmlspecialchars(t($translations, 'howitworks_help_aria', 'Help'), ENT_QUOTES, 'UTF-8') ?>">
|
||||
?
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow help-menu menu-panel">
|
||||
<li><a href="/how-it-work.php"><?php echo htmlspecialchars($translations['kak_rabotaet'] ?? 'Как это работает', ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
<li><a href="/about.php"><?php echo htmlspecialchars($translations['pro_nas'] ?? 'О нас', ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
<li><a href="/faq.php"><?php echo htmlspecialchars($translations['faq'] ?? 'FAQ', ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
<li><a href="/contact.php"><?php echo htmlspecialchars($translations['nam_pisat'] ?? 'Написать нам', ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<?php endif; ?>
|
||||
|
||||
<main class="faq-page">
|
||||
<div class="faq-wrap">
|
||||
<div class="faq-card">
|
||||
|
||||
<!--<h1><?php echo htmlspecialchars(t($translations, 'faq_title', 'FAQ'), ENT_QUOTES, 'UTF-8') ?></h1>
|
||||
<p class="faq-intro"><?php echo htmlspecialchars(t($translations, 'faq_intro', ''), ENT_QUOTES, 'UTF-8') ?></p>-->
|
||||
|
||||
<!-- ===== Block 1 ===== -->
|
||||
<section class="faq-block" id="block1">
|
||||
<h2><?php echo htmlspecialchars(t($translations, 'faq_block1_title', ''), ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
|
||||
<ul class="faq-list">
|
||||
<li class="faq-item">
|
||||
<button class="faq-q" aria-expanded="false" aria-controls="a1" id="q1">
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_q1', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="faq-a" id="a1" role="region" aria-labelledby="q1">
|
||||
<p><?php echo htmlspecialchars(t($translations, 'faq_a1', '...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="faq-item">
|
||||
<button class="faq-q" aria-expanded="false" aria-controls="a2" id="q2">
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_q2', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="faq-a" id="a2" role="region" aria-labelledby="q2">
|
||||
<p><?php echo htmlspecialchars(t($translations, 'faq_a2', '...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="faq-item">
|
||||
<button class="faq-q" aria-expanded="false" aria-controls="a3" id="q3">
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_q3', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="faq-a" id="a3" role="region" aria-labelledby="q3">
|
||||
<p><?php echo htmlspecialchars(t($translations, 'faq_a3', '...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="faq-item">
|
||||
<button class="faq-q" aria-expanded="false" aria-controls="a4" id="q4">
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_q4', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="faq-a" id="a4" role="region" aria-labelledby="q4">
|
||||
<p><?php echo htmlspecialchars(t($translations, 'faq_a4', '...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="faq-item">
|
||||
<button class="faq-q" aria-expanded="false" aria-controls="a5" id="q5">
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_q5', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="faq-a" id="a5" role="region" aria-labelledby="q5">
|
||||
<p><?php echo htmlspecialchars(t($translations, 'faq_a5', '...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="faq-item">
|
||||
<button class="faq-q" aria-expanded="false" aria-controls="a6" id="q6">
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_q6', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="faq-a" id="a6" role="region" aria-labelledby="q6">
|
||||
<p><?php echo htmlspecialchars(t($translations, 'faq_a6', '...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ===== Block 2 ===== -->
|
||||
<section class="faq-block" id="block2">
|
||||
<h2><?php echo htmlspecialchars(t($translations, 'faq_block2_title', ''), ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
|
||||
<ul class="faq-list">
|
||||
<li class="faq-item">
|
||||
<button class="faq-q" aria-expanded="false" aria-controls="b1" id="qb1">
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_q7', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="faq-a" id="b1" role="region" aria-labelledby="qb1">
|
||||
<p><?php echo htmlspecialchars(t($translations, 'faq_a7', '...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="faq-item">
|
||||
<button class="faq-q" aria-expanded="false" aria-controls="b2" id="qb2">
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_q8', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="faq-a" id="b2" role="region" aria-labelledby="qb2">
|
||||
<p>
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_a8_intro', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<a href="/topics/immigration.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_immigration', 'Immigration'), ENT_QUOTES, 'UTF-8') ?></a>,
|
||||
<a href="/topics/family.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_family', 'Family'), ENT_QUOTES, 'UTF-8') ?></a>,
|
||||
<a href="/topics/jobs.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_jobs', 'Jobs'), ENT_QUOTES, 'UTF-8') ?></a>,
|
||||
<a href="/topics/taxes.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_taxes', 'Taxes'), ENT_QUOTES, 'UTF-8') ?></a>,
|
||||
<a href="/topics/health.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_health', 'Health'), ENT_QUOTES, 'UTF-8') ?></a>
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_a8_and', 'and'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<a href="/topics/business.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_business', 'Business'), ENT_QUOTES, 'UTF-8') ?></a>.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="faq-item">
|
||||
<button class="faq-q" aria-expanded="false" aria-controls="b3" id="qb3">
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_q9', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="faq-a" id="b3" role="region" aria-labelledby="qb3">
|
||||
<p><?php echo htmlspecialchars(t($translations, 'faq_a9', '...'), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="faq-item">
|
||||
<button class="faq-q" aria-expanded="false" aria-controls="b4" id="qb4">
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_q10', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="faq-a" id="b4" role="region" aria-labelledby="qb4">
|
||||
<p>
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_a10_intro', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<a href="/topics/immigration.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_immigration', 'Immigration'), ENT_QUOTES, 'UTF-8') ?></a>,
|
||||
<a href="/topics/jobs.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_jobs', 'Jobs'), ENT_QUOTES, 'UTF-8') ?></a>
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_a10_or', 'or'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<a href="/topics/business.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_business', 'Business'), ENT_QUOTES, 'UTF-8') ?></a>.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="faq-item">
|
||||
<button class="faq-q" aria-expanded="false" aria-controls="b5" id="qb5">
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_q11', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="faq-a" id="b5" role="region" aria-labelledby="qb5">
|
||||
<p>
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_a11_intro', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<a href="/topics/taxes.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_taxes', 'Taxes'), ENT_QUOTES, 'UTF-8') ?></a>.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="faq-item">
|
||||
<button class="faq-q" aria-expanded="false" aria-controls="b6" id="qb6">
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_q12', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<span class="chev">›</span>
|
||||
</button>
|
||||
<div class="faq-a" id="b6" role="region" aria-labelledby="qb6">
|
||||
<p>
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_a12_intro', '...'), ENT_QUOTES, 'UTF-8') ?>
|
||||
<a href="/topics/immigration.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_immigration', 'Immigration'), ENT_QUOTES, 'UTF-8') ?></a>,
|
||||
<a href="/topics/family.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_family', 'Family'), ENT_QUOTES, 'UTF-8') ?></a>,
|
||||
<a href="/topics/jobs.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_jobs', 'Jobs'), ENT_QUOTES, 'UTF-8') ?></a>,
|
||||
<a href="/topics/taxes.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_taxes', 'Taxes'), ENT_QUOTES, 'UTF-8') ?></a>,
|
||||
<a href="/topics/health.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_health', 'Health'), ENT_QUOTES, 'UTF-8') ?></a>,
|
||||
<a href="/topics/business.php"><?php echo htmlspecialchars(t($translations, 'faq_topic_business', 'Business'), ENT_QUOTES, 'UTF-8') ?></a>.
|
||||
<?php echo htmlspecialchars(t($translations, 'faq_a12_outro', ''), ENT_QUOTES, 'UTF-8') ?>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="mt-3 d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-primary btn-pill" href="/index.php"><?php echo htmlspecialchars(t($translations, 'faq_btn_find', 'Find a specialist'), ENT_QUOTES, 'UTF-8') ?></a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?php echo $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('click', function(e){
|
||||
const btn = e.target.closest('.faq-q');
|
||||
if(!btn) return;
|
||||
|
||||
const expanded = btn.getAttribute('aria-expanded') === 'true';
|
||||
const panelId = btn.getAttribute('aria-controls');
|
||||
const panel = document.getElementById(panelId);
|
||||
if(!panel) return;
|
||||
|
||||
btn.setAttribute('aria-expanded', expanded ? 'false' : 'true');
|
||||
panel.style.display = expanded ? 'none' : 'block';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2024 The Montserrat.Git Project Authors (https://github.com/JulietaUla/Montserrat.git)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,81 @@
|
||||
Montserrat Variable Font
|
||||
========================
|
||||
|
||||
This download contains Montserrat as both variable fonts and static fonts.
|
||||
|
||||
Montserrat is a variable font with this axis:
|
||||
wght
|
||||
|
||||
This means all the styles are contained in these files:
|
||||
Montserrat-VariableFont_wght.ttf
|
||||
Montserrat-Italic-VariableFont_wght.ttf
|
||||
|
||||
If your app fully supports variable fonts, you can now pick intermediate styles
|
||||
that aren’t available as static fonts. Not all apps support variable fonts, and
|
||||
in those cases you can use the static font files for Montserrat:
|
||||
static/Montserrat-Thin.ttf
|
||||
static/Montserrat-ExtraLight.ttf
|
||||
static/Montserrat-Light.ttf
|
||||
static/Montserrat-Regular.ttf
|
||||
static/Montserrat-Medium.ttf
|
||||
static/Montserrat-SemiBold.ttf
|
||||
static/Montserrat-Bold.ttf
|
||||
static/Montserrat-ExtraBold.ttf
|
||||
static/Montserrat-Black.ttf
|
||||
static/Montserrat-ThinItalic.ttf
|
||||
static/Montserrat-ExtraLightItalic.ttf
|
||||
static/Montserrat-LightItalic.ttf
|
||||
static/Montserrat-Italic.ttf
|
||||
static/Montserrat-MediumItalic.ttf
|
||||
static/Montserrat-SemiBoldItalic.ttf
|
||||
static/Montserrat-BoldItalic.ttf
|
||||
static/Montserrat-ExtraBoldItalic.ttf
|
||||
static/Montserrat-BlackItalic.ttf
|
||||
|
||||
Get started
|
||||
-----------
|
||||
|
||||
1. Install the font files you want to use
|
||||
|
||||
2. Use your app's font picker to view the font family and all the
|
||||
available styles
|
||||
|
||||
Learn more about variable fonts
|
||||
-------------------------------
|
||||
|
||||
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
|
||||
https://variablefonts.typenetwork.com
|
||||
https://medium.com/variable-fonts
|
||||
|
||||
In desktop apps
|
||||
|
||||
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
|
||||
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
|
||||
|
||||
Online
|
||||
|
||||
https://developers.google.com/fonts/docs/getting_started
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
|
||||
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
|
||||
|
||||
Installing fonts
|
||||
|
||||
MacOS: https://support.apple.com/en-us/HT201749
|
||||
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
|
||||
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
|
||||
|
||||
Android Apps
|
||||
|
||||
https://developers.google.com/fonts/docs/android
|
||||
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
|
||||
|
||||
License
|
||||
-------
|
||||
Please read the full license text (OFL.txt) to understand the permissions,
|
||||
restrictions and requirements for usage, redistribution, and modification.
|
||||
|
||||
You can use them in your products & projects – print or digital,
|
||||
commercial or otherwise.
|
||||
|
||||
This isn't legal advice, please consider consulting a lawyer and see the full
|
||||
license for all details.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
'client_id' => getenv('GOOGLE_CLIENT_ID') ?: '',
|
||||
'client_secret' => getenv('GOOGLE_CLIENT_SECRET') ?: '',
|
||||
'redirect_uri' => 'https://uslugi.fr/google/oauth_callback.php',
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
require_once __DIR__ .'/../../includes/bootstrap_global.php';
|
||||
require_once '../vendor/autoload.php';
|
||||
$config = require 'config_google.php';
|
||||
|
||||
$client = new Google_Client();
|
||||
$client->setClientId($config['client_id']);
|
||||
$client->setClientSecret($config['client_secret']);
|
||||
$client->setRedirectUri($config['redirect_uri']);
|
||||
$client->addScope(Google_Service_Calendar::CALENDAR_EVENTS);
|
||||
$client->setAccessType('offline'); // important pour refresh_token
|
||||
$client->setPrompt('consent');
|
||||
|
||||
$auth_url = $client->createAuthUrl();
|
||||
header('Location: ' . filter_var($auth_url, FILTER_SANITIZE_URL));
|
||||
exit;
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../public/includes/init_praticien.php';
|
||||
|
||||
use Google\Client;
|
||||
use Google\Service\Calendar;
|
||||
use Google\Service\Calendar\Event;
|
||||
|
||||
function envoyerVersGoogleCalendar($praticien_id, $summary, $description, $start_iso, $end_iso = null, $duree_minutes = 60) {
|
||||
global $pdo;
|
||||
|
||||
$config = require 'config_google.php';
|
||||
|
||||
// 1. Récupérer le token Google du praticien
|
||||
$stmt = $pdo->prepare("SELECT * FROM google_tokens WHERE praticien_id = ?");
|
||||
$stmt->execute([$praticien_id]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$row) {
|
||||
error_log("❌ Aucun token trouvé pour praticien ID $praticien_id");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Préparer le client Google
|
||||
$client = new Client();
|
||||
$client->setClientId($config['client_id']);
|
||||
$client->setClientSecret($config['client_secret']);
|
||||
$client->setAccessToken([
|
||||
'access_token' => $row['access_token'],
|
||||
'expires_in' => 3600,
|
||||
'refresh_token' => $row['refresh_token'],
|
||||
'created' => $row['expires_at'] - 3600
|
||||
]);
|
||||
|
||||
// 3. Rafraîchir le token si expiré
|
||||
if ($client->isAccessTokenExpired()) {
|
||||
$client->fetchAccessTokenWithRefreshToken($client->getRefreshToken());
|
||||
|
||||
$new_token = $client->getAccessToken();
|
||||
$stmt = $pdo->prepare("UPDATE google_tokens SET access_token = ?, expires_at = ? WHERE praticien_id = ?");
|
||||
$stmt->execute([
|
||||
$new_token['access_token'],
|
||||
time() + $new_token['expires_in'],
|
||||
$praticien_id
|
||||
]);
|
||||
}
|
||||
|
||||
// 4. Calculer l'heure de fin si elle n'est pas transmise
|
||||
if (!$end_iso) {
|
||||
$start = new DateTime($start_iso);
|
||||
$start->modify("+{$duree_minutes} minutes");
|
||||
$end_iso = $start->format(DateTime::ATOM);
|
||||
}
|
||||
|
||||
// 🔍 Log pour debug (facultatif)
|
||||
error_log("🟢 Google Sync - START: $start_iso");
|
||||
error_log("🟢 Google Sync - END : $end_iso");
|
||||
|
||||
// 5. Préparer le service et l'événement
|
||||
$calendar = new Calendar($client);
|
||||
|
||||
$event = new Event([
|
||||
'summary' => $summary,
|
||||
'description' => $description,
|
||||
'start' => [
|
||||
'dateTime' => $start_iso,
|
||||
'timeZone' => 'Europe/Paris'
|
||||
],
|
||||
'end' => [
|
||||
'dateTime' => $end_iso,
|
||||
'timeZone' => 'Europe/Paris'
|
||||
]
|
||||
]);
|
||||
|
||||
try {
|
||||
$calendar->events->insert('primary', $event);
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
error_log("❌ Erreur Google Calendar : " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../public/includes/init_praticien.php';
|
||||
$config = require 'config_google.php';
|
||||
|
||||
$client = new Google_Client();
|
||||
$client->setClientId($config['client_id']);
|
||||
$client->setClientSecret($config['client_secret']);
|
||||
$client->setRedirectUri($config['redirect_uri']);
|
||||
|
||||
if (isset($_GET['code'])) {
|
||||
$client->authenticate($_GET['code']);
|
||||
$token = $client->getAccessToken();
|
||||
|
||||
if (!isset($_SESSION['reference_id']) || $_SESSION['role'] !== 'praticien') {
|
||||
die("Utilisateur non connecté.");
|
||||
}
|
||||
|
||||
$praticien_id = $_SESSION['reference_id'];
|
||||
|
||||
// Vérifier si un token existe déjà
|
||||
$stmt = $pdo->prepare("SELECT id FROM google_tokens WHERE praticien_id = ?");
|
||||
$stmt->execute([$praticien_id]);
|
||||
|
||||
$data = [
|
||||
'access_token' => $token['access_token'],
|
||||
'refresh_token' => $token['refresh_token'] ?? '',
|
||||
'expires_at' => time() + $token['expires_in']
|
||||
];
|
||||
|
||||
if ($stmt->fetch()) {
|
||||
$stmt = $pdo->prepare("UPDATE google_tokens SET access_token = ?, refresh_token = ?, expires_at = ? WHERE praticien_id = ?");
|
||||
} else {
|
||||
$stmt = $pdo->prepare("INSERT INTO google_tokens (access_token, refresh_token, expires_at, praticien_id) VALUES (?, ?, ?, ?)");
|
||||
}
|
||||
|
||||
$stmt->execute([$data['access_token'], $data['refresh_token'], $data['expires_at'], $praticien_id]);
|
||||
|
||||
echo <<<HTML
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-center align-items-center" style="min-height:48vh;">
|
||||
<div class="card shadow-lg border-0 text-center" style="max-width:720px; width:100%; border-radius:18px;">
|
||||
<div class="card-body p-4">
|
||||
<div class="display-6 mb-2">✅</div>
|
||||
<h2 class="h4 mb-2">Подключение к Google Календарю выполнено успешно</h2>
|
||||
<p class="text-muted mb-4">Вы можете вернуться в календарь или запустить синхронизацию прямо сейчас.</p>
|
||||
<div class="d-flex gap-2 justify-content-center">
|
||||
<a href="/synchroniser_google.php" class="btn btn-primary btn-lg rounded-pill">
|
||||
Синхронизировать календарь
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
} else {
|
||||
echo <<<HTML
|
||||
<div class="container py-4">
|
||||
<div class="alert alert-danger text-center mx-auto" role="alert" style="max-width:720px;">
|
||||
❌ Не передан код авторизации Google. Попробуйте выполнить вход ещё раз.
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
?>
|
||||
|
||||
<link href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
|
||||
// ===== Helpers header (identiques à index.php) =====
|
||||
function is_client_logged_in(): bool
|
||||
{
|
||||
$role = $_SESSION['role'] ?? ($_SESSION['user_type'] ?? '');
|
||||
if (! empty($_SESSION['user_id']) && $role === 'client') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! empty($_SESSION['client_id'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! empty($_SESSION['reference_id']) && $role === 'client') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! empty($_SESSION['user_id']) && $role === 'praticien') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! empty($_SESSION['praticien_id'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! empty($_SESSION['reference_id']) && $role === 'praticien') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function include_header_clean(string $absPath): void
|
||||
{
|
||||
global $translations, $lang;
|
||||
ob_start();
|
||||
include $absPath; // /public/includes/header.php
|
||||
$buf = ob_get_clean() ?: '';
|
||||
|
||||
// 0) DOCTYPE + <html>…</html>
|
||||
$buf = preg_replace('~<!doctype\s+html[^>]*>~i', '', $buf);
|
||||
$buf = preg_replace('~</?html\b[^>]*>~i', '', $buf);
|
||||
|
||||
// 1) retirer <head>…</head> et <body> ouverts/fermés
|
||||
$buf = preg_replace('~<head\b[^>]*>.*?</head>~is', '', $buf);
|
||||
$buf = preg_replace('~</?body\b[^>]*>~i', '', $buf);
|
||||
|
||||
// 2) retirer les <link> CSS “globaux” pour éviter d’écraser la home
|
||||
$buf = preg_replace('~<link[^>]+href="[^"]*(bootstrap[^"]*\.css|mobil_style\.css|site_style\.css|fonts\.googleapis)[^"]*"[^>]*>~i', '', $buf);
|
||||
|
||||
// 3) retirer le <script> Bootstrap CDN (on le charge déjà en bas de la page)
|
||||
$buf = preg_replace('~<script[^>]+src="[^"]*bootstrap[^"]*bundle[^"]*\.js[^"]*"[^>]*>\s*</script>~i', '', $buf);
|
||||
|
||||
echo $buf; // garde <header> + tes scripts inline utiles
|
||||
}
|
||||
|
||||
function t(array $translations, string $key, string $fallback = ''): string
|
||||
{
|
||||
$val = $translations[$key] ?? '';
|
||||
if ($val === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return (string) $val;
|
||||
}
|
||||
|
||||
// Lang HTML
|
||||
$langHtml = preg_match('~^[a-z]{2}(-[A-Z]{2})?$~', (string) ($lang ?? '')) ? (string) $lang : 'ru';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?php echo htmlspecialchars($langHtml, ENT_QUOTES, 'UTF-8')?>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><?php echo htmlspecialchars($translations['seo_how_it_work_title'], ENT_QUOTES, 'UTF-8')?></title>
|
||||
<meta name="description" content="<?php echo htmlspecialchars($translations['seo_how_it_work_desc'], ENT_QUOTES, 'UTF-8')?>">
|
||||
<meta name="keywords" content="<?php echo htmlspecialchars($translations['seo_how_it_work_keywords'], ENT_QUOTES, 'UTF-8')?>">
|
||||
<meta name="author" content="Uslugi.fr">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
|
||||
<link rel="stylesheet" href="<?= asset('/includes/site_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/custom_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/mobil_style.css') ?>">
|
||||
<style>
|
||||
.howitworks { --brand:#38378B; --mint:#06FFA8; --text:#111; --muted:#6b7280; --card:#fff; --border:#e5e7eb; }
|
||||
.howitworks { font-family: "Montserrat", system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; color:var(--text); }
|
||||
.howitworks .wrap { max-width: 980px; margin: 0 auto; padding: clamp(16px,4vw,32px); }
|
||||
.howitworks .card {
|
||||
background: var(--card); border:1px solid var(--border); border-radius:16px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.06); padding: clamp(18px,3.6vw,28px);
|
||||
}
|
||||
.howitworks h2 { font-size: clamp(24px, 5.2vw, 36px); line-height:1.2; margin: 0 0 16px; }
|
||||
.howitworks p { font-size: clamp(14px, 2.8vw, 16px); line-height:1.65; color: var(--muted); margin: 0 0 12px; }
|
||||
|
||||
.hiw-steps{ display:grid; gap:14px; grid-template-columns: 1fr; }
|
||||
@media (min-width: 600px){ .hiw-steps{ grid-template-columns: 1fr 1fr; } }
|
||||
@media (min-width: 960px){ .hiw-steps{ grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
.step{
|
||||
display:flex; gap:12px; align-items:flex-start; background:#fafafa; border:1px solid #f0f1f4;
|
||||
border-radius:12px; padding:14px;
|
||||
}
|
||||
.step-icon{
|
||||
width:44px; height:44px; flex:0 0 44px; display:grid; place-items:center; font-size:20px; font-weight:700;
|
||||
background: color-mix(in oklab, var(--brand) 12%, #fff); color: var(--brand); border:2px solid color-mix(in oklab, var(--brand) 25%, #fff);
|
||||
border-radius:50%;
|
||||
}
|
||||
.step h3{ margin:2px 0 4px; font-size: clamp(15px, 2.8vw, 18px); line-height:1.35; color:#1f2937; font-weight:600; }
|
||||
.step p { margin:0; }
|
||||
|
||||
.hiw-intro { margin-bottom: 10px; }
|
||||
.hiw-list{ list-style:none; padding:0; margin: 8px 0 0; display:grid; gap:10px; }
|
||||
.hiw-item{
|
||||
display:flex; gap:12px; align-items:flex-start; border:1px dashed #e6e8ee; border-radius:12px; padding:12px 14px; background:#fff;
|
||||
}
|
||||
.dot{
|
||||
width:22px; height:22px; border-radius:50%; flex:0 0 22px; display:grid; place-items:center;
|
||||
background: var(--mint); color:#043; font-size:14px; font-weight:800;
|
||||
}
|
||||
.hiw-item h4{ margin:2px 0 4px; font-size: clamp(15px, 2.8vw, 18px); line-height:1.35; font-weight:700; color:#111; }
|
||||
.hiw-item p { margin:0; }
|
||||
|
||||
.callout{
|
||||
margin-top:16px; border-left:6px solid var(--brand);
|
||||
background: color-mix(in oklab, var(--brand) 8%, #fff);
|
||||
border-radius:12px; padding:14px 16px;
|
||||
}
|
||||
</style>
|
||||
<?php include_once __DIR__ . '/includes/tracking.php'; ?>
|
||||
</head>
|
||||
<body>
|
||||
<?php if (is_client_logged_in()): ?>
|
||||
<?php
|
||||
require_once __DIR__ . '/public/includes/init_pr_cl.php';
|
||||
include_header_clean(__DIR__ . '/public/includes/header.php'); ?>
|
||||
<?php else: ?>
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo_u.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-mobile"></a>
|
||||
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-desktop"></a>
|
||||
</div>
|
||||
<div class="lang-switch">
|
||||
<?php require __DIR__ . '/includes/menu_langue.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-actions">
|
||||
<a class="btn-login" href="/connexion.php">
|
||||
<span class="d-sm-none"><?php echo htmlspecialchars($translations['btn_se_connecter'] ?? 'Connexion', ENT_QUOTES, 'UTF-8')?></span>
|
||||
<span class="d-none d-sm-inline"><?php echo htmlspecialchars($translations['ja_spec'] ?? 'Je suis spécialiste', ENT_QUOTES, 'UTF-8')?></span>
|
||||
</a>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-logo rounded-circle d-inline-flex align-items-center justify-content-center question-btn"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="true"
|
||||
aria-expanded="false"
|
||||
aria-label="<?php echo htmlspecialchars(t($translations, 'howitworks_help_aria', 'Help'), ENT_QUOTES, 'UTF-8')?>">
|
||||
?
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow help-menu menu-panel">
|
||||
<li><a href="/how-it-work.php"><?php echo htmlspecialchars($translations['kak_rabotaet'] ?? 'Как это работает', ENT_QUOTES, 'UTF-8')?></a></li>
|
||||
<li><a href="/about.php"><?php echo htmlspecialchars($translations['pro_nas'] ?? 'О нас', ENT_QUOTES, 'UTF-8')?></a></li>
|
||||
<li><a href="/faq.php"><?php echo htmlspecialchars($translations['faq'] ?? 'FAQ', ENT_QUOTES, 'UTF-8')?></a></li>
|
||||
<li><a href="/contact.php"><?php echo htmlspecialchars($translations['nam_pisat'] ?? 'Написать нам', ENT_QUOTES, 'UTF-8')?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
|
||||
<section class="howitworks">
|
||||
<div class="wrap">
|
||||
|
||||
<div class="card" style="margin-bottom:18px;">
|
||||
<h2><?php echo htmlspecialchars(t($translations, 'howitworks_short_title', 'How it works'), ENT_QUOTES, 'UTF-8')?></h2>
|
||||
|
||||
<div class="hiw-steps">
|
||||
<div class="step">
|
||||
<div class="step-icon">📝</div>
|
||||
<div>
|
||||
<h3><?php echo htmlspecialchars(t($translations, 'howitworks_step1_title', 'Registration'), ENT_QUOTES, 'UTF-8')?></h3>
|
||||
<p><?php echo htmlspecialchars(t($translations, 'howitworks_step1_text', 'Create your account in minutes.'), ENT_QUOTES, 'UTF-8')?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-icon">🔎</div>
|
||||
<div>
|
||||
<h3><?php echo htmlspecialchars(t($translations, 'howitworks_step2_title', 'Choose a specialist'), ENT_QUOTES, 'UTF-8')?></h3>
|
||||
<p><?php echo htmlspecialchars(t($translations, 'howitworks_step2_text', 'Find an expert and pick a time.'), ENT_QUOTES, 'UTF-8')?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-icon">✉️</div>
|
||||
<div>
|
||||
<h3><?php echo htmlspecialchars(t($translations, 'howitworks_step3_title', 'Confirmation'), ENT_QUOTES, 'UTF-8')?></h3>
|
||||
<p><?php echo htmlspecialchars(t($translations, 'howitworks_step3_text', 'Receive an email confirmation and pay the specialist directly.'), ENT_QUOTES, 'UTF-8')?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-icon">🎥</div>
|
||||
<div>
|
||||
<h3><?php echo htmlspecialchars(t($translations, 'howitworks_step4_title', 'Online consultation'), ENT_QUOTES, 'UTF-8')?></h3>
|
||||
<p><?php echo htmlspecialchars(t($translations, 'howitworks_step4_text', 'Happens on our website via Jitsi.'), ENT_QUOTES, 'UTF-8')?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2><?php echo htmlspecialchars(t($translations, 'howitworks_long_title', 'How uslugi.fr works'), ENT_QUOTES, 'UTF-8')?></h2>
|
||||
|
||||
<p class="hiw-intro">
|
||||
<?php echo htmlspecialchars(t($translations, 'howitworks_long_intro', 'We built the website to help you find a trusted Russian-speaking specialist in France and get a consultation easily.'), ENT_QUOTES, 'UTF-8')?>
|
||||
</p>
|
||||
|
||||
<ul class="hiw-list">
|
||||
<li class="hiw-item">
|
||||
<div class="dot">1</div>
|
||||
<div>
|
||||
<h4><?php echo htmlspecialchars(t($translations, 'howitworks_item1_title', 'Registration'), ENT_QUOTES, 'UTF-8')?></h4>
|
||||
<p><?php echo htmlspecialchars(t($translations, 'howitworks_item1_text', 'Create your personal account in minutes.'), ENT_QUOTES, 'UTF-8')?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="hiw-item">
|
||||
<div class="dot">2</div>
|
||||
<div>
|
||||
<h4><?php echo htmlspecialchars(t($translations, 'howitworks_item2_title', 'Choose a specialist'), ENT_QUOTES, 'UTF-8')?></h4>
|
||||
<p><?php echo htmlspecialchars(t($translations, 'howitworks_item2_text', 'In the catalog: lawyers, notaries, consultants, psychologists and translators. Each specialist has a calendar with available dates and times.'), ENT_QUOTES, 'UTF-8')?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="hiw-item">
|
||||
<div class="dot">3</div>
|
||||
<div>
|
||||
<h4><?php echo htmlspecialchars(t($translations, 'howitworks_item3_title', 'Booking'), ENT_QUOTES, 'UTF-8')?></h4>
|
||||
<p><?php echo htmlspecialchars(t($translations, 'howitworks_item3_text', 'Pick a time and receive an email confirmation.'), ENT_QUOTES, 'UTF-8')?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="hiw-item">
|
||||
<div class="dot">4</div>
|
||||
<div>
|
||||
<h4><?php echo htmlspecialchars(t($translations, 'howitworks_item4_title', 'Payment'), ENT_QUOTES, 'UTF-8')?></h4>
|
||||
<p><?php echo htmlspecialchars(t($translations, 'howitworks_item4_text', 'Pay the specialist directly — we do not take any commission.'), ENT_QUOTES, 'UTF-8')?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="hiw-item">
|
||||
<div class="dot">5</div>
|
||||
<div>
|
||||
<h4><?php echo htmlspecialchars(t($translations, 'howitworks_item5_title', 'Chat'), ENT_QUOTES, 'UTF-8')?></h4>
|
||||
<p><?php echo htmlspecialchars(t($translations, 'howitworks_item5_text', 'Before the consultation, you can уточнить детали in chat with the expert.'), ENT_QUOTES, 'UTF-8')?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="hiw-item">
|
||||
<div class="dot">6</div>
|
||||
<div>
|
||||
<h4><?php echo htmlspecialchars(t($translations, 'howitworks_item6_title', 'Online meeting'), ENT_QUOTES, 'UTF-8')?></h4>
|
||||
<p><?php echo htmlspecialchars(t($translations, 'howitworks_item6_text', 'The consultation takes place via Jitsi on our website — just open the link.'), ENT_QUOTES, 'UTF-8')?></p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="hiw-item">
|
||||
<div class="dot">7</div>
|
||||
<div>
|
||||
<h4><?php echo htmlspecialchars(t($translations, 'howitworks_item7_title', 'Cancellation'), ENT_QUOTES, 'UTF-8')?></h4>
|
||||
<p><?php echo htmlspecialchars(t($translations, 'howitworks_item7_text', 'If plans change, each specialist has their own cancellation policy.'), ENT_QUOTES, 'UTF-8')?></p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout">
|
||||
<strong><?php echo htmlspecialchars(t($translations, 'howitworks_goal_label', 'Our goal:'), ENT_QUOTES, 'UTF-8')?></strong>
|
||||
<?php echo htmlspecialchars(t($translations, 'howitworks_goal_text', 'Connect you with the right Russian-speaking specialist in France and make everything simple and convenient.'), ENT_QUOTES, 'UTF-8')?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?php echo $translations['copyr']?></p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
# Interdit l'accès HTTP aux fichiers sensibles
|
||||
<FilesMatch "\.(php|inc|phtml|phar|env|ini|json|ya?ml|sql|bak|zip)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
# Interdit l'accès aux fichiers cachés (.env, .git, etc.)
|
||||
<FilesMatch "^\.">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
# Pas de listing de répertoire
|
||||
Options -Indexes
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
// htdocs/includes/accepter_cookies.php — CookieConsent v3 (correctif)
|
||||
// Si tu as des traductions dans lang/fr.php / lang/ru.php, on les lit (optionnel)
|
||||
$lang = $_SESSION['lang'] ?? 'fr';
|
||||
$langFile = __DIR__ . "/../lang/{$lang}.php";
|
||||
if (is_file($langFile)) { include $langFile; }
|
||||
$t = isset($translations) && is_array($translations) ? $translations : [];
|
||||
function tt($k,$d=''){ global $t; return htmlspecialchars($t[$k]??$d, ENT_QUOTES, 'UTF-8'); }
|
||||
?>
|
||||
<!--<a id="cc-manage" href="#" style="display:none"><?= tt('cookies_manage_link','Paramètres cookies') ?></a>-->
|
||||
|
||||
<link id="cc-css" rel="stylesheet" href="/js/cookieconsent.css"
|
||||
onerror="this.href='https://cdn.jsdelivr.net/gh/orestbida/cookieconsent@3.1.0/dist/cookieconsent.css'">
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
// Textes (prend ceux de tes fichiers de langue si présents)
|
||||
const TXT = {
|
||||
title: <?= json_encode($t['cookies_title'] ?? 'Cookies & confidentialité') ?>,
|
||||
desc: <?= json_encode($t['cookies_desc'] ?? "Nous utilisons des cookies essentiels. Avec votre accord, nous activons aussi l'analyse et la personnalisation.") ?>,
|
||||
acceptAll: <?= json_encode($t['cookies_accept_all'] ?? 'Tout accepter') ?>,
|
||||
rejectAll: <?= json_encode($t['cookies_reject_all'] ?? 'Tout refuser') ?>,
|
||||
settings: <?= json_encode($t['cookies_settings'] ?? 'Personnaliser') ?>,
|
||||
necessary: <?= json_encode($t['cookies_necessary'] ?? 'Essentiels (toujours actifs)') ?>,
|
||||
analytics: <?= json_encode($t['cookies_analytics'] ?? 'Mesure d’audience') ?>,
|
||||
personalization: <?= json_encode($t['cookies_personalization'] ?? 'Personnalisation') ?>,
|
||||
save: <?= json_encode($t['cookies_save'] ?? 'Enregistrer') ?>,
|
||||
};
|
||||
|
||||
const SRCS = [
|
||||
'/js/cookieconsent.umd.js', // local d'abord
|
||||
'https://cdn.jsdelivr.net/gh/orestbida/cookieconsent@3.1.0/dist/cookieconsent.umd.js',
|
||||
'https://unpkg.com/vanilla-cookieconsent@3.1.0/dist/cookieconsent.umd.js'
|
||||
];
|
||||
|
||||
function loadScriptSequential(i){
|
||||
if(i >= SRCS.length){ console.error('[CC] Impossible de charger la lib'); return; }
|
||||
const s = document.createElement('script');
|
||||
s.src = SRCS[i];
|
||||
s.onload = function(){
|
||||
// v3 => CookieConsent.run(...)
|
||||
if (window.CookieConsent && typeof CookieConsent.run === 'function'){ initCC(); }
|
||||
else { console.warn('[CC] Script chargé mais API v3 absente → essai suivant'); loadScriptSequential(i+1); }
|
||||
};
|
||||
s.onerror = function(){ console.warn('[CC] Échec chargement', SRCS[i]); loadScriptSequential(i+1); };
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function initCC(){
|
||||
// Sécurité
|
||||
if (!(window.CookieConsent && typeof CookieConsent.run === 'function')){
|
||||
console.error('[CC] CookieConsent.run indisponible'); return;
|
||||
}
|
||||
|
||||
CookieConsent.run({
|
||||
cookie: {
|
||||
name: 'cc_consent_v3',
|
||||
path: '/',
|
||||
sameSite: 'Lax',
|
||||
expiresAfterDays: 180
|
||||
},
|
||||
guiOptions: {
|
||||
consentModal: { layout: 'cloud', position: 'bottom center', equalWeightButtons: false, flipButtons: false },
|
||||
preferencesModal: { layout: 'box', equalWeightButtons: true, flipButtons: false }
|
||||
},
|
||||
// v3: définir "categories" + "language" (obligatoires)
|
||||
categories: {
|
||||
necessary: { enabled: true, readOnly: true },
|
||||
analytics: {},
|
||||
personalization: {}
|
||||
},
|
||||
language: {
|
||||
default: 'xx',
|
||||
translations: {
|
||||
xx: {
|
||||
consentModal: {
|
||||
title: TXT.title,
|
||||
description: TXT.desc,
|
||||
acceptAllBtn: TXT.acceptAll,
|
||||
acceptNecessaryBtn: TXT.rejectAll,
|
||||
showPreferencesBtn: TXT.settings
|
||||
},
|
||||
preferencesModal: {
|
||||
title: TXT.title,
|
||||
acceptAllBtn: TXT.acceptAll,
|
||||
acceptNecessaryBtn: TXT.rejectAll,
|
||||
savePreferencesBtn: TXT.save,
|
||||
sections: [
|
||||
{ title: TXT.necessary, description: '' },
|
||||
{ title: TXT.analytics, description: '', linkedCategory: 'analytics' },
|
||||
{ title: TXT.personalization, description: '', linkedCategory: 'personalization' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Hooks
|
||||
onConsent: ({cookie}) => {
|
||||
if (CookieConsent.acceptedCategory('analytics')) enableAnalytics();
|
||||
},
|
||||
onChange: ({changedCategories}) => {
|
||||
if (changedCategories.includes('analytics')) {
|
||||
CookieConsent.acceptedCategory('analytics') ? enableAnalytics() : disableAnalytics();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Lien pour rouvrir
|
||||
const manage = document.getElementById('cc-manage');
|
||||
if (manage) {
|
||||
manage.style.display = 'inline';
|
||||
manage.addEventListener('click', (e)=>{ e.preventDefault(); CookieConsent.showPreferences(); });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Tes hooks perso (ex: activer GA seulement si analytics accepté)
|
||||
window.enableAnalytics = function(){
|
||||
if (window.__gaLoaded) return; window.__gaLoaded = true;
|
||||
// Exemple:
|
||||
CookieConsent.loadScript('https://www.googletagmanager.com/gtag/js?id=G-XXXX').then(()=>{
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments)}; gtag('js', new Date()); gtag('config','G-XXXX');
|
||||
});
|
||||
};
|
||||
window.disableAnalytics = function(){ /* éventuellement reset */ };
|
||||
|
||||
// Lance le chargement (local → CDN fallback)
|
||||
loadScriptSequential(0);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#cc--main .cm__btn, #cc--main .pm__btn { border-radius: 999px; }
|
||||
#cc-manage { position: fixed; right: 12px; bottom: 12px; font-size: 12px; opacity: .7; z-index: 99999; }
|
||||
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,400 @@
|
||||
/* ==========================================================================
|
||||
admin_style.css — Mobile global (≤ 956px)
|
||||
Charge APRÈS Bootstrap
|
||||
========================================================================== */
|
||||
|
||||
/* --- Dropdown admin (utilisé aussi hors mobile) --- */
|
||||
:root {
|
||||
--header: #f1f1f1;
|
||||
}
|
||||
.menu-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.dropdown-menu-custom {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 230px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
display: none;
|
||||
z-index: 1100;
|
||||
}
|
||||
.dropdown-menu-custom li {
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid #f1f1f1;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
}
|
||||
.dropdown-menu-custom li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.dropdown-menu-custom li:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* ======================== Mobile-first (≤ 956px) ======================== */
|
||||
@media (max-width: 956px) {
|
||||
/* Garde-fous */
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
img,
|
||||
video,
|
||||
iframe {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Grille & conteneurs */
|
||||
.row {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
.container {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important;
|
||||
}
|
||||
|
||||
/* Layout principal */
|
||||
main,
|
||||
.main,
|
||||
.main1,
|
||||
.main2 {
|
||||
display: block !important;
|
||||
align-items: initial !important;
|
||||
justify-content: initial !important;
|
||||
background: transparent !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.btn-lg,
|
||||
.btn-sm {
|
||||
min-height: 38px !important;
|
||||
padding: 0.35rem 0.7rem !important;
|
||||
font-size: 0.92rem !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.btn-outline-primary,
|
||||
.btn-outline-secondary,
|
||||
.btn-outline-danger,
|
||||
.btn-outline-dark {
|
||||
border-width: 1px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* groupes d’actions dans les tableaux → même largeur */
|
||||
.d-flex.gap-2 .btn,
|
||||
.row .btn {
|
||||
flex: 1 1 0;
|
||||
text-align: center;
|
||||
}
|
||||
.text-end {
|
||||
text-align: initial !important;
|
||||
}
|
||||
.text-end .btn {
|
||||
width: 100%;
|
||||
}
|
||||
.btn-group {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn-group .btn {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Cartes */
|
||||
.card,
|
||||
.card * {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.card,
|
||||
.card.shadow-sm,
|
||||
.card.shadow-sm.p-3 {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Formulaires */
|
||||
.form-control,
|
||||
.form-select,
|
||||
.form-check-input,
|
||||
.input-group-text,
|
||||
.input-group > .btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Tables: scroll horizontal “propre” */
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
table.table {
|
||||
font-size: 14px;
|
||||
}
|
||||
table.table th,
|
||||
table.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Listes → actions tap-friendly */
|
||||
td.text-end .d-flex {
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
td.text-end .d-flex .btn {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* Split list/detail */
|
||||
.row.g-3 > [class*="col-"] .card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Autocomplete / suggestions */
|
||||
.suggest,
|
||||
#suggestions {
|
||||
position: static !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.site-footer {
|
||||
font-size: 12px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Menu hamburger */
|
||||
.header-actions .btn {
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ====== Composants “pages admin” (tous écrans, pensés mobile) ====== */
|
||||
.table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table .minw-120 {
|
||||
min-width: 120px;
|
||||
}
|
||||
.photo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.badge-active {
|
||||
background: #e8f6ee;
|
||||
border-color: #27ae60;
|
||||
color: #2e07a3ff;
|
||||
}
|
||||
.badge-expiring {
|
||||
background: #fff8e6;
|
||||
border-color: #f39c12;
|
||||
color: #2e07a3ff;
|
||||
}
|
||||
.badge-expired {
|
||||
background: #fdecea;
|
||||
border-color: #c0392b;
|
||||
color: #2e07a3ff;
|
||||
}
|
||||
|
||||
.card pre {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.list-group-item > .badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid #6366f1;
|
||||
outline-offset: 2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ====== Table -> cartes (opt-in via .mobile-cards) ====== */
|
||||
@media (max-width: 768px) {
|
||||
table.mobile-cards {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
table.mobile-cards thead {
|
||||
display: none;
|
||||
}
|
||||
table.mobile-cards tbody {
|
||||
display: block;
|
||||
}
|
||||
table.mobile-cards tr {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
table.mobile-cards tr::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 3px;
|
||||
background: #38378b;
|
||||
opacity: 0.9;
|
||||
}
|
||||
table.mobile-cards td {
|
||||
display: block;
|
||||
padding: 10px 14px !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
table.mobile-cards td + td {
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
table.mobile-cards td::before {
|
||||
content: attr(data-label);
|
||||
display: inline-block;
|
||||
min-width: 120px;
|
||||
margin-right: 8px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
table.mobile-cards td::before:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 956px) {
|
||||
.d-flex.justify-content-end.gap-2 .btn {
|
||||
flex: 1 1 0; /* même largeur */
|
||||
min-width: 120px; /* évite les boutons trop petits */
|
||||
text-align: center; /* centre le texte */
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.site-header {
|
||||
background: var(--header);
|
||||
border-bottom: 1px solid rgba(168, 39, 39, 0.06);
|
||||
}
|
||||
.site-header .container {
|
||||
margin: 0 auto;
|
||||
padding: 10px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.site-header .btn-circle {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.site-header .avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
font-weight: 600;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
/* ==== LOGO: mobile vs desktop (béton) ==== */
|
||||
.brand {
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 0; /* évite petits décalages baseline */
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px; /* distance logo ↔ langue */
|
||||
}
|
||||
.brand .logo-mobile,
|
||||
.brand .logo-desktop {
|
||||
display: none !important; /* on cache tout par défaut */
|
||||
height: 40px; /* taille unique (tu ajustes) */
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Mobile: logo_u */
|
||||
@media (max-width: 767.98px) {
|
||||
.brand .logo-mobile {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: logo.png */
|
||||
@media (min-width: 768px) {
|
||||
.brand .logo-desktop {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Langue choix*/
|
||||
/* Sélecteur de langue dans le header */
|
||||
.lang-switch {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
height: 42px;
|
||||
background: #fff;
|
||||
padding: 6px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
color: #38378b; /* bleu foncé */
|
||||
border-radius: 14px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
background: rgba(56, 55, 139, 0.08);
|
||||
}
|
||||
|
||||
.lang-btn.active {
|
||||
background: #38378b; /* bleu foncé */
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
/**
|
||||
* Prépare le contexte attendu par header.php (pushCfg, alerte_abonnement, avatar/initiales)
|
||||
* SANS appeler init_praticien.php => pas de redirection abonnement.
|
||||
*/
|
||||
|
||||
/* =========================
|
||||
Helpers
|
||||
========================= */
|
||||
function is_logged_in(): bool {
|
||||
$role = (string)($_SESSION['role'] ?? ($_SESSION['user_type'] ?? ''));
|
||||
if (!empty($_SESSION['reference_id']) && $role !== '') return true;
|
||||
if (!empty($_SESSION['user_id'])) return true;
|
||||
if (!empty($_SESSION['client_id'])) return true;
|
||||
if (!empty($_SESSION['praticien_id'])) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclut /public/includes/header.php sans DOCTYPE/HTML/HEAD/BODY et sans recharger bootstrap/css.
|
||||
*/
|
||||
function include_header_clean(string $absPath): void {
|
||||
global $translations, $lang;
|
||||
ob_start();
|
||||
include $absPath;
|
||||
$buf = ob_get_clean() ?: '';
|
||||
|
||||
$buf = preg_replace('~<!doctype\s+html[^>]*>~i', '', $buf);
|
||||
$buf = preg_replace('~</?html\b[^>]*>~i', '', $buf);
|
||||
$buf = preg_replace('~<head\b[^>]*>.*?</head>~is', '', $buf);
|
||||
$buf = preg_replace('~</?body\b[^>]*>~i', '', $buf);
|
||||
|
||||
// éviter d’écraser la home
|
||||
$buf = preg_replace('~<link[^>]+href="[^"]*(bootstrap[^"]*\.css|mobil_style\.css|site_style\.css|fonts\.googleapis)[^"]*"[^>]*>~i', '', $buf);
|
||||
$buf = preg_replace('~<script[^>]+src="[^"]*bootstrap[^"]*bundle[^"]*\.js[^"]*"[^>]*>\s*</script>~i', '', $buf);
|
||||
|
||||
echo $buf;
|
||||
}
|
||||
function prepare_secure_header_context(): void {
|
||||
$pdo = $GLOBALS['pdo'] ?? null;
|
||||
if (!($pdo instanceof PDO)) return;
|
||||
|
||||
$role = strtolower((string)($_SESSION['role'] ?? ''));
|
||||
$refId = (int)($_SESSION['reference_id'] ?? 0);
|
||||
|
||||
|
||||
|
||||
// Initiales client/praticien
|
||||
if ($refId > 0 && ($role === 'client' || $role === 'praticien')) {
|
||||
try {
|
||||
$table = ($role === 'client') ? 'client' : 'praticien';
|
||||
$st = $pdo->prepare("SELECT nom, prenom FROM {$table} WHERE id = ? LIMIT 1");
|
||||
$st->execute([$refId]);
|
||||
$u = $st->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
$nom = trim((string)($u['nom'] ?? ''));
|
||||
$prenom = trim((string)($u['prenom'] ?? ''));
|
||||
$ini = mb_strtoupper(
|
||||
mb_substr($prenom, 0, 1, 'UTF-8') . mb_substr($nom, 0, 1, 'UTF-8'),
|
||||
'UTF-8'
|
||||
);
|
||||
$GLOBALS['initiales'] = $ini !== '' ? $ini : 'U';
|
||||
} catch (Throwable $e) {
|
||||
$GLOBALS['initiales'] = 'U';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si connecté -> préparer le contexte header sécurisé (sans redirection)
|
||||
$logged = is_logged_in();
|
||||
if ($logged) prepare_secure_header_context();
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
/*
|
||||
* /city_aliases_ru.php
|
||||
*/
|
||||
// RU exonyms -> Latin/French city names (lowercase, sans accents si possible)
|
||||
return [
|
||||
/* ================= Île-de-France ================= */
|
||||
'париж' => 'paris',
|
||||
'булонь-бийанкур' => 'boulogne-billancourt',
|
||||
'сен-дени' => 'saint-denis',
|
||||
'аржантёй' => 'argenteuil',
|
||||
'монтрой' => 'montreuil',
|
||||
'нантер' => 'nanterre',
|
||||
'витри-сюр-сен' => 'vitry-sur-seine',
|
||||
'курбевуа' => 'courbevoie',
|
||||
'кольомб' => 'colombes',
|
||||
'аньер-сюр-сен' => 'asnieres-sur-seine',
|
||||
'олнэ-су-буа' => 'aulnay-sous-bois',
|
||||
'рюэй-мальмезон' => 'rueil-malmaison',
|
||||
'сен-мор-де-фоссе' => 'saint-maur-des-fosses',
|
||||
'шампиньи-сюр-марн' => 'champigny-sur-marne',
|
||||
'исси-ле-мулино' => 'issy-les-moulineaux',
|
||||
'клиши' => 'clichy',
|
||||
'нуази-ле-гран' => 'noisy-le-grand',
|
||||
'сарсель' => 'sarcelles',
|
||||
'леваллуа-перре' => 'levallois-perret',
|
||||
'нёйи-сюр-сен' => 'neuilly-sur-seine',
|
||||
'антони' => 'antony',
|
||||
'мэзон-алфор' => 'maisons-alfort',
|
||||
'кретей' => 'creteil',
|
||||
'версаль' => 'versailles',
|
||||
'иври-сюр-сен' => 'ivry-sur-seine',
|
||||
'пантен' => 'pantin',
|
||||
'севран' => 'sevran',
|
||||
'дранси' => 'drancy',
|
||||
'бобиньи' => 'bobigny',
|
||||
'бонди' => 'bondy',
|
||||
'обервилье' => 'aubervilliers',
|
||||
'сен-уэн' => 'saint-ouen',
|
||||
'масси' => 'massy',
|
||||
'мо' => 'meaux',
|
||||
'сен-жермен-ан-ле' => 'saint-germain-en-laye',
|
||||
'пюто' => 'puteaux',
|
||||
'мелён' => 'melun',
|
||||
'эври' => 'evry',
|
||||
'сержи' => 'cergy',
|
||||
'фонтенбло' => 'fontainebleau',
|
||||
'монруж' => 'montrouge',
|
||||
'нанси-сюр- марн' => 'nancy',
|
||||
'сарсель' => 'sarcelles',
|
||||
'монтеро-фо-ивонн' => 'montereau-fault-yonne',
|
||||
'сарсель' => 'sarcelles',
|
||||
'нантер' => 'nanterre',
|
||||
|
||||
/* ================= Auvergne–Rhône–Alpes ================= */
|
||||
'лион' => 'lyon',
|
||||
'виллербанн' => 'villeurbanne',
|
||||
'вок-ан-влен' => 'vaulx-en-velin',
|
||||
'венисьё' => 'venissieux',
|
||||
'кальюир-э-кюйр' => 'caluire-et-cuire',
|
||||
'сент-фуа-ле-льон' => 'sainte-foy-les-lyon',
|
||||
'гренобль' => 'grenoble',
|
||||
'сен-этьен' => 'saint-etienne',
|
||||
'клермон-ферран' => 'clermont-ferrand',
|
||||
'ансии' => 'annecy',
|
||||
'валанс' => 'valence',
|
||||
'шамбери' => 'chambery',
|
||||
'бур-ж-ан-бресс' => 'bourg-en-bresse',
|
||||
'роан' => 'roanne',
|
||||
'ойонна' => 'oyonnax',
|
||||
'анномас' => 'annemasse',
|
||||
'тнон-ле-бен' => 'thonon-les-bains',
|
||||
'эвиан-ле-бен' => 'evian-les-bains',
|
||||
'боннвиль' => 'bonneville',
|
||||
'вирьё-шательон' => 'villefontaine', /* approx area alias */
|
||||
'белле' => 'belley',
|
||||
|
||||
/* ================= Provence–Alpes–Côte d’Azur ================= */
|
||||
'марсель' => 'marseille',
|
||||
'ницца' => 'nice',
|
||||
'тулон' => 'toulon',
|
||||
'авиньон' => 'avignon',
|
||||
'канны' => 'cannes',
|
||||
'антиб' => 'antibes',
|
||||
'грас' => 'grasse',
|
||||
'экс-ан-прованс' => 'aix-en-provence',
|
||||
'арль' => 'arles',
|
||||
'фрежюс' => 'frejus',
|
||||
'драгиньян' => 'draguignan',
|
||||
'йер' => 'hyeres',
|
||||
'ла-сейн-сюр-мер' => 'la seyne-sur-mer',
|
||||
'ла-сьота' => 'la ciotat',
|
||||
'мартг' => 'martigues',
|
||||
'салон-де-прованс' => 'salon-de-provence',
|
||||
'оранж' => 'orange',
|
||||
'карпантра' => 'carpentras',
|
||||
'маноск' => 'manosque',
|
||||
'кавайон' => 'cavaillon',
|
||||
'мантон' => 'menton',
|
||||
'сен-рафаэль' => 'saint-raphael',
|
||||
'канн-сюр-мер' => 'cagnes-sur-mer',
|
||||
|
||||
/* ================= Occitanie ================= */
|
||||
'тулуза' => 'toulouse',
|
||||
'монпелье' => 'montpellier',
|
||||
'ним' => 'nimes',
|
||||
'перпиньян' => 'perpignan',
|
||||
'безье' => 'beziers',
|
||||
'нарбон' => 'narbonne',
|
||||
'каркасон' => 'carcassonne',
|
||||
'альби' => 'albi',
|
||||
'тарб' => 'tarbes',
|
||||
'ош' => 'auch',
|
||||
'каор' => 'cahors',
|
||||
'рудез' => 'rodez',
|
||||
'монто-бан' => 'montauban',
|
||||
'сет' => 'sete',
|
||||
'коломье' => 'colomiers',
|
||||
'бланьяк' => 'blagnac',
|
||||
'кастр' => 'castres',
|
||||
|
||||
/* ================= Nouvelle-Aquitaine ================= */
|
||||
'бордо' => 'bordeaux',
|
||||
'лимож' => 'limoges',
|
||||
'пуа' => 'poitiers',
|
||||
'пау' => 'pau',
|
||||
'байонна' => 'bayonne',
|
||||
'биарриц' => 'biarritz',
|
||||
'ла-рошель' => 'la rochelle',
|
||||
'ниор' => 'niort',
|
||||
'ангулем' => 'angouleme',
|
||||
'перигё' => 'perigueux',
|
||||
'мон-де-марсан' => 'mont-de-marsan',
|
||||
'брив-ла-гайард' => 'brive-la-gaillarde',
|
||||
'бержерак' => 'bergerac',
|
||||
'ажен' => 'agen',
|
||||
'анксьон' => 'anglet',
|
||||
|
||||
/* ================= Grand Est ================= */
|
||||
'страсбург' => 'strasbourg',
|
||||
'реймс' => 'reims',
|
||||
'мец' => 'metz',
|
||||
'нанси' => 'nancy',
|
||||
'мюлуз' => 'mulhouse',
|
||||
'труа' => 'troyes',
|
||||
'колмар' => 'colmar',
|
||||
'эпиналь' => 'epinal',
|
||||
'шалон-ан-шампань' => 'chalons-en-champagne',
|
||||
'шарлевиль-мезьер' => 'charleville-mezieres',
|
||||
'седан' => 'sedan',
|
||||
'сент-дизье' => 'saint-dizier',
|
||||
'саррегемин' => 'sarreguemines',
|
||||
'илкирш-графенштаден' => 'illkirch-graffenstaden',
|
||||
'сент-луи' => 'saint-louis',
|
||||
'агно' => 'haguenau',
|
||||
'томблен' => 'tomblaine',
|
||||
|
||||
/* ================= Hauts-de-France ================= */
|
||||
'лилль' => 'lille',
|
||||
'рубай' => 'roubaix',
|
||||
'туркуэн' => 'tourcoing',
|
||||
'дакс' => 'dax', /* ignore if not needed */
|
||||
'амиен' => 'amiens',
|
||||
'валансен' => 'valenciennes',
|
||||
'дуэ' => 'douai',
|
||||
'денкерк' => 'dunkerque',
|
||||
'кале' => 'calais',
|
||||
'компьень' => 'compiegne',
|
||||
'сен-кантен' => 'saint-quentin',
|
||||
'аррас' => 'arras',
|
||||
'булонь-сюр-мер' => 'boulogne-sur-mer',
|
||||
'абвиль' => 'abbeville',
|
||||
'ламберсар' => 'lambersart',
|
||||
'вилнев-д-аск' => 'villeneuve-d-ascq',
|
||||
'курриер' => 'courrieres',
|
||||
'ли-ленн' => 'lievin',
|
||||
|
||||
/* ================= Pays de la Loire ================= */
|
||||
'нант' => 'nantes',
|
||||
'анже' => 'angers',
|
||||
'ле-ман' => 'le mans',
|
||||
'сен-назер' => 'saint-nazaire',
|
||||
'шоле' => 'cholet',
|
||||
'ла-рош-сюр-йон' => 'la roche-sur-yon',
|
||||
'лаваль' => 'laval',
|
||||
|
||||
/* ================= Bretagne ================= */
|
||||
'ренн' => 'rennes',
|
||||
'брест' => 'brest',
|
||||
'квенпер' => 'quimper',
|
||||
'ван' => 'vannes',
|
||||
'сен-мало' => 'saint-malo',
|
||||
'лориан' => 'lorient',
|
||||
'сен-бриё' => 'saint-brieuc',
|
||||
|
||||
/* ================= Normandie ================= */
|
||||
'руан' => 'rouen',
|
||||
'ле-авр' => 'le havre',
|
||||
'ле-аврр' => 'le havre',
|
||||
'каен' => 'caen',
|
||||
'шербур' => 'cherbourg',
|
||||
'эвре' => 'evreux',
|
||||
'сен-ло' => 'saint-lo',
|
||||
'флер' => 'flers',
|
||||
'алансон' => 'alencon',
|
||||
|
||||
/* ================= Centre–Val de Loire ================= */
|
||||
'тур' => 'tours',
|
||||
'орлеан' => 'orleans',
|
||||
'шартр' => 'chartres',
|
||||
'бурж' => 'bourges',
|
||||
'блуа' => 'blois',
|
||||
'шатору' => 'chateauroux',
|
||||
|
||||
/* ================= Bourgogne–Franche-Comté ================= */
|
||||
'дижон' => 'dijon',
|
||||
'безансон' => 'besancon',
|
||||
'оссер' => 'auxerre',
|
||||
'макон' => 'macon',
|
||||
'невр' => 'nevers',
|
||||
'шалон-сюр-сон' => 'chalon-sur-saone',
|
||||
'дан-ле-золе' => 'dole',
|
||||
|
||||
/* ================= Grand Ouest divers ================= */
|
||||
'ла-манш' => 'saint-lo', /* dept seat hint */
|
||||
'пуатье' => 'poitiers',
|
||||
'сан-назар' => 'saint-nazaire',
|
||||
'сан-мало' => 'saint-malo',
|
||||
'конкарно' => 'concarneau',
|
||||
'морле' => 'morlaix',
|
||||
'сен-брюк' => 'saint-brieuc',
|
||||
'баянн' => 'bayonne', /* duplicate safe */
|
||||
|
||||
/* ================= Corse ================= */
|
||||
'аджакьо' => 'ajaccio',
|
||||
'бастия' => 'bastia',
|
||||
|
||||
/* ================= Outre-mer ================= */
|
||||
'фор-де-франс' => 'fort-de-france',
|
||||
'басс-тер' => 'basse-terre',
|
||||
'каенн' => 'cayenne',
|
||||
'сен-дени' => 'saint-denis',
|
||||
'мамудзу' => 'mamoudzou',
|
||||
'пуан-а-питр' => 'pointe-a-pitre',
|
||||
|
||||
/* ================= Autres communes fréquentes ================= */
|
||||
'нанси' => 'nancy',
|
||||
'реймс' => 'reims',
|
||||
'амьен' => 'amiens',
|
||||
'тур' => 'tours',
|
||||
'ниш' => 'nice',
|
||||
'монтабан' => 'montauban',
|
||||
'канн' => 'cannes',
|
||||
'анси' => 'annecy',
|
||||
'везул' => 'vesoul',
|
||||
'перига' => 'perigueux',
|
||||
'лейон' => 'lyon',
|
||||
|
||||
/* ===== Variantes pour Saint-/Sainte- (дублеты «сен-» / «сент-») ===== */
|
||||
'сент-этьен' => 'saint-etienne',
|
||||
'сент-мало' => 'saint-malo',
|
||||
'сент-назер' => 'saint-nazaire',
|
||||
'сент-дени' => 'saint-denis',
|
||||
'сент-ло' => 'saint-lo',
|
||||
'сент-омер' => 'saint-omer',
|
||||
'сент-рафаэль' => 'saint-raphael',
|
||||
'сент-квентин' => 'saint-quentin',
|
||||
'сент-жермен-ан-ле' => 'saint-germain-en-laye',
|
||||
'сент-мор-де-фоссе' => 'saint-maur-des-fosses',
|
||||
'сент-дизье' => 'saint-dizier',
|
||||
'сент-луи' => 'saint-louis',
|
||||
'сент-брие' => 'saint-brieuc',
|
||||
|
||||
/* ===== Variantes avec articles Le/La/Les ===== */
|
||||
'ле-авр' => 'le havre',
|
||||
'ле-ман' => 'le mans',
|
||||
'ла-рош-сюр-йон' => 'la roche-sur-yon',
|
||||
'ле-кане' => 'le cannet',
|
||||
'ле-мюро' => 'les mureaux',
|
||||
'ле-сабль-д-олон' => 'les sables-d-olonne',
|
||||
|
||||
/* ===== Agglo parisienne complémentaires ===== */
|
||||
'аржантей' => 'argenteuil',
|
||||
'монфермей' => 'montfermeil',
|
||||
'вильжюиф' => 'villejuif',
|
||||
'валь-де-репюблик' => 'val-de-reuil', /* approx */
|
||||
'нежен-сюр-сен' => 'gennevilliers',
|
||||
'оржи' => 'orges', /* ignore if not used */
|
||||
|
||||
/* ===== Sud-Ouest complémentaires ===== */
|
||||
'байон' => 'bayonne',
|
||||
'мон-де-марсан' => 'mont-de-marsan',
|
||||
'песак' => 'pesac', /* if stored */
|
||||
|
||||
/* ===== Côte d’Azur complémentaires ===== */
|
||||
'сан-лерен-дю-вар' => 'saint-laurent-du-var',
|
||||
'вилльфранш-сюр-мер' => 'villefranche-sur-mer',
|
||||
'ле-кане' => 'le cannet',
|
||||
|
||||
/* ===== Languedoc complémentaires ===== */
|
||||
'безье' => 'beziers',
|
||||
'альес' => 'ales',
|
||||
'агд' => 'agde',
|
||||
|
||||
/* ===== Savoie / Haute-Savoie ===== */
|
||||
'анси-ле-вье' => 'annecy',
|
||||
'кранс-же' => 'cran-gevrier',
|
||||
'анмас' => 'annemasse',
|
||||
|
||||
/* ===== Divers couverts par translit mais utiles en alias ===== */
|
||||
'луара' => 'loire',
|
||||
'исер' => 'isere',
|
||||
'вар' => 'var',
|
||||
'воклюз' => 'vaucluse',
|
||||
'кукурон' => 'cucuron',
|
||||
];
|
||||
@@ -0,0 +1,912 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Montserrat:wght@100..900&display=swap");
|
||||
|
||||
:root {
|
||||
--red: #e03c3f;
|
||||
--white: #ffffff;
|
||||
--blue: #38378b;
|
||||
--mint: #06ffa8;
|
||||
--hero: #8ca9ff; /* bleu clair fond hero (d’après maquette) */
|
||||
--header: #f1f1f1;
|
||||
--radius: 22px;
|
||||
|
||||
--c-bg: #8fa2ff; /* fond bleu/violet actuel de ton hero */
|
||||
--c-dark: #38378b; /* bleu foncé de ta charte */
|
||||
--c-white: #ffffff;
|
||||
--c-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.only-mobile {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 956px) {
|
||||
.only-desktop {
|
||||
display: none;
|
||||
}
|
||||
.only-mobile {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
/*Boutons page accueil du site*/
|
||||
.steps {
|
||||
width: min(800px, 92vw);
|
||||
margin: 40px auto 0;
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
.step {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.step-badge {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-white);
|
||||
color: var(--c-dark);
|
||||
font-weight: 800;
|
||||
font-size: clamp(28px, 5vw, 56px);
|
||||
line-height: 1;
|
||||
box-shadow: var(--c-shadow);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
background: var(--c-white);
|
||||
color: var(--c-dark);
|
||||
font-weight: 700;
|
||||
font-size: clamp(16px, 2.6vw, 26px);
|
||||
padding: 14px 26px 14px 38px; /* pour chevauchement côté gauche */
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--c-shadow);
|
||||
margin-left: -30px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Étape 3 inversée */
|
||||
.step--reverse {
|
||||
flex-direction: row-reverse; /* inverse ordre badge et label */
|
||||
}
|
||||
|
||||
.step--reverse .step-label {
|
||||
padding: 14px 38px 14px 26px; /* espace à droite */
|
||||
margin-left: 0;
|
||||
margin-right: -30px; /* chevauchement côté droit */
|
||||
}
|
||||
/* tailles différentes pour chaque étape */
|
||||
.step:nth-child(1) .step-badge {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
font-size: clamp(28px, 5vw, 56px);
|
||||
}
|
||||
|
||||
.step:nth-child(2) .step-badge {
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
font-size: clamp(32px, 5.5vw, 64px);
|
||||
}
|
||||
|
||||
.step:nth-child(3) .step-badge {
|
||||
width: 158px;
|
||||
height: 158px;
|
||||
font-size: clamp(36px, 6vw, 72px);
|
||||
}
|
||||
|
||||
/* placements (comme sur ta maquette) */
|
||||
.step--left {
|
||||
justify-self: start;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.step--center {
|
||||
justify-self: center;
|
||||
transform: translateY(8px);
|
||||
padding-left: 230px;
|
||||
}
|
||||
.step--right {
|
||||
justify-self: end;
|
||||
transform: translateX(0);
|
||||
}
|
||||
/*Fin boutons rondes*/
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"Montserrat",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
Arial,
|
||||
sans-serif;
|
||||
color: #111;
|
||||
/*background: var(--hero);*/
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto; /* header, contenu, footer */
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.site-header {
|
||||
background: var(--header);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.site-header .container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
/* ==== LOGO: mobile vs desktop (béton) ==== */
|
||||
.brand {
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 0; /* évite petits décalages baseline */
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px; /* distance logo ↔ langue */
|
||||
}
|
||||
.brand .logo-mobile,
|
||||
.brand .logo-desktop {
|
||||
display: none !important; /* on cache tout par défaut */
|
||||
height: 40px; /* taille unique (tu ajustes) */
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Mobile: logo_u */
|
||||
@media (max-width: 767.98px) {
|
||||
.brand .logo-mobile {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: logo.png */
|
||||
@media (min-width: 768px) {
|
||||
.brand .logo-desktop {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Actions header */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.icon-btn {
|
||||
border: none;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* HERO */
|
||||
.hero {
|
||||
padding: 0 0 56px;
|
||||
background: var(--hero);
|
||||
}
|
||||
.hero .container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 18px;
|
||||
}
|
||||
.hero-title {
|
||||
color: #38378b;
|
||||
text-align: center;
|
||||
font-size: 48px;
|
||||
line-height: 1.16;
|
||||
font-weight: 800;
|
||||
margin: 5px 0 26px;
|
||||
}
|
||||
|
||||
/* Bouton LOGIN */
|
||||
.btn-login {
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
|
||||
/*font-size: clamp(12px, 2vw, 16px);*/
|
||||
}
|
||||
|
||||
/* grille 3 au-dessus + 1 en dessous */
|
||||
|
||||
/* style des blocs ovales */
|
||||
|
||||
.hero .row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
margin: 15px 0;
|
||||
flex-wrap: wrap; /* au cas où ; reste cohérent */
|
||||
}
|
||||
|
||||
.chip {
|
||||
appearance: none;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
color: #000000;
|
||||
width: 230px; /* largeur fixe */
|
||||
text-align: center; /* texte centré */
|
||||
padding: 12px 0; /* hauteur uniforme */
|
||||
border-radius: 15px; /* ovale */
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Footer plus large (haut) et gris, toujours en bas */
|
||||
footer {
|
||||
background: var(--header);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
padding: 22px 20px; /* plus haut qu’avant */
|
||||
}
|
||||
footer p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: #222;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/*===================================================================================================================*/
|
||||
|
||||
/*La partie affichage pour la page aprés la recherche*/
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
/* Liste : espace entre blocs */
|
||||
.pro-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px; /* augmente l’écart vertical */
|
||||
padding: 20px 0 40px;
|
||||
}
|
||||
|
||||
/* Carte = 4 colonnes : photo | infos | vidéo | bouton */
|
||||
.pro-card {
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.18);
|
||||
display: grid;
|
||||
/* NE PAS forcer start ici */
|
||||
align-items: stretch; /* <— important */
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
grid-template-columns: 260px 1.5fr 240px 180px;
|
||||
grid-template-areas: "photo info video cta";
|
||||
}
|
||||
|
||||
/* 1) Photo portrait, non déformée (recadrage OK) */
|
||||
.pro-photo {
|
||||
grid-area: photo;
|
||||
}
|
||||
.pro-photo img {
|
||||
width: 100%;
|
||||
aspect-ratio: 3/4; /* portrait */
|
||||
object-fit: cover; /* pas de déformation */
|
||||
display: block;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
/* 2) Infos */
|
||||
.pro-info {
|
||||
grid-area: info;
|
||||
padding: 0 6px;
|
||||
color: #1b1b1b;
|
||||
}
|
||||
.pro-name {
|
||||
margin: 0 0 8px;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.pro-spec {
|
||||
color: #38378b;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pro-desc {
|
||||
color: #3f3f3f;
|
||||
line-height: 1.45;
|
||||
font-size: 1.05rem; /* augmente légèrement la taille */
|
||||
max-width: 95%; /* laisse plus d’espace dans la colonne */
|
||||
}
|
||||
.pro-info .favori {
|
||||
top: 10px;
|
||||
right: 10px; /* passe à left:10px si tu le veux à gauche */
|
||||
z-index: 2;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
filter: drop-shadow(0 2px 6px rgba(6, 6, 6, 0.25));
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
}
|
||||
.pro-info .favori:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.pro-info .favori.ajoute {
|
||||
color: rgb(12, 12, 11);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* 3) Vidéo (vignette 16:9) */
|
||||
/* même gabarit pour lien vidéo ET placeholder */
|
||||
.pro-video,
|
||||
.pro-video.placeholder {
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 4 / 3; /* <- ton format souhaité */
|
||||
max-height: 180px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: #000; /* évite les bordures blanches */
|
||||
}
|
||||
|
||||
/* l'image occupe tout l'espace du cadre */
|
||||
.pro-video img,
|
||||
.pro-video.placeholder img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* optionnel: largeur de la colonne vidéo */
|
||||
.pro-video-col {
|
||||
display: flex;
|
||||
align-items: center; /* centre verticalement dans la colonne */
|
||||
justify-content: center;
|
||||
width: clamp(240px, 32vw, 200px);
|
||||
}
|
||||
|
||||
/* 4) Bouton dans sa colonne, centré verticalement et aligné à droite */
|
||||
.pro-cta-col {
|
||||
grid-area: cta;
|
||||
display: flex;
|
||||
align-items: center; /* centre verticalement */
|
||||
justify-content: center; /* centre horizontalement */
|
||||
}
|
||||
|
||||
.pro-cta {
|
||||
background: #e03c3f;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
|
||||
white-space: nowrap;
|
||||
/*font-size: 0.95rem;*/
|
||||
}
|
||||
|
||||
/* Langue choix*/
|
||||
/* Sélecteur de langue dans le header */
|
||||
.lang-switch {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
height: 42px;
|
||||
background: #fff;
|
||||
padding: 6px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
color: #38378b; /* bleu foncé */
|
||||
border-radius: 14px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
background: rgba(56, 55, 139, 0.08);
|
||||
}
|
||||
|
||||
.lang-btn.active {
|
||||
background: #38378b; /* bleu foncé */
|
||||
color: #fff;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Compact */
|
||||
.pro-card--sm {
|
||||
--pro-card-pad: 12px;
|
||||
--pro-card-gap: 10px;
|
||||
--pro-avatar: 72px;
|
||||
--pro-title: 16px;
|
||||
--pro-lines: 2;
|
||||
}
|
||||
|
||||
/* Large */
|
||||
.pro-card--lg {
|
||||
--pro-card-pad: 22px;
|
||||
--pro-card-gap: 18px;
|
||||
--pro-avatar: 100px;
|
||||
--pro-title: 20px;
|
||||
--pro-lines: 4;
|
||||
}
|
||||
|
||||
.results-title,
|
||||
.results-meta,
|
||||
.result-count {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-logo {
|
||||
--bs-btn-bg: #38378b;
|
||||
--bs-btn-border-color: #38378b;
|
||||
--bs-btn-hover-bg: #2f2e77;
|
||||
--bs-btn-hover-border-color: #2f2e77;
|
||||
--bs-btn-active-bg: #24235e;
|
||||
--bs-btn-active-border-color: #24235e;
|
||||
--bs-btn-color: #fff; /* blanc par défaut */
|
||||
--bs-btn-focus-shadow-rgb: 56, 55, 139; /* halo focus */
|
||||
}
|
||||
|
||||
/* On force la couleur du texte à blanc dans tous les états */
|
||||
.btn-logo,
|
||||
.btn-logo:hover,
|
||||
.btn-logo:active,
|
||||
.btn-logo:focus,
|
||||
.btn-logo:focus-visible {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Menu compact, largeur auto comme dans ta maquette */
|
||||
.help-menu {
|
||||
min-width: auto; /* override Bootstrap */
|
||||
width: max-content; /* s’adapte au contenu */
|
||||
max-width: 92vw; /* borne mobile */
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
.help-menu .dropdown-item {
|
||||
padding: 10px 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.help-menu .dropdown-item + .dropdown-item {
|
||||
margin-top: 6px;
|
||||
} /* petits espacements */
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.help-menu {
|
||||
max-width: 360px;
|
||||
} /* borne desktop */
|
||||
}
|
||||
|
||||
/* Taille tactile du bouton “?” */
|
||||
.question-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Menu plus étroit (recommandé) */
|
||||
.offcanvas-slim {
|
||||
--bs-offcanvas-width: clamp(240px, 80vw, 320px);
|
||||
}
|
||||
|
||||
/* Option: largeur calée au titre (plus “compacte”) */
|
||||
/* .offcanvas-slim{ --bs-offcanvas-width: fit-content; max-width: min(90vw, 360px); } */
|
||||
|
||||
/* Fallback minimal sans Bootstrap */
|
||||
.d-none {
|
||||
display: none !important;
|
||||
}
|
||||
.d-sm-inline {
|
||||
display: none !important;
|
||||
}
|
||||
.d-sm-none {
|
||||
display: inline !important;
|
||||
}
|
||||
.text-nowrap {
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.d-sm-inline {
|
||||
display: inline !important;
|
||||
}
|
||||
.d-sm-none {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==== VISUEL COMMUN POUR TOUS LES MENUS ==== */
|
||||
.menu-panel {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
min-width: 230px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Items menu principal (li custom OU .dropdown-item Bootstrap) */
|
||||
.menu-panel > li,
|
||||
.menu-panel .dropdown-item {
|
||||
padding: 12px 18px;
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.menu-panel > li + li,
|
||||
.menu-panel .dropdown-item + .dropdown-item {
|
||||
border-top: 1px solid #f1f1f1;
|
||||
}
|
||||
|
||||
.menu-panel > li:hover,
|
||||
.menu-panel .dropdown-item:hover {
|
||||
background: #f8f9fa; /* juste fond gris clair */
|
||||
}
|
||||
|
||||
.menu-panel a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ==== MENU “BURGER” CUSTOM ==== */
|
||||
.menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu-custom {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
display: none; /* fermé par défaut */
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.dropdown-menu-custom.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Sous-menu */
|
||||
.has-submenu .submenu-toggle {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0.55rem 1rem;
|
||||
font-weight: 400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submenu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.submenu > li {
|
||||
padding: 0.4rem 1rem 0.4rem 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.submenu > li:hover {
|
||||
background: #fff; /* juste fond gris clair */
|
||||
}
|
||||
|
||||
.submenu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ==== MENU “?” BOOTSTRAP ==== */
|
||||
.dropdown-menu.menu-panel {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
min-width: 230px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Sécurité : que rien ne coupe les menus */
|
||||
.site-header,
|
||||
.header-actions,
|
||||
.menu-wrap,
|
||||
.dropdown {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Bouton avatar (menu principal) */
|
||||
#btnMenu {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent; /* enlève flash bleu sur mobile */
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* Supprime le halo/cadre bleu par défaut */
|
||||
#btnMenu:focus,
|
||||
#btnMenu:active {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Si tu veux garder une indication focus clavier accessible */
|
||||
#btnMenu:focus-visible {
|
||||
outline: 2px solid var(--color-blue); /* ou une couleur douce */
|
||||
outline-offset: 3px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.footer-top-links {
|
||||
color: #38378b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer-top-links a {
|
||||
color: inherit; /* hérite du bleu foncé */
|
||||
}
|
||||
|
||||
/* ===== Personnalisation bandeau cookies (CookieConsent v3) ===== */
|
||||
#cc-main {
|
||||
--cc-font-family: "Montserrat", sans-serif;
|
||||
/* Couleurs de texte */
|
||||
--cc-primary-color: #38378b; /* titres/links -> bleu foncé */
|
||||
--cc-secondary-color: #38378b; /* paragraphe du bandeau */
|
||||
|
||||
/* Bouton principal (Accepter tout) -> rouge arrondi */
|
||||
--cc-btn-border-radius: 22px;
|
||||
--cc-btn-primary-bg: #e03c3f;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #e03c3f;
|
||||
--cc-btn-primary-hover-bg: #c92f33; /* rouge un peu plus foncé */
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #c92f33;
|
||||
|
||||
/* Boutons secondaires (Refuser / Paramétrer) -> fond blanc, bord gris, arrondi */
|
||||
--cc-btn-secondary-bg: #ffffff;
|
||||
--cc-btn-secondary-color: #38378b;
|
||||
--cc-btn-secondary-border-color: #d9dee3;
|
||||
--cc-btn-secondary-hover-bg: #f4f6f8;
|
||||
--cc-btn-secondary-hover-color: #38378b;
|
||||
--cc-btn-secondary-hover-border-color: #c9d0d6;
|
||||
|
||||
/* Séparateur & fond pieds/bords pour un rendu plus clean */
|
||||
--cc-separator-border-color: #eef2f5;
|
||||
--cc-footer-bg: #ffffff;
|
||||
--cc-footer-color: #38378b;
|
||||
|
||||
/* Optionnel: arrondir la carte elle-même */
|
||||
--cc-modal-border-radius: 22px;
|
||||
}
|
||||
|
||||
/* Si tu veux forcer l’arrondi aussi quand des classes sont injectées */
|
||||
#cc-main .cm__btn,
|
||||
#cc-main .pm__btn {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
/*===================================================================================================================*/
|
||||
/* --- Container modal --- */
|
||||
.vmodal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
z-index: 99999;
|
||||
}
|
||||
.vmodal[aria-hidden="false"] {
|
||||
display: block;
|
||||
}
|
||||
.vmodal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* --- Boîte --- */
|
||||
.vmodal__dialog {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 1000px;
|
||||
width: 92%;
|
||||
margin: 40px auto;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden; /* pour arrondis propres */
|
||||
}
|
||||
|
||||
/* --- Header “Bootstrap-like” --- */
|
||||
.vmodal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: #fff;
|
||||
}
|
||||
.vmodal__title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* --- Close visible (rond blanc) --- */
|
||||
.vmodal__close {
|
||||
border: 0;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.vmodal__close:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
/* --- Body + ratio vidéo --- */
|
||||
.vmodal__body {
|
||||
padding: 1rem;
|
||||
}
|
||||
.vmodal__ratio {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: #000;
|
||||
}
|
||||
.vmodal__ratio iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* --- Footer --- */
|
||||
.vmodal__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: #fff;
|
||||
}
|
||||
.btn.btn-secondary-like {
|
||||
appearance: none;
|
||||
border: 1px solid #ced4da;
|
||||
background: #f8f9fa;
|
||||
color: #212529;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.btn-secondary-like:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
.no-results {
|
||||
text-align: center; /* centre le texte */
|
||||
color: #fff; /* texte en blanc */
|
||||
}
|
||||
/* --- Mobile --- */
|
||||
@media (max-width: 575.98px) {
|
||||
.vmodal__dialog {
|
||||
margin: 16px auto;
|
||||
width: 96%;
|
||||
}
|
||||
.vmodal__title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
.pager a {
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pager-arrow {
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e0e3eb;
|
||||
background: #fff;
|
||||
color: #38378b;
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.1s,
|
||||
opacity 0.2s;
|
||||
}
|
||||
.pager-arrow:hover {
|
||||
background: #f8f9ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.pager-arrow.is-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pager-page {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #e0e3eb;
|
||||
background: #fff;
|
||||
color: #38378b;
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.1s,
|
||||
color 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
.pager-page:hover {
|
||||
background: #f8f9ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.pager-page.is-active {
|
||||
background: #e03c3f;
|
||||
color: #fff;
|
||||
border-color: #e03c3f;
|
||||
transform: none;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
// Si quelqu'un essaie d'appeler directement /includes/db.php dans le navigateur, on coupe
|
||||
if (isset($_SERVER['SCRIPT_FILENAME']) && realpath(__FILE__) === realpath($_SERVER['SCRIPT_FILENAME'])) {
|
||||
http_response_code(403);
|
||||
exit('
|
||||
<!doctype html>
|
||||
<html lang="ru"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>403 — Accès refusé</title>
|
||||
<style>
|
||||
html,body{height:100%;margin:0}
|
||||
body{display:flex;align-items:center;justify-content:center;background:#f7f7f9;font-family:system-ui,Segoe UI,Roboto,Arial}
|
||||
.card{max-width:560px;width:92%;background:#fff;border:1px solid #e5e7eb;border-radius:14px;box-shadow:0 8px 24px rgba(0,0,0,.08);padding:28px;text-align:center}
|
||||
.emoji{font-size:48px;margin-bottom:8px}
|
||||
h1{margin:6px 0 10px;font-size:22px}
|
||||
p{margin:0 0 18px;color:#6b7280}
|
||||
.row{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
|
||||
.btn{padding:10px 14px;border-radius:999px;text-decoration:none;display:inline-block}
|
||||
.btn-primary{background:#E03C3F;color:#fff}
|
||||
.btn-outline{border:1px solid #d1d5db;color:#fff;background:#38378B}
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="card">
|
||||
<div class="emoji"></div>
|
||||
<h1>Accès refusé</h1>
|
||||
<p>Vous n’êtes pas autorisé à consulter cette page.</p>
|
||||
<div class="row">
|
||||
<a class="btn btn-primary" href="/connexion.php">Se connecter</a>
|
||||
<a class="btn btn-outline" href="/">Retour à l’accueil</a>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>');
|
||||
}
|
||||
$host = getenv('APP_DB_HOST') ?: 'localhost';
|
||||
$dbname = getenv('APP_DB_NAME') ?: '';
|
||||
$user = getenv('APP_DB_USER') ?: '';
|
||||
$password = getenv('APP_DB_PASS') ?: '';
|
||||
$charset = getenv('APP_DB_CHARSET') ?: 'utf8mb4';
|
||||
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
"mysql:host=$host;dbname=$dbname;charset=$charset",
|
||||
$user,
|
||||
$password,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
error_log('DB connect error: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
exit('Erreur de connexion à la base de données. racine');
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,507 @@
|
||||
/* =========================================================
|
||||
1) Liens / Chips
|
||||
========================================================= */
|
||||
.chip-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
2) Grand panneau blanc
|
||||
========================================================= */
|
||||
.home-panel {
|
||||
max-width: 850px;
|
||||
margin: 22px auto 0;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
border-radius: 22px;
|
||||
/*box-shadow: 0 10px 26px rgba(0, 0, 0, 0.14);*/
|
||||
overflow: hidden;
|
||||
}
|
||||
.home-panel__inner {
|
||||
padding: 18px 16px;
|
||||
}
|
||||
.home-panel__divider {
|
||||
height: 1px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
3) Checklist
|
||||
========================================================= */
|
||||
.home-checklist {
|
||||
padding: 14px 14px 10px;
|
||||
}
|
||||
.home-checklist ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.home-checklist li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
color: #1b1b1b;
|
||||
}
|
||||
.home-check {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: rgba(6, 255, 168, 0.18);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.home-check svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
4) Titres
|
||||
========================================================= */
|
||||
.home-h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #1b1b1b;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
5) Liste praticiens
|
||||
========================================================= */
|
||||
.hp-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* --- Carte --- */
|
||||
.hp-card {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.08);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* --- Ligne 1 : avatar | texte | rating --- */
|
||||
.hp-row1 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hp-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.hp-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hp-name {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #111;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.hp-spec {
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* --- Rating (étoiles base + fill) --- */
|
||||
.hp-rating {
|
||||
text-align: right;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.hp-stars {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
font-size: 13px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.hp-stars .stars-base {
|
||||
color: #ddd;
|
||||
}
|
||||
.hp-stars .stars-fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: #f5b301;
|
||||
}
|
||||
|
||||
.hp-note {
|
||||
margin-top: 2px;
|
||||
font-size: 0.85rem;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* --- Ligne 2 : Online (gauche) | bouton (droite) --- */
|
||||
.hp-row2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
padding-left: 68px; /* aligné sous le texte (56 avatar + gap) */
|
||||
}
|
||||
|
||||
.hp-online {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
opacity: 0.9;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Bouton --- */
|
||||
.hp-btn {
|
||||
background: var(--red, #e03c3f);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
min-width: 110px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.16);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
6) Mini steps
|
||||
========================================================= */
|
||||
.hp-steps {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.08);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
/* --- Nouveau : entête (sobre) --- */
|
||||
.hp-steps__head {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hp-steps__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
color: #111;
|
||||
}
|
||||
.hp-steps__sub {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* --- Nouveau : grille steps propre (sans <br>) --- */
|
||||
.hp-steps__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.hp-steps__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.hp-steps__arr {
|
||||
color: #6b7280;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
7) Articles
|
||||
========================================================= */
|
||||
.hp-articles {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.ha {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
color: #111;
|
||||
}
|
||||
.ha-img {
|
||||
height: 92px;
|
||||
background: #e9edf8;
|
||||
}
|
||||
.ha-body {
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
.ha-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ha-sub {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
color: #6b7280;
|
||||
}
|
||||
.hp-all {
|
||||
text-align: center;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.hp-all a {
|
||||
font-weight: 600;
|
||||
color: #1f3bb3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.spec-cta-wrap {
|
||||
margin-top: 24px;
|
||||
padding: 28px 22px;
|
||||
border-radius: 12px;
|
||||
|
||||
background: #8ca9ff;
|
||||
box-shadow: 0 20px 40px rgba(127, 91, 255, 0.2);
|
||||
|
||||
color: #fff;
|
||||
|
||||
/* ⬇️ IMPORTANT */
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto; /* gauche | milieu | droite */
|
||||
align-items: center;
|
||||
column-gap: 22px;
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Titre */
|
||||
.spec-cta-wrap h3 {
|
||||
margin: 0;
|
||||
font-size: 34px; /* pour se rapprocher de la capture 2 */
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap; /* optionnel: évite retour ligne */
|
||||
}
|
||||
|
||||
.spec-cta-wrap p {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
opacity: 0.95;
|
||||
line-height: 1.35;
|
||||
max-width: 520px; /* pour un look “milieu” */
|
||||
}
|
||||
|
||||
/* Bouton blanc */
|
||||
.spec-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
|
||||
padding: 12px 24px;
|
||||
border-radius: 999px;
|
||||
|
||||
background: #fff;
|
||||
color: #8ca9ff;
|
||||
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
|
||||
text-decoration: none;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.12);
|
||||
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
/* Hover */
|
||||
.spec-cta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 22px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* Active */
|
||||
.spec-cta:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
/* Barre style "carte" */
|
||||
.searchbar {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 14px;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
margin-left: 6px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-width: 0; /* IMPORTANT: évite débordements */
|
||||
}
|
||||
|
||||
/* Champs “plats” (comme sur ta capture) */
|
||||
.searchbar input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
padding: 10px 8px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.searchbar input::placeholder {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* séparateur */
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 34px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Bouton intégré à droite */
|
||||
.btn-find--inbar {
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-find--inbar:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-find--inbar:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Suggestions dropdown */
|
||||
.suggest {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 6px 0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.14);
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
z-index: 20;
|
||||
}
|
||||
.suggest li {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
}
|
||||
.suggest li:hover {
|
||||
background: #f6f6f6;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
8) Mobile
|
||||
========================================================= */
|
||||
@media (max-width: 720px) {
|
||||
.hp-btn {
|
||||
min-width: 112px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
.hp-row2 {
|
||||
padding-left: 4px;
|
||||
}
|
||||
.ha-img {
|
||||
height: 90px;
|
||||
}
|
||||
.hp-articles {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hp-steps__title {
|
||||
font-size: 17px;
|
||||
}
|
||||
.hp-steps__sub {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.hp-steps__item {
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.spec-cta-wrap {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 12px;
|
||||
text-align: center;
|
||||
justify-items: center;
|
||||
padding: 22px 18px;
|
||||
}
|
||||
|
||||
.spec-cta-wrap h3 {
|
||||
font-size: 26px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.spec-cta-wrap p {
|
||||
font-size: 15px;
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
.spec-cta {
|
||||
width: 50%;
|
||||
max-width: 340px;
|
||||
}
|
||||
.hero {
|
||||
background: #f3f4f8;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
// NE PAS versionner ce fichier (ajoute-le au .gitignore)
|
||||
return [
|
||||
'smtp' => [
|
||||
'host' => 'smtp-relay.brevo.com',
|
||||
'port' => 587,
|
||||
'secure' => 'tls', // 'tls' (STARTTLS) ou 'ssl' si 465
|
||||
'auth' => true,
|
||||
'username' => getenv('BREVO_USERNAME') ?: '', // boîte SMTP dédiée
|
||||
'password' => getenv('BREVO_PASSWORD') ?: '', // ⚠️ remplace par le vrai mdp
|
||||
// 'password' => getenv('SMTP_PASS'), // (option) via variable d'env
|
||||
],
|
||||
'from' => ['no-reply@uslugi.fr', 'USLUGI.FR'], // From par défaut
|
||||
'reply_to' => ['contact@uslugi.fr', 'USLUGI.FR'], // Reply-To global
|
||||
'bounce' => 'bounce@uslugi.fr', // Return-Path
|
||||
'suppress_auto' => true, // Ajoute des entêtes pour réduire OOO/auto-replies
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
$MAILCFG = require __DIR__.'/mail_config.php';
|
||||
|
||||
/**
|
||||
* Envoie un mail HTML via SMTP Gandi.
|
||||
*
|
||||
* @param string $to Adresse destinataire
|
||||
* @param string $subject Sujet (UTF-8)
|
||||
* @param string $html Corps HTML
|
||||
* @param string|null $text Alternative texte (sinon strip_tags)
|
||||
* @param array $opts ['to_name','cc'=>[],'bcc'=>[],'attachments'=>[],'headers'=>[],'from'=>[email,name],'reply_to'=>[email,name]]
|
||||
* @return array [bool success, ?string error]
|
||||
*/
|
||||
function uslugi_mail(string $to, string $subject, string $html, ?string $text = null, array $opts = []): array {
|
||||
global $MAILCFG;
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
try {
|
||||
// SMTP
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $MAILCFG['smtp']['host'];
|
||||
$mail->Port = $MAILCFG['smtp']['port'];
|
||||
$mail->SMTPSecure = $MAILCFG['smtp']['secure'];
|
||||
$mail->SMTPAuth = $MAILCFG['smtp']['auth'];
|
||||
$mail->Username = $MAILCFG['smtp']['username'];
|
||||
$mail->Password = $MAILCFG['smtp']['password'];
|
||||
|
||||
// Base
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Encoding = 'base64';
|
||||
$mail->XMailer = ''; // pas d'X-Mailer bavard
|
||||
|
||||
// Expéditeur / Reply-To
|
||||
$from = $opts['from'] ?? $MAILCFG['from'];
|
||||
$rpto = $opts['reply_to'] ?? $MAILCFG['reply_to'];
|
||||
$mail->setFrom($from[0], $from[1] ?? '');
|
||||
if (!empty($rpto[0])) $mail->addReplyTo($rpto[0], $rpto[1] ?? '');
|
||||
|
||||
// Return-Path (enveloppe) → SPF/DMARC alignés
|
||||
if (!empty($MAILCFG['bounce'])) $mail->Sender = $MAILCFG['bounce'];
|
||||
|
||||
// Destinataires
|
||||
$mail->addAddress($to, $opts['to_name'] ?? '');
|
||||
foreach (($opts['cc'] ?? []) as $cc) { $mail->addCC($cc); }
|
||||
foreach (($opts['bcc'] ?? []) as $bcc) { $mail->addBCC($bcc); }
|
||||
|
||||
// Pièces jointes
|
||||
foreach (($opts['attachments'] ?? []) as $k => $v) {
|
||||
if (is_int($k)) $mail->addAttachment($v);
|
||||
else $mail->addAttachment($k, $v);
|
||||
}
|
||||
|
||||
// Entêtes optionnelles
|
||||
foreach (($opts['headers'] ?? []) as $k => $v) {
|
||||
$mail->addCustomHeader($k, $v);
|
||||
}
|
||||
if (!empty($MAILCFG['suppress_auto'])) {
|
||||
$mail->addCustomHeader('Auto-Submitted', 'auto-generated');
|
||||
$mail->addCustomHeader('X-Auto-Response-Suppress', 'All');
|
||||
}
|
||||
|
||||
// Contenu
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject; // PHPMailer encode proprement
|
||||
$mail->Body = $html;
|
||||
$mail->AltBody = $text ?? strip_tags($html);
|
||||
|
||||
$mail->send();
|
||||
return [true, null];
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('PHPMailer error: '.$mail->ErrorInfo);
|
||||
return [false, $mail->ErrorInfo];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<?php //Liste des langues disponibles pour traduction de site ?>
|
||||
<a href="/public/change_lang.php?lang=ru" class="lang-btn <?= ($_SESSION['lang'] ?? 'ru') === 'ru' ? 'active' : '' ?>">РУ</a>
|
||||
<a href="/public/change_lang.php?lang=fr" class="lang-btn <?= ($_SESSION['lang'] ?? 'ru') === 'fr' ? 'active' : '' ?>">FR</a>
|
||||
@@ -0,0 +1,937 @@
|
||||
/* ==========================================================================
|
||||
mobile.css — Overrides globaux mobile (≤ 956px)
|
||||
Charge CE FICHIER EN DERNIER
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 956px) {
|
||||
/* ---------- Garde-fous généraux ---------- */
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden; /* coupe tout débordement horizontal */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
img,
|
||||
video,
|
||||
iframe {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh; /* hauteur écran fiable iOS/Android */
|
||||
padding-left: env(safe-area-inset-left, 0);
|
||||
padding-right: env(safe-area-inset-right, 0);
|
||||
}
|
||||
|
||||
.row {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important; /* ✅ symétrie gauche/droite */
|
||||
}
|
||||
|
||||
/* ==== VISUEL COMMUN POUR TOUS LES MENUS ==== */
|
||||
.menu-panel {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
min-width: 230px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Items (li custom OU .dropdown-item Bootstrap) */
|
||||
.menu-panel > li,
|
||||
.menu-panel .dropdown-item {
|
||||
padding: 12px 18px;
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-panel > li + li,
|
||||
.menu-panel .dropdown-item + .dropdown-item {
|
||||
border-top: 1px solid #f1f1f1;
|
||||
}
|
||||
|
||||
.menu-panel a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ==== MENU “BURGER” CUSTOM ==== */
|
||||
.menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.dropdown-menu-custom {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0; /* ← fini le -180px */
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
display: none; /* fermé par défaut */
|
||||
z-index: 1100;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dropdown-menu-custom.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Sous-menu admin (inchangé) */
|
||||
.has-submenu .submenu-toggle {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0.55rem 1rem;
|
||||
font-weight: 400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
color: #000;
|
||||
}
|
||||
.submenu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0.5rem 0;
|
||||
}
|
||||
.submenu > li {
|
||||
padding: 0.4rem 1rem 0.4rem 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.submenu > li:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.submenu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ==== MENU “?” BOOTSTRAP ==== */
|
||||
.dropdown-menu.menu-panel {
|
||||
/* on garde le comportement Bootstrap */
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
min-width: 230px;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Sécurité : que rien ne coupe les menus */
|
||||
.site-header,
|
||||
.header-actions,
|
||||
.menu-wrap,
|
||||
.dropdown {
|
||||
overflow: visible;
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ---------- HERO ---------- */
|
||||
.hero-title {
|
||||
font-size: 40px;
|
||||
}
|
||||
.results-title,
|
||||
.results-meta,
|
||||
.result-count {
|
||||
margin-top: 12px;
|
||||
color: #38378b;
|
||||
text-align: center;
|
||||
}
|
||||
/* Chips : rangées centrées et espacées de façon homogène */
|
||||
.hero .row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center; /* ✅ centrage horizontal des chips */
|
||||
gap: 8px 10px; /* espace cohérent */
|
||||
}
|
||||
.chip {
|
||||
font-size: 12px;
|
||||
width: 110px; /* tu peux passer en auto + padding si tu veux */
|
||||
}
|
||||
|
||||
/* ---------- Steps (mobile) ---------- */
|
||||
.step-label {
|
||||
background: var(--c-white);
|
||||
color: var(--c-dark);
|
||||
font-weight: 700;
|
||||
font-size: clamp(13px, 2.6vw, 26px);
|
||||
padding: 14px 26px 14px 38px; /* pour chevauchement côté gauche */
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--c-shadow);
|
||||
margin-left: -30px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* tailles différentes pour chaque étape */
|
||||
.step:nth-child(1) .step-badge {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
font-size: clamp(41px, 5vw, 56px);
|
||||
}
|
||||
.step:nth-child(2) .step-badge {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
font-size: clamp(48px, 5.5vw, 64px);
|
||||
}
|
||||
.step:nth-child(3) .step-badge {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
font-size: clamp(52px, 6vw, 72px);
|
||||
}
|
||||
|
||||
.step--left {
|
||||
justify-self: start;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.step--right {
|
||||
justify-self: end;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* ❗️Supprime le décalage latéral au milieu (source d’asymétrie visuelle) */
|
||||
.step--center {
|
||||
justify-self: center;
|
||||
transform: translateY(12px);
|
||||
padding-left: 100px; /* ✅ avant : 100px → crée l’effet “plus d’espace à gauche” */
|
||||
}
|
||||
|
||||
/* ---------- Composants transverses ---------- */
|
||||
main,
|
||||
.main,
|
||||
.main1,
|
||||
.main2 {
|
||||
display: block !important;
|
||||
align-items: initial !important;
|
||||
justify-content: initial !important;
|
||||
background: transparent !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.text-end {
|
||||
text-align: initial !important;
|
||||
}
|
||||
.text-end .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card,
|
||||
.card * {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card,
|
||||
.card.shadow-sm.p-3,
|
||||
.card.shadow-sm.p-3.avis-card {
|
||||
min-width: 0 !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/*
|
||||
.profile-grid > * { min-width: 0; min-height: 0; }
|
||||
|
||||
.big-photo { width: 100% !important; max-height: 400px; overflow: hidden; border-radius: 16px; }
|
||||
.big-photo img { width: 100% !important; height: auto !important; object-fit: cover; display: block; }
|
||||
|
||||
.info-scroll, .vlist { max-height: none !important; overflow: visible !important; padding-right: 0 !important; }
|
||||
|
||||
.vlist { display: grid !important; grid-template-columns: 1fr !important; gap: 12px !important; }
|
||||
.vlist .vitem { width: 100%; border-radius: 16px; overflow: hidden; background: #000; aspect-ratio: 21 / 9; }
|
||||
.vlist .vitem img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
.bottom-grid { display: grid !important; grid-template-columns: 1fr !important; gap: 12px !important; }
|
||||
.card .d-flex.align-items-center { flex-direction: column !important; align-items: flex-start !important; gap: 6px; }
|
||||
.avis-card .d-flex.justify-content-between { flex-direction: column !important; align-items: flex-start !important; gap: 4px; }
|
||||
.avis-card .meta + div { overflow: hidden; display: block; line-height: 1.5; max-height: calc(1.5em * 3); }
|
||||
|
||||
.big-photo, section.panel, aside.panel, .profile-grid, .bottom-grid, .card {
|
||||
position: static !important; float: none !important; transform: none !important;
|
||||
z-index: auto !important; margin-left: 0 !important; margin-right: 0 !important; clear: both !important;
|
||||
}*/
|
||||
|
||||
/* ---------- PROFIL ---------- */
|
||||
/* ---------- PROFIL (mobile) ---------- */
|
||||
.profile-grid {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr !important; /* ← empile en 1 colonne */
|
||||
grid-auto-rows: auto !important;
|
||||
gap: 16px !important;
|
||||
}
|
||||
.profile-grid.profile-top {
|
||||
height: auto !important;
|
||||
} /* annule clamp() s'il existe */
|
||||
.profile-grid > * {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* annule les hauteurs "90%" du desktop */
|
||||
.profile-grid .panel {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.big-photo {
|
||||
width: 100% !important;
|
||||
height: auto !important; /* ← annule le 90% */
|
||||
max-width: 100% !important;
|
||||
max-height: none !important; /* évite la “fenêtre” grise */
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.big-photo img {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.info-scroll,
|
||||
.vlist {
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.vlist {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
.bottom-grid {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
/* les cartes doivent pouvoir rétrécir */
|
||||
.card {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
/* ---------- LISTES / RÉSULTATS ---------- */
|
||||
.pro-list {
|
||||
gap: 16px !important;
|
||||
padding: 12px 0 24px !important;
|
||||
}
|
||||
.pro-card {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-areas: "photo" "info" "video" "cta" !important;
|
||||
gap: 12px !important;
|
||||
padding: 16px !important;
|
||||
}
|
||||
.pro-photo img {
|
||||
width: 100% !important;
|
||||
aspect-ratio: 3 / 4;
|
||||
object-fit: cover;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.pro-info {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.pro-video-col {
|
||||
width: 100% !important;
|
||||
display: block !important;
|
||||
}
|
||||
.pro-video,
|
||||
.pro-video.placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: none;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
.pro-cta-col {
|
||||
display: block !important;
|
||||
}
|
||||
.pro-cta {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---------- FAVORIS ---------- */
|
||||
.praticien-card {
|
||||
display: block !important;
|
||||
}
|
||||
.praticien-card > div:first-child {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.praticien-card .card-img-top {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.praticien-card > .card-body.p-0 {
|
||||
margin-bottom: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.praticien-card .d-flex {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.praticien-card .d-flex .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ---------- Suggestions (autocomplete) ---------- */
|
||||
.suggest,
|
||||
#suggestions {
|
||||
position: absolute !important;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0; /* => même largeur que l'input */
|
||||
z-index: 2000;
|
||||
|
||||
background: #fff;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 14px;
|
||||
padding: 8px 0;
|
||||
margin: 0;
|
||||
|
||||
max-height: 50vh; /* scrollable si long */
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
|
||||
width: auto !important; /* annule width:100% si besoin */
|
||||
}
|
||||
|
||||
.suggest li,
|
||||
#suggestions li {
|
||||
list-style: none;
|
||||
padding: 12px 16px;
|
||||
font-size: clamp(14px, 3.8vw, 18px);
|
||||
line-height: 1.25;
|
||||
cursor: pointer;
|
||||
}
|
||||
.suggest li + li,
|
||||
#suggestions li + li {
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
.suggest li:hover,
|
||||
#suggestions li:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Largeurs figées à neutraliser en mobile */
|
||||
.pto-list {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.site-footer {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/*---------Transformation table en cards-----------------------*
|
||||
***************************************************************/
|
||||
/* ===== TABLE -> CARTES (mobile) ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* ✅ N’affiche pas THEAD tant que data-label n’est pas prêt */
|
||||
#table_rdv thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
.mc-ready #table_rdv thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* neutralise centrage hérité */
|
||||
#table_rdv,
|
||||
#table_rdv tr,
|
||||
#table_rdv td,
|
||||
#table_rdv td * {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
/* wrapper bootstrap */
|
||||
.table-responsive {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
#table_rdv {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
#table_rdv tbody {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* chaque ligne -> carte */
|
||||
#table_rdv tr {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
#table_rdv tr::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 3px;
|
||||
background: #38378b;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* cellules empilées */
|
||||
#table_rdv td {
|
||||
display: block;
|
||||
padding: 10px 14px !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#table_rdv td + td {
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
/* libellés auto (et on masque s’ils sont vides) */
|
||||
#table_rdv td::before {
|
||||
content: attr(data-label);
|
||||
display: inline-block;
|
||||
min-width: 120px;
|
||||
margin-right: 8px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
#table_rdv td::before:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* hiérarchie visuelle (adapte si ordre diffère) */
|
||||
#table_rdv td:nth-child(1) {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
#table_rdv td:nth-child(2) {
|
||||
color: #6b7280;
|
||||
font-weight: 600;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
/* select pleine largeur + iOS align */
|
||||
#table_rdv select.form-select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
margin: 6px 0;
|
||||
text-align: left !important;
|
||||
text-align-last: left !important;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* actions */
|
||||
#table_rdv .row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
justify-content: flex-start !important;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
#table_rdv .row-actions .btn {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* neutralise utilitaires Bootstrap qui centrent */
|
||||
#table_rdv
|
||||
:is(.text-center, .text-sm-center, .text-md-center, .text-lg-center) {
|
||||
text-align: left !important;
|
||||
}
|
||||
#table_rdv :is(.mx-auto, .ms-auto, .me-auto) {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
#table_rdv
|
||||
:is(
|
||||
.justify-content-center,
|
||||
.justify-content-sm-center,
|
||||
.justify-content-md-center,
|
||||
.justify-content-lg-center
|
||||
) {
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
#table_rdv :is(.align-items-center) {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
/* retire les zébrures en mode carte */
|
||||
#table_rdv.table-striped > tbody > tr:nth-of-type(odd) > * {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
/******************************FIN TABLES MOBILE********************************************/
|
||||
@media (max-width: 576px) {
|
||||
/* Barre de recherche */
|
||||
.searchbar {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 14px;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.search-icon {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
flex: 1.3;
|
||||
}
|
||||
.searchbar input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: clamp(10px, 3vw, 16px);
|
||||
padding: 10px 4px;
|
||||
}
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: #e6e6e6;
|
||||
}
|
||||
}
|
||||
.footer-top-links {
|
||||
color: #38378b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer-top-links a {
|
||||
color: inherit; /* hérite du bleu foncé */
|
||||
}
|
||||
|
||||
/* Fallback si le navigateur ne supporte pas gap */
|
||||
@supports not (gap: 1rem) {
|
||||
.d-flex.gap-2 > * {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.d-flex.gap-3 > * {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.d-flex.gap-4 > * {
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
.d-flex[class*="gap-"] > *:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.scrollable {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.fullheight {
|
||||
min-height: 100vh;
|
||||
} /* défaut */
|
||||
@supports (height: 100dvh) {
|
||||
.fullheight {
|
||||
min-height: 100dvh;
|
||||
} /* iOS 16+ */
|
||||
}
|
||||
@supports not (height: 100dvh) {
|
||||
.fullheight {
|
||||
min-height: -webkit-fill-available;
|
||||
} /* iOS anciens */
|
||||
}
|
||||
|
||||
/*---------Transformation table en cards-----------------------*
|
||||
***************************************************************/
|
||||
/* ===== TABLE -> CARTES (mobile) ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* ✅ N’affiche pas THEAD tant que data-label n’est pas prêt */
|
||||
#table_rdv1 thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
.mc-ready #table_rdv1 thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* neutralise centrage hérité */
|
||||
#table_rdv1,
|
||||
#table_rdv1 tr,
|
||||
#table_rdv1 td,
|
||||
#table_rdv1 td * {
|
||||
text-align: left !important;
|
||||
}
|
||||
/* wrapper bootstrap */
|
||||
.table-responsive {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
#table_rdv1 {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
#table_rdv1 tbody {
|
||||
display: block;
|
||||
}
|
||||
/* chaque ligne -> carte */
|
||||
#table_rdv1 tr {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
#table_rdv1 tr::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 3px;
|
||||
background: #38378b;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* cellules empilées */
|
||||
#table_rdv1 td {
|
||||
display: block;
|
||||
padding: 10px 14px !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#table_rdv1 td + td {
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
/* libellés auto (et on masque s’ils sont vides) */
|
||||
#table_rdv1 td::before {
|
||||
content: attr(data-label);
|
||||
display: inline-block;
|
||||
min-width: 120px;
|
||||
margin-right: 8px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
#table_rdv1 td::before:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* hiérarchie visuelle (adapte si ordre diffère) */
|
||||
#table_rdv1 td:nth-child(1) {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
#table_rdv1 td:nth-child(2) {
|
||||
color: #6b7280;
|
||||
font-weight: 600;
|
||||
padding-top: 6px;
|
||||
}
|
||||
/* select pleine largeur + iOS align */
|
||||
#table_rdv1 select.form-select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
margin: 6px 0;
|
||||
text-align: left !important;
|
||||
text-align-last: left !important;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* actions */
|
||||
#table_rdv1 .row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
justify-content: flex-start !important;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
#table_rdv1 .row-actions .btn {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
margin: 0 !important;
|
||||
}
|
||||
/* neutralise utilitaires Bootstrap qui centrent */
|
||||
#table_rdv1
|
||||
:is(.text-center, .text-sm-center, .text-md-center, .text-lg-center) {
|
||||
text-align: left !important;
|
||||
}
|
||||
#table_rdv1 :is(.mx-auto, .ms-auto, .me-auto) {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
#table_rdv1
|
||||
:is(
|
||||
.justify-content-center,
|
||||
.justify-content-sm-center,
|
||||
.justify-content-md-center,
|
||||
.justify-content-lg-center
|
||||
) {
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
#table_rdv1 :is(.align-items-center) {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
/* retire les zébrures en mode carte */
|
||||
#table_rdv1.table-striped > tbody > tr:nth-of-type(odd) > * {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
/******************************FIN TABLES MOBILE********************************************/
|
||||
|
||||
/* Header fixe en haut */
|
||||
.site-header--fixed {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1030;
|
||||
}
|
||||
|
||||
/* Safe-area iOS (notch) */
|
||||
@supports (padding: max(0px)) {
|
||||
.site-header--fixed {
|
||||
padding-top: max(env(safe-area-inset-top), 0px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Espace en haut pour ne pas cacher le contenu */
|
||||
body.has-fixed-header {
|
||||
padding-top: 64px; /* ajuste si besoin */
|
||||
}
|
||||
|
||||
@supports (padding: max(0px)) {
|
||||
body.has-fixed-header {
|
||||
padding-top: calc(64px + max(env(safe-area-inset-top), 0px));
|
||||
}
|
||||
}
|
||||
|
||||
.pwa-install {
|
||||
position: fixed;
|
||||
right: 14px;
|
||||
top: 100px;
|
||||
bottom: auto;
|
||||
transform: translateY(-50%);
|
||||
z-index: 9999;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
background: #38378b;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.16);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pwa-install img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pwa-ios-hint {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pwa-ios-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 14px 14px 12px;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.pwa-ios-title {
|
||||
font-weight: 800;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.pwa-ios-text {
|
||||
line-height: 1.25;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pwa-ios-close {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 10px 12px;
|
||||
background: #e03c3f;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.pwa-install,
|
||||
.pwa-ios-hint {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/*.d-none{ display:none !important; }*/
|
||||
|
||||
@media (max-width: 956px) {
|
||||
/* Layout page : header + main qui prend l'espace + footer en bas */
|
||||
body {
|
||||
min-height: 100dvh; /* mieux que svh */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1 0 auto; /* prend l’espace vide */
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: auto; /* pousse le footer tout en bas */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
function refreshOutlookToken(PDO $pdo, int $praticien_id): ?string {
|
||||
$stmt = $pdo->prepare("SELECT refresh_token FROM outlook_tokens WHERE praticien_id = ?");
|
||||
$stmt->execute([$praticien_id]);
|
||||
$refresh_token = $stmt->fetchColumn();
|
||||
|
||||
if (!$refresh_token) return null;
|
||||
|
||||
$client_id = '8328e538-3cee-4731-9122-8b7fd6f461a9';
|
||||
$client_secret = 'xKY8Q~dygFtWlM62--4t0RVkAlFI3f5uspwOedkL';
|
||||
$token_url = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token';
|
||||
|
||||
$data = [
|
||||
'client_id' => $client_id,
|
||||
'client_secret' => $client_secret,
|
||||
'refresh_token' => $refresh_token,
|
||||
'grant_type' => 'refresh_token',
|
||||
'scope' => 'offline_access User.Read Calendars.ReadWrite'
|
||||
];
|
||||
|
||||
$options = [
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-type: application/x-www-form-urlencoded",
|
||||
'content' => http_build_query($data),
|
||||
],
|
||||
];
|
||||
|
||||
$response = file_get_contents($token_url, false, stream_context_create($options));
|
||||
$tokens = json_decode($response, true);
|
||||
|
||||
if (!isset($tokens['access_token'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$access_token = $tokens['access_token'];
|
||||
$refresh_token = $tokens['refresh_token'] ?? $refresh_token;
|
||||
$expires_at = time() + $tokens['expires_in'];
|
||||
|
||||
$pdo->prepare("UPDATE outlook_tokens SET access_token = ?, refresh_token = ?, expires_at = ? WHERE praticien_id = ?")
|
||||
->execute([$access_token, $refresh_token, $expires_at, $praticien_id]);
|
||||
|
||||
return $access_token;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
// --------------------------------------------------
|
||||
// BOOTSTRAP GLOBAL — exécuté UNE SEULE FOIS
|
||||
// --------------------------------------------------
|
||||
|
||||
if (!defined('APP_ROOT')) {
|
||||
define('APP_ROOT', realpath(__DIR__ . '/..')); // vhosts/staging.uslugi.fr
|
||||
}
|
||||
if (!defined('APP_HTDOCS')) {
|
||||
define('APP_HTDOCS', APP_ROOT . '/htdocs');
|
||||
}
|
||||
|
||||
// Charger env UNE FOIS
|
||||
if (empty($GLOBALS['ENV_LOADED'])) {
|
||||
require_once APP_ROOT . '/includes/env.php';
|
||||
app_load_env(APP_ROOT . '/.env');
|
||||
$GLOBALS['ENV_LOADED'] = true;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
// Évite la boucle quand router.php inclut la page
|
||||
if (defined('ROUTED_PRETTY')) return;
|
||||
|
||||
// Mapping vilain -> joli
|
||||
$UGLY_TO_PRETTY = [
|
||||
//racine
|
||||
'/index.php' => '/',
|
||||
'/result_search.php' => '/recherche',
|
||||
'/detail_public.php' => '/specialistes',
|
||||
'/connexion.php' => '/login',
|
||||
'/choix_inscr.php' => '/choix-inscription',
|
||||
'/inscription_client.php' => '/inscription-client',
|
||||
'/inscription_praticien.php' => '/inscription-praticien',
|
||||
'/mdp_oublie.php' => '/mot-de-passe-oublie',
|
||||
'/avis_public.php' => '/avis',
|
||||
//dossier client
|
||||
'/public/client/informations.php' => '/profil',
|
||||
'/public/client/modifier.php' => '/modifier-profil',
|
||||
'/public/client/compte.php' => '/mon-compte',
|
||||
'/public/client/list_praticien.php' => '/trouver-specialistes',
|
||||
'/public/client/mes_favori.php' => '/mes-preferences',
|
||||
'/public/client/detail_praticien.php' => '/specialiste',
|
||||
'/public/client/avis.php' => '/donner-avis',
|
||||
'/public/client/reserver_rdv.php' => '/reservation',
|
||||
'/public/client/mes_rendez_vous.php' => '/mes-rendez-vous',
|
||||
|
||||
//Dossier praticien
|
||||
'/public/praticien/informations.php' => '/profil-pro',
|
||||
'/public/praticien/modifier.php' => '/modifier-profil-pro',
|
||||
'/public/praticien/compte.php' => '/mon-compte-pro',
|
||||
'/public/praticien/disponibilites.php' => '/mes-creneaux',
|
||||
'/public/praticien/disponibilites_date.php' => '/mes-creneaux-date',
|
||||
'/public/praticien/rendez_vous.php' => '/mes-consultations',
|
||||
'/public/praticien/rendez_vous_du_jour.php' => '/consultations-du-jour',
|
||||
'/public/praticien/agenda.php' => '/agenda',
|
||||
'/public/praticien/synchronisation.php' => '/connecter-agenda',
|
||||
'/public/praticien/resiliation.php' => '/abonnement',
|
||||
'/public/praticien/factures.php' => '/mes-factures',
|
||||
|
||||
//Dossier messages
|
||||
|
||||
];
|
||||
|
||||
// Debug : ajouter ?_noredir=1 pour désactiver la redirection
|
||||
if (!empty($_GET['_noredir'])) return;
|
||||
|
||||
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '/';
|
||||
|
||||
if (isset($UGLY_TO_PRETTY[$path])) {
|
||||
$target = $UGLY_TO_PRETTY[$path];
|
||||
$qs = $_SERVER['QUERY_STRING'] ? ('?' . $_SERVER['QUERY_STRING']) : '';
|
||||
header('Location: ' . $target . $qs, true, 301);
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
// Ne PAS versionner ce fichier
|
||||
return [
|
||||
'site' => '6LcltswrAAAAAOQ3NwAgUbAu75WHQ9MsiFVd7ZVe', // publique
|
||||
'secret' => '6LcltswrAAAAABTicobq3NigOaO3hTFNZBieZyeO', // privée
|
||||
];
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
function has_cyrillic(string $s): bool {
|
||||
return (bool) preg_match('/\p{Cyrillic}/u', $s);
|
||||
}
|
||||
|
||||
function norm_key(string $s): string {
|
||||
$s = mb_strtolower(trim($s), 'UTF-8');
|
||||
$s = str_replace(['ё'], ['е'], $s); // homogénéiser RU
|
||||
$s = preg_replace('/[\'"’\-\s]+/u', '', $s); // enlève tirets, espaces, quotes
|
||||
return $s;
|
||||
}
|
||||
|
||||
// Translittération simple RU -> latin (fallback si intl n’est pas dispo)
|
||||
function translit_ru_to_lat(string $s): string {
|
||||
if (class_exists('Transliterator')) {
|
||||
// "Russian-Latin/BGN" produit Parizh (Париж) -> "parizh"
|
||||
$t = \Transliterator::create('Russian-Latin/BGN; Lower();');
|
||||
if ($t) return $t->transliterate($s);
|
||||
}
|
||||
$map = [
|
||||
'а'=>'a','б'=>'b','в'=>'v','г'=>'g','д'=>'d','е'=>'e','ё'=>'e','ж'=>'zh','з'=>'z','и'=>'i','й'=>'i','к'=>'k',
|
||||
'л'=>'l','м'=>'m','н'=>'n','о'=>'o','п'=>'p','р'=>'r','с'=>'s','т'=>'t','у'=>'u','ф'=>'f','х'=>'kh','ц'=>'ts',
|
||||
'ч'=>'ch','ш'=>'sh','щ'=>'shch','ъ'=>'','ы'=>'y','ь'=>'','э'=>'e','ю'=>'yu','я'=>'ya',
|
||||
];
|
||||
$s = mb_strtolower($s, 'UTF-8');
|
||||
return strtr($s, $map);
|
||||
}
|
||||
|
||||
// Retourne une liste de candidats latins (pour LIKE) à partir d’une saisie q
|
||||
function city_query_candidates(string $q): array {
|
||||
$q = trim($q);
|
||||
if ($q === '') return [];
|
||||
|
||||
$cands = [];
|
||||
$cands[] = mb_strtolower($q, 'UTF-8'); // saisie brute (si déjà latine)
|
||||
|
||||
// charger alias RU -> latin
|
||||
$aliases = @include __DIR__ . '/city_aliases_ru.php';
|
||||
if (!is_array($aliases)) $aliases = [];
|
||||
|
||||
if (has_cyrillic($q)) {
|
||||
// 1) correspondance par préfixe dans les alias (ПАР → Paris)
|
||||
$key = norm_key($q);
|
||||
foreach ($aliases as $ru => $lat) {
|
||||
if (strpos(norm_key($ru), $key) === 0) {
|
||||
$cands[] = $lat;
|
||||
}
|
||||
}
|
||||
// 2) translittération simple (Париж -> parizh)
|
||||
$cands[] = translit_ru_to_lat($q);
|
||||
}
|
||||
|
||||
// Normalisation légère des candidats (minuscules, accents retirés si possible)
|
||||
$res = [];
|
||||
foreach (array_unique($cands) as $c) {
|
||||
$lc = mb_strtolower($c, 'UTF-8');
|
||||
// enlever accents (si intl transliterator dispo)
|
||||
if (class_exists('Transliterator')) {
|
||||
$t = \Transliterator::create('Any-Latin; Latin-ASCII;');
|
||||
if ($t) $lc = $t->transliterate($lc);
|
||||
}
|
||||
$res[] = $lc;
|
||||
}
|
||||
return array_values(array_unique($res));
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
/* ==========================================================================
|
||||
site_style.css — Refactor v2 (mobile‑first, compatible Bootstrap)
|
||||
- Tokens centralisés (couleurs, rayons, ombres, largeurs)
|
||||
- Nettoyage des imbrications, corrections de sélecteurs, fusion des doublons
|
||||
- Breakpoints cohérents, priorités claires
|
||||
========================================================================== */
|
||||
|
||||
/* 1) Design tokens */
|
||||
:root {
|
||||
--color-red: #e03c3f;
|
||||
--color-white: #ffffff;
|
||||
--color-blue: #38378b;
|
||||
--color-mint: #06ffa8;
|
||||
--color-hero: #8ca9ff;
|
||||
--color-header: #f1f1f1;
|
||||
|
||||
--radius: 22px;
|
||||
--radius-lg: 24px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--shadow-card: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||
--shadow-pop: 0 6px 15px rgba(0, 0, 0, 0.15);
|
||||
|
||||
--container: 1200px;
|
||||
}
|
||||
|
||||
/* 2) Base & layout */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"Montserrat",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
Arial,
|
||||
sans-serif;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
background: var(--color-hero);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
/* Compat anciens sélecteurs 'main1' / 'main2' (existaient comme balises) */
|
||||
.main1 {
|
||||
flex: 1;
|
||||
background: var(--color-hero);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.main2 {
|
||||
flex: 1;
|
||||
background: var(--color-white);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* HERO */
|
||||
.hero {
|
||||
padding: 40px 0 56px;
|
||||
/*background: var(--color-hero);*/
|
||||
}
|
||||
.hero .container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 18px;
|
||||
}
|
||||
.hero-title {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 48px;
|
||||
line-height: 1.16;
|
||||
font-weight: 800;
|
||||
margin: 10px 0 26px;
|
||||
}
|
||||
.container {
|
||||
width: min(var(--container), 100% - 36px);
|
||||
margin-inline: auto;
|
||||
padding-inline: 18px;
|
||||
}
|
||||
.pro-name {
|
||||
margin: 0 0 8px;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* 3) Header & footer */
|
||||
.site-header {
|
||||
background: var(--color-header);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.site-header .container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-block: 10px;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
footer {
|
||||
background: var(--color-header);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
padding: 22px 20px;
|
||||
}
|
||||
footer p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: #222;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Barre style "carte" */
|
||||
.searchbar {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 14px;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
margin-left: 6px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-width: 0; /* IMPORTANT: évite débordements */
|
||||
}
|
||||
|
||||
/* Champs “plats” (comme sur ta capture) */
|
||||
.searchbar input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
padding: 10px 8px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.searchbar input::placeholder {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* séparateur */
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 34px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Bouton intégré à droite */
|
||||
.btn-find--inbar {
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 4) Boutons (override Bootstrap) */
|
||||
.btn-primary {
|
||||
background-color: var(--color-red) !important;
|
||||
border-color: var(--color-red) !important;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
.btn-primary a {
|
||||
color: #fff;
|
||||
} /* corrige l'ancien '.btn-primary :a' */
|
||||
|
||||
/* 5) Sélecteur de langue & menu utilisateur */
|
||||
.lang-switch {
|
||||
display: none;
|
||||
gap: 6px;
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.lang-btn {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-blue);
|
||||
border-radius: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.lang-btn:hover {
|
||||
background: rgba(56, 55, 139, 0.08);
|
||||
}
|
||||
.lang-btn.active {
|
||||
background: var(--color-blue);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 6) Page Détails praticien */
|
||||
.profile-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
/* grille bas de page (fusion + align fix) */
|
||||
.bottom-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: 0.8fr 1.6fr auto;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bottom-grid .text-end {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Rating (fusion doublons) */
|
||||
.rating {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
letter-spacing: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
.rating .stars-base {
|
||||
color: #e3e5ea;
|
||||
}
|
||||
.rating .stars-fill {
|
||||
color: #ffc107;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.avis-card .meta {
|
||||
font-size: 0.9rem;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
/* 8) Champs communs + force MDP (fusion doublons) */
|
||||
.field-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.toggle-password {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
}
|
||||
.strength-bar {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.strength-fill {
|
||||
flex: 1;
|
||||
background: #e0e0e0;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
/* 9) Listes & cartes (client) */
|
||||
.card {
|
||||
min-width: 380px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.card h4 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.btn-agenda {
|
||||
background-color: #4285f4;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.btn-agenda:hover {
|
||||
background-color: #2a69d1;
|
||||
}
|
||||
.card_sync {
|
||||
margin-bottom: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.praticien-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.card-img-top {
|
||||
max-width: 150px;
|
||||
aspect-ratio: 3/4;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.card-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.favori_lien {
|
||||
cursor: pointer;
|
||||
color: #055a92;
|
||||
transition:
|
||||
color 0.3s,
|
||||
transform 0.2s;
|
||||
}
|
||||
.favori_lien:hover {
|
||||
color: #d21427;
|
||||
}
|
||||
.favori_lien.ajoute {
|
||||
color: #d21427;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.card-img-top_liste {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
/* 10) Suggestions adresse */
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
#suggestions {
|
||||
position: absolute;
|
||||
background: #f1f1f1;
|
||||
border: 1px solid #ccc;
|
||||
width: 100%;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
#suggestions li {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#suggestions li:hover {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
/* 11) Partie avec scroll pour detail praticien pour scroller videos */
|
||||
.profile-grid.profile-top {
|
||||
/* hauteur comprise entre 420px et 780px, ~70% de la hauteur viewport */
|
||||
height: clamp(420px, 70vh, 780px);
|
||||
align-items: stretch; /* important pour que les colonnes prennent 100% de la hauteur */
|
||||
}
|
||||
|
||||
/* Permettre l’overflow interne (déjà presque bon chez toi) */
|
||||
.profile-grid > * {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.profile-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: 1.15fr 1fr 0.9fr;
|
||||
}
|
||||
|
||||
.profile-grid .panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* crucial */
|
||||
height: 90%; /* ← donner une hauteur de référence */
|
||||
}
|
||||
|
||||
/* La grande photo remplit la hauteur disponible */
|
||||
.big-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.big-photo {
|
||||
height: 90%;
|
||||
max-width: 350px;
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
/* contenu scrollable dans la colonne infos */
|
||||
.info-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto; /* scroll si ça dépasse */
|
||||
padding-right: 4px; /* petit espace pour la scrollbar */
|
||||
}
|
||||
|
||||
/* liste de vidéos scrollable (le <h5> reste fixe) */
|
||||
.vlist {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto; /* scroll si beaucoup de vignettes */
|
||||
}
|
||||
|
||||
/* Optionnel : taille des vignettes vidéo */
|
||||
.vlist .vitem {
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
aspect-ratio: 21 / 9; /* 21:9 compact et propre */
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.vlist .vitem img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #38378b; /* bleu foncé */
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
a,
|
||||
a:link,
|
||||
a:visited {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Personnalisation des boutons*/
|
||||
|
||||
/* Même hauteur / alignement que les autres boutons */
|
||||
.btn-table {
|
||||
vertical-align: middle; /* alignement dans la cellule */
|
||||
margin: 0; /* pas de marge parasite */
|
||||
}
|
||||
|
||||
/* Bord gris par défaut */
|
||||
.btn-outline-secondary {
|
||||
border: 1px solid #6c757d;
|
||||
color: #6c757d;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Fond gris au survol */
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: #6c757d !important;
|
||||
color: #fff; /* texte blanc pour contraste */
|
||||
}
|
||||
|
||||
/* Variante outline mais avec gris à la place du bleu */
|
||||
.btn-outline-primary.custom-gray {
|
||||
border-color: #6c757d; /* gris */
|
||||
color: #6c757d; /* texte gris */
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary.custom-gray:hover {
|
||||
background-color: #6c757d; /* fond gris au survol */
|
||||
color: #fff; /* texte blanc */
|
||||
border-color: #6c757d; /* garder la bordure grise */
|
||||
}
|
||||
|
||||
/*.swal2-actions .swal2-confirm.swal2-styled {
|
||||
box-shadow: 0 0 0 0.5px #6c757d;
|
||||
border: none;
|
||||
color: #6c757d;
|
||||
background-color: transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.swal2-actions .swal2-confirm.swal2-styled:hover {
|
||||
background-color: #6c757d;
|
||||
color: #fff;
|
||||
}
|
||||
.swal2-actions {
|
||||
margin-bottom: 12px;
|
||||
}*/
|
||||
|
||||
/* Dans la colonne actions du tableau */
|
||||
#table_rdv td {
|
||||
vertical-align: middle; /* centre verticalement la cellule */
|
||||
text-align: center; /* optionnel : centre horizontalement */
|
||||
}
|
||||
|
||||
/* Normaliser le <form> pour qu'il se comporte comme un bouton */
|
||||
#table_rdv td form {
|
||||
display: inline-block; /* au lieu de block */
|
||||
margin: 0; /* supprime la marge par défaut */
|
||||
}
|
||||
|
||||
/* Harmoniser <a> et <button> visuellement */
|
||||
#table_rdv td a.btn,
|
||||
#table_rdv td button.btn {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
line-height: 1.2; /* même hauteur de ligne */
|
||||
padding: 4px 10px; /* compacité identique */
|
||||
border-radius: 999px;
|
||||
box-shadow: none; /* même arrondi si tu l’utilises */
|
||||
}
|
||||
|
||||
/* Styles de tes variantes (si tu utilises ces classes) */
|
||||
.btn-table {
|
||||
border: 1px solid #6c757d;
|
||||
color: #6c757d;
|
||||
background: transparent;
|
||||
}
|
||||
.btn-table:hover {
|
||||
background: #6c757d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-table_del {
|
||||
border: 1px solid #dc3545;
|
||||
color: #dc3545;
|
||||
background: transparent;
|
||||
}
|
||||
.btn-table_del:hover {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* === Global button polish (après Bootstrap) === */
|
||||
:root {
|
||||
/*Ajuste ici la rondeur et l’ombre */
|
||||
--btn-radius: 999px; /*passe à 999px pour style "pill" */
|
||||
--btn-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
|
||||
--btn-shadow-hover: 0 8px 22px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.btn:not(.btn-link) {
|
||||
border-radius: var(--btn-radius);
|
||||
box-shadow: var(--btn-shadow);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.btn:not(.btn-link) {
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.08s ease,
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.btn:hover:not(.btn-link),
|
||||
.btn:focus-visible:not(.btn-link) {
|
||||
box-shadow: var(--btn-shadow-hover);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn:active:not(.btn-link) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 8px px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
.btn.disabled {
|
||||
box-shadow: none;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/*Exceptions / utilitaires */
|
||||
.btn.btn-link {
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
} /* les liens ne prennent pas d’ombre*/
|
||||
.btn--no-shadow {
|
||||
box-shadow: none !important;
|
||||
} /*opt-out par bouton si besoin */
|
||||
|
||||
/*********************PAGE PRISE RENDEZ-VOUS**************************/
|
||||
|
||||
.rdv-card {
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
/* Titres */
|
||||
.rdv-title {
|
||||
margin: 0 0 6px;
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
color: #6c757d;
|
||||
}
|
||||
.rdv-subtitle {
|
||||
margin: 0 0 22px;
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
color: #000;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
/* Pagination (boutons discrets “pill”) */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin: 6px 0 18px;
|
||||
}
|
||||
.pagination a {
|
||||
text-decoration: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: #fff;
|
||||
color: #38378b;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
transform 0.06s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
.pagination a:hover {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.1);
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
/* Grille des jours (responsive) */
|
||||
.grid-container {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
/******************Gestion des Slots affichage par 3 par ligne***************************/
|
||||
@media (min-width: 1200px) {
|
||||
.grid-container {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
/* Colonne “jour” (carte) */
|
||||
.day-column {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* En-tête jour */
|
||||
.day-column h4 {
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
background: #38378b;
|
||||
border-radius: 14px;
|
||||
padding: 8px 10px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
/* Slot (heure) */
|
||||
.slot {
|
||||
background: #f1f1f1;
|
||||
border: 1px solid #e6e9ff;
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #38378b;
|
||||
line-height: 1.25;
|
||||
box-shadow: inset 0 0 0 1px rgba(56, 55, 139, 0.05);
|
||||
}
|
||||
|
||||
/* Bouton “Réserver” (pill) */
|
||||
.slot .reserver-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
background: #e03c3f;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.12);
|
||||
transition:
|
||||
transform 0.06s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
.slot .reserver-btn:hover {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.16);
|
||||
background: #cc3538; /* léger foncé */
|
||||
}
|
||||
.slot .reserver-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* États “désactivé” si besoin (au cas où) */
|
||||
.slot.is-disabled {
|
||||
opacity: 0.55;
|
||||
filter: grayscale(20%);
|
||||
}
|
||||
.slot.is-disabled .reserver-btn {
|
||||
background: #d4d7e5;
|
||||
color: #fff;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Message “aucun créneau” */
|
||||
.rdv-empty {
|
||||
color: rgb(33, 37, 41);
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* SweetAlert – harmonisation rapide avec tes boutons pills gris (optionnel) */
|
||||
.swal2-actions .swal2-confirm.swal2-styled {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: #e03c3f;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.swal2-actions .swal2-confirm.swal2-styled:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
.swal2-actions .swal2-cancel.swal2-styled {
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #38378b;
|
||||
border: 1px solid #e0e3eb;
|
||||
}
|
||||
|
||||
/* Mobile: aération + swipe confortable si trop de colonnes */
|
||||
@media (max-width: 640px) {
|
||||
.rdv-card {
|
||||
padding: 18px;
|
||||
}
|
||||
.grid-container {
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: 1fr; /* empile les jours */
|
||||
}
|
||||
/* Variante swipe (si tu préfères conserver les colonnes) :
|
||||
dé-commente ce bloc et entoure .grid-container d’un wrapper .rdv-scroll
|
||||
.rdv-scroll{ overflow-x:auto; -webkit-overflow-scrolling:touch; }
|
||||
.grid-container{ grid-auto-flow: column; grid-auto-columns: 88%; }
|
||||
*/
|
||||
}
|
||||
|
||||
/**********************Paginantion pour le site ****************************/
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
.pager a {
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pager-arrow {
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e0e3eb;
|
||||
background: #fff;
|
||||
color: #38378b;
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.1s,
|
||||
opacity 0.2s;
|
||||
}
|
||||
.pager-arrow:hover {
|
||||
background: #f8f9ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.pager-arrow.is-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pager-page {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #e0e3eb;
|
||||
background: #fff;
|
||||
color: #38378b;
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.1s,
|
||||
color 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
.pager-page:hover {
|
||||
background: #f8f9ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.pager-page.is-active {
|
||||
background: #e03c3f;
|
||||
color: #fff;
|
||||
border-color: #e03c3f;
|
||||
transform: none;
|
||||
}
|
||||
/* =========================================
|
||||
Header FIXE seulement en mobile + PWA
|
||||
========================================= */
|
||||
|
||||
/* Desktop par défaut */
|
||||
.site-header {
|
||||
position: static;
|
||||
}
|
||||
|
||||
/* Mobile navigateur */
|
||||
@media (max-width: 899px) {
|
||||
.site-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
z-index: 1030;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: 64px; /* hauteur header mobile */
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA (même si largeur > 899px) */
|
||||
@media (display-mode: standalone) {
|
||||
.site-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
z-index: 1030;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: calc(64px + env(safe-area-inset-top));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// Map des clés de traduction -> valeur canonique (singulier) pour la BDD
|
||||
return [
|
||||
'ru' => [
|
||||
'avocats' => 'адвокат',
|
||||
'consultants' => 'консультант',
|
||||
'vrachi' => 'врач',
|
||||
'psychologues' => 'психолог',
|
||||
'repetitori' => 'преподаватель',
|
||||
'finance' => 'бухгалтер', // ajuste si besoin
|
||||
'perevod' => 'переводчик',
|
||||
'notarius' => 'нотариус',
|
||||
'gid' => 'гид',
|
||||
'rieltori' => 'риелтор',
|
||||
|
||||
|
||||
],
|
||||
'fr' => [
|
||||
'avocats' => 'адвокат',
|
||||
'consultants' => 'консультант',
|
||||
'vrachi' => 'врач', // clé existante "vrachi" côté traductions
|
||||
'psychologues' => 'психолог',
|
||||
'repetitori' => 'преподаватель', // ou "Répetiteur" selon ta BDD
|
||||
'finance' => 'бухгалтер', // ajuste si besoin
|
||||
'perevod' => 'переводчик',
|
||||
'notarius' => 'нотариус',
|
||||
'gid' => 'гид',
|
||||
'rieltori' => 'риелтор',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
// Charge les scripts de tracking uniquement pour des pages HTML vues en GET.
|
||||
// → Pas d’injection sur API/AJAX/assets/callbacks.
|
||||
if (PHP_SAPI === 'cli') return;
|
||||
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$xrw = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
|
||||
|
||||
$isGet = strcasecmp($method, 'GET') === 0;
|
||||
$wantsHtml = (stripos($accept, 'text/html') !== false) || ($accept === '');
|
||||
$wantsJson = stripos($accept, 'application/json') !== false;
|
||||
|
||||
$isAjax = strcasecmp($xrw, 'XMLHttpRequest') === 0;
|
||||
$isApi = preg_match('~/(api/|messages/api/|webhook|callback|notify)~i', $uri);
|
||||
$isAsset = preg_match('~\.(css|js|map|png|jpe?g|gif|svg|webp|ico|json|xml|txt|pdf|woff2?|ttf|eot|mp4|webm)(\?|$)~i', $uri);
|
||||
$isMatomo = stripos($uri, '/matomo/') !== false;
|
||||
$isRdv = preg_match('~/(valider_rdv\.php|confirmer_rdv\.php|rdv/)~i', $uri);
|
||||
|
||||
if ($isGet && $wantsHtml && !$wantsJson && !$isAjax && !$isApi && !$isAsset && !$isMatomo && !$isRdv) {
|
||||
// 100% externe (pas d’inline) → compatible CSP 'self' + domaines autorisés
|
||||
echo '<script src="/js/tracking.js" defer></script>';
|
||||
// Pixel noscript Matomo (ok même sans JS)
|
||||
echo '<noscript><p><img src="https://staging.uslugi.fr/matomo/matomo.php?idsite=1&rec=1" style="border:0" alt=""></p></noscript>';
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Uslugi.fr - Bientôt en ligne</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
background: #f7f7f7;
|
||||
color: #38378B;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
.logo {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #E03C3F;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
max-width: 500px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
.highlight {
|
||||
color: #e23310;
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 15px;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
.spinner {
|
||||
border: 4px solid #ddd;
|
||||
border-top: 4px solid #38378B;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1.5s linear infinite;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg);}
|
||||
100% { transform: rotate(360deg);}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="logo"><img src="/public/uploads/img/logo.png" width="200"></div>
|
||||
<h1>Техническое обслуживание</h1>
|
||||
<p>
|
||||
Проводятся технические работы на <span class="highlight">uslugi.fr</span>, мы улучшаем сервис.<br>
|
||||
Сайт будет снова доступен в ближайшее время. Спасибо за ваше терпение!
|
||||
</p>
|
||||
<div class="spinner"></div>
|
||||
<div class="footer">© 2026 Uslugi.fr — Tous droits réservés</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
require_once APP_HTDOCS . '/includes/avatar_initiales.php';
|
||||
|
||||
$SPEC_CANON = require __DIR__ . '/includes/spec_map.php';
|
||||
$canon = $SPEC_CANON[$lang] ?? [];
|
||||
|
||||
/* =========================
|
||||
AJAX: suggestions
|
||||
========================= */
|
||||
if (isset($_GET['ajax']) && $_GET['ajax'] === 'suggest') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$field = (string) ($_GET['field'] ?? '');
|
||||
$q = trim((string) ($_GET['q'] ?? ''));
|
||||
if (! in_array($field, ['specialite', 'ville'], true)) {echo json_encode([]);exit;}
|
||||
|
||||
require_once __DIR__ . '/includes/search_helpers.php';
|
||||
|
||||
$params = [];
|
||||
$sql = '';
|
||||
|
||||
if ($field === 'ville') {
|
||||
$sql = "SELECT DISTINCT ville AS val
|
||||
FROM praticien
|
||||
WHERE ville IS NOT NULL AND ville <> ''";
|
||||
|
||||
if ($q !== '') {
|
||||
$cands = city_query_candidates($q);
|
||||
$cands[] = mb_strtolower($q, 'UTF-8');
|
||||
$cands = array_values(array_unique($cands));
|
||||
|
||||
$ors = [];
|
||||
foreach ($cands as $i => $cand) {
|
||||
$ors[] = "LOWER(ville) LIKE :q$i";
|
||||
$params[":q$i"] = $cand . '%';
|
||||
}
|
||||
if ($ors) {
|
||||
$sql .= " AND (" . implode(' OR ', $ors) . ")";
|
||||
}
|
||||
|
||||
}
|
||||
$sql .= " ORDER BY val ASC LIMIT 15";
|
||||
|
||||
} else {
|
||||
$sql = "SELECT DISTINCT val FROM (
|
||||
SELECT specialite AS val
|
||||
FROM praticien
|
||||
WHERE specialite IS NOT NULL AND specialite <> ''
|
||||
UNION ALL
|
||||
SELECT CONCAT(prenom, ' ', nom) AS val
|
||||
FROM praticien
|
||||
WHERE prenom IS NOT NULL AND prenom <> '' AND nom IS NOT NULL AND nom <> ''
|
||||
UNION ALL
|
||||
SELECT prenom AS val
|
||||
FROM praticien
|
||||
WHERE prenom IS NOT NULL AND prenom <> ''
|
||||
UNION ALL
|
||||
SELECT nom AS val
|
||||
FROM praticien
|
||||
WHERE nom IS NOT NULL AND nom <> ''
|
||||
) t
|
||||
WHERE 1";
|
||||
|
||||
if ($q !== '') {
|
||||
$sql .= " AND LOWER(val) LIKE :q";
|
||||
$params[':q'] = mb_strtolower($q, 'UTF-8') . '%';
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY (LOWER(val) LIKE :qprefix) DESC, val ASC LIMIT 15";
|
||||
$params[':qprefix'] = ($q !== '') ? (mb_strtolower($q, 'UTF-8') . '%') : '';
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
echo json_encode($stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
exit;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Helpers
|
||||
========================= */
|
||||
function h($v): string
|
||||
{return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');}
|
||||
$reqUri = h($_SERVER['REQUEST_URI'] ?? '/');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?php echo h($lang ?? 'ru') ?>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title><?php echo h($translations['seo_index_title'] ?? '') ?></title>
|
||||
<meta name="description" content="<?php echo h($translations['seo_index_desc'] ?? '') ?>">
|
||||
<meta name="keywords" content="<?php echo h($translations['seo_index_keywords'] ?? '') ?>">
|
||||
<meta name="author" content="Uslugi.fr">
|
||||
<meta name="language" content="<?php echo h($lang ?? 'ru') ?>">
|
||||
|
||||
<!-- ✅ Canonical + Hreflang (SEO multilingue) -->
|
||||
<link rel="canonical" href="https://uslugi.fr<?php echo $reqUri ?>">
|
||||
<link rel="alternate" hreflang="ru" href="https://uslugi.fr/ru/">
|
||||
<link rel="alternate" hreflang="fr" href="https://uslugi.fr/fr/">
|
||||
<link rel="alternate" hreflang="x-default" href="https://uslugi.fr/">
|
||||
|
||||
<!-- ✅ Open Graph -->
|
||||
<meta property="og:title" content="<?php echo h($translations['seo_index_title'] ?? '') ?>">
|
||||
<meta property="og:description" content="<?php echo h($translations['seo_index_desc'] ?? '') ?>">
|
||||
<meta property="og:image" content="https://uslugi.fr/public/uploads/img/logo.png">
|
||||
<meta property="og:url" content="https://uslugi.fr<?php echo $reqUri ?>">
|
||||
<meta property="og:type" content="website">
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#38378B">
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/public/uploads/img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/public/uploads/img/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/public/uploads/img/favicon-96x96.png">
|
||||
<link rel="icon" href="/public/uploads/img/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" type="image/svg+xml" href="/public/uploads/img/favicon.svg">
|
||||
<meta name="apple-mobile-web-app-title" content="Uslugi.fr">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/public/uploads/img/apple-touch-icon.png">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
|
||||
<!-- DNS Prefetch -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="dns-prefetch" href="//fonts.googleapis.com">
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||
<?php if (! empty($logged)): ?>
|
||||
<link rel="stylesheet" href="<?php echo h(asset('/includes/site_style.css')) ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="stylesheet" href="<?php echo h(asset('/includes/custom_style.css')) ?>">
|
||||
<link rel="stylesheet" href="<?php echo h(asset('/includes/index_style.css')) ?>">
|
||||
<link rel="stylesheet" href="<?php echo h(asset('/includes/mobil_style.css')) ?>">
|
||||
<style>
|
||||
.site-header
|
||||
{
|
||||
background: #FFF;
|
||||
border-bottom: 0px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.site-footer
|
||||
{
|
||||
background: #FFF;
|
||||
border-top: 0px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
<!-- ✅ Schema (simple, tu peux enrichir plus tard) -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context":"https://schema.org",
|
||||
"@type":"LocalBusiness",
|
||||
"name":"Uslugi.fr",
|
||||
"url":"https://uslugi.fr",
|
||||
"logo":"https://uslugi.fr/public/uploads/img/logo.png",
|
||||
"address":{"@type":"PostalAddress","addressCountry":"FR"}
|
||||
}
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<?php if (! empty($logged)):
|
||||
include_header_clean(APP_HTDOCS . '/public/includes/header.php');
|
||||
else: ?>
|
||||
<!-- Header public identique -->
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<img src="/public/uploads/img/logo_u.png" alt="uslugi.fr" class="logo-mobile">
|
||||
<img src="/public/uploads/img/logo.png" alt="uslugi.fr" class="logo-desktop">
|
||||
</div>
|
||||
<div class="lang-switch">
|
||||
<?php require __DIR__ . '/includes/menu_langue.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="header-actions">
|
||||
<a class="btn-login" href="/connexion.php">
|
||||
<span class="d-sm-none"><?php echo h($translations['btn_se_connecter'] ?? '') ?></span>
|
||||
<span class="d-none d-sm-inline"><?php echo h($translations['ja_spec'] ?? '') ?></span>
|
||||
</a>
|
||||
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-logo rounded-circle d-inline-flex align-items-center justify-content-center question-btn"
|
||||
type="button" data-bs-toggle="dropdown" data-bs-auto-close="true"
|
||||
aria-expanded="false" aria-label="<?php echo h($translations['help'] ?? 'Помощь') ?>">?</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow help-menu menu-panel">
|
||||
<li><a href="/how-it-work.php"><?php echo h($translations['kak_rabotaet'] ?? '') ?></a></li>
|
||||
<li><a href="/about.php"><?php echo h($translations['pro_nas'] ?? '') ?></a></li>
|
||||
<li><a href="/faq.php"><?php echo h($translations['faq'] ?? '') ?></a></li>
|
||||
<li><a href="/contact.php"><?php echo h($translations['nam_pisat'] ?? '') ?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<div class="home-panel" role="region" aria-label="<?php echo h($translations['home_panel_aria'] ?? 'Accueil') ?>">
|
||||
<div class="container hero-inner">
|
||||
|
||||
<!-- PWA Install floating button (mobile only, hidden by default) ✅ -->
|
||||
<button id="pwaInstallBtn" class="pwa-install d-none" type="button" aria-label="Installer USLUGI">
|
||||
⤓ Uslugi
|
||||
</button>
|
||||
|
||||
<!-- iOS helper (hidden by default) ✅ -->
|
||||
<div id="pwaIosHint" class="pwa-ios-hint d-none" role="dialog" aria-modal="true">
|
||||
<div class="pwa-ios-card">
|
||||
<div class="pwa-ios-title">USLUGI.FR</div>
|
||||
<div class="pwa-ios-text">
|
||||
На iPhone: нажмите (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-up" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M3.5 6a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5h-2a.5.5 0 0 1 0-1h2A1.5 1.5 0 0 1 14 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 14.5v-8A1.5 1.5 0 0 1 3.5 5h2a.5.5 0 0 1 0 1z"/>
|
||||
<path fill-rule="evenodd" d="M7.646.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 1.707V10.5a.5.5 0 0 1-1 0V1.707L5.354 3.854a.5.5 0 1 1-.708-.708z"/>
|
||||
</svg>) <b>Поделиться,</b> затем <b>Добавить на экран</b>.<br><br>
|
||||
Sur iPhone : appuie sur <b>Partager</b> (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-up" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M3.5 6a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5v-8a.5.5 0 0 0-.5-.5h-2a.5.5 0 0 1 0-1h2A1.5 1.5 0 0 1 14 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 14.5v-8A1.5 1.5 0 0 1 3.5 5h2a.5.5 0 0 1 0 1z"/>
|
||||
<path fill-rule="evenodd" d="M7.646.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 1.707V10.5a.5.5 0 0 1-1 0V1.707L5.354 3.854a.5.5 0 1 1-.708-.708z"/>
|
||||
</svg>), puis <b>Sur l'écran d'accueil</b>.
|
||||
</div>
|
||||
<button id="pwaIosClose" class="pwa-ios-close" type="button">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
<h1 class="hero-title"><?php echo($translations['titre_home'] ?? '') ?></h1>
|
||||
<center><h5><?php echo($translations['sous_titre_home'] ?? '') ?></h5></center>
|
||||
<br>
|
||||
<?php
|
||||
// Chips -> liens (SEO + comme image)
|
||||
$chipKeysRow1 = ['avocats', 'notarius', 'rieltori'];
|
||||
$chipKeysRow2 = ['vrachi', 'psychologues', 'consultants'];
|
||||
$chipKeysRow3 = ['perevod', 'repetitori', 'drugie'];
|
||||
|
||||
$renderChipLinks = function (array $keys) use ($translations, $canon) {
|
||||
foreach ($keys as $k) {
|
||||
$label = $translations[$k] ?? $k;
|
||||
$query = ($k === 'drugie') ? '__OTHERS__' : ($canon[$k] ?? $label);
|
||||
|
||||
$href = '/result_search.php?' . http_build_query([
|
||||
'specialite' => $query,
|
||||
'ville' => '',
|
||||
]);
|
||||
|
||||
echo '<a class="chip chip-link" href="' . h($href) . '">' . h($label) . '</a>';
|
||||
}
|
||||
};
|
||||
?>
|
||||
<!--<h2 class="home-h2"><?php echo h($translations['home_popular_specialists'] ?? '') ?></h2>-->
|
||||
<div class="row"><?php $renderChipLinks($chipKeysRow1); ?></div>
|
||||
<div class="row"><?php $renderChipLinks($chipKeysRow2); ?></div>
|
||||
<div class="row"><?php $renderChipLinks($chipKeysRow3); ?></div>
|
||||
|
||||
<br>
|
||||
<div class="hp-list">
|
||||
<?php
|
||||
try {
|
||||
|
||||
/* ======================================================
|
||||
1️⃣ On récupère 50 IDs récents avec photo + abonnement actif
|
||||
====================================================== */
|
||||
$ids = $pdo->query("
|
||||
SELECT id
|
||||
FROM praticien
|
||||
WHERE photo IS NOT NULL
|
||||
AND photo <> ''
|
||||
AND date_expiration > DATE_ADD(NOW(), INTERVAL 2 DAY)
|
||||
ORDER BY id DESC
|
||||
LIMIT 50
|
||||
")->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
if (! $ids) {
|
||||
throw new Exception('Aucun praticien');
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
2️⃣ On mélange et on prend 4
|
||||
====================================================== */
|
||||
shuffle($ids);
|
||||
$ids = array_slice($ids, 0, 4);
|
||||
|
||||
/* ======================================================
|
||||
3️⃣ On récupère les 4 profils
|
||||
====================================================== */
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT id, nom, prenom, specialite, photo
|
||||
FROM praticien
|
||||
WHERE id IN ($placeholders)
|
||||
");
|
||||
$stmt->execute($ids);
|
||||
|
||||
$randomPros = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (! $randomPros) {
|
||||
throw new Exception('Aucun praticien');
|
||||
}
|
||||
/* ======================================================
|
||||
3️⃣ Notes
|
||||
====================================================== */
|
||||
function percent_from_5($note)
|
||||
{
|
||||
$n = max(0, min(5, (float) $note));
|
||||
return (int) round($n / 5 * 100);
|
||||
}
|
||||
|
||||
$ratingsById = [];
|
||||
|
||||
$stRatings = $pdo->prepare("
|
||||
SELECT praticien_id, ROUND(AVG(note),1) AS m, COUNT(*) AS c
|
||||
FROM avis_praticien
|
||||
WHERE statut='publie' AND praticien_id IN ($placeholders)
|
||||
GROUP BY praticien_id
|
||||
");
|
||||
$stRatings->execute($ids);
|
||||
|
||||
foreach ($stRatings->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$ratingsById[(int) $r['praticien_id']] = [
|
||||
'm' => (float) $r['m'],
|
||||
'c' => (int) $r['c'],
|
||||
];
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
4️⃣ Affichage
|
||||
====================================================== */
|
||||
foreach ($randomPros as $rp):
|
||||
|
||||
$pid = (int) $rp['id'];
|
||||
|
||||
$photoName = ltrim((string) $rp['photo'], '/');
|
||||
$rpImg = "/public/uploads/" . $photoName;
|
||||
$abs = APP_HTDOCS . "/public/uploads/" . $photoName;
|
||||
|
||||
if (! $photoName || ! is_file($abs)) {
|
||||
$rpImg = "/public/uploads/img/user_placeholder.png";
|
||||
}
|
||||
|
||||
$fullName = trim(($rp['prenom'] ?? '') . ' ' . ($rp['nom'] ?? ''));
|
||||
$spec = trim((string) ($rp['specialite'] ?? ''));
|
||||
$detailUrl = "/detail_public.php?id=" . $pid;
|
||||
|
||||
$hour = random_int(9, 18);
|
||||
$availability = ($translations['home_online_at'] ?? 'Онлайн:') . ' ' . sprintf('%02d:00', $hour);
|
||||
|
||||
$stats = $ratingsById[$pid] ?? ['m' => 0, 'c' => 0];
|
||||
$note = (float) $stats['m'];
|
||||
$count = (int) $stats['c'];
|
||||
$w = percent_from_5($note);
|
||||
?>
|
||||
<article class="hp-card">
|
||||
|
||||
<div class="hp-row1">
|
||||
<a href="<?php echo h($detailUrl) ?>" class="hp-avatarLink">
|
||||
<img class="hp-avatar" src="<?php echo h($rpImg) ?>" alt="<?php echo h($fullName) ?>" width="56" height="56" loading="lazy">
|
||||
</a>
|
||||
|
||||
<div class="hp-main">
|
||||
<a href="<?php echo h($detailUrl) ?>" class="hp-avatarLink">
|
||||
<p class="hp-name"><?php echo h($fullName) ?></p>
|
||||
</a>
|
||||
<div class="hp-spec">
|
||||
<?php
|
||||
$text = $spec ?: ($translations['home_specialist_fallback'] ?? '');
|
||||
$words = preg_split('/\s+/', trim($text));
|
||||
echo h(implode(' ', array_slice($words, 0, 7)));
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hp-rating">
|
||||
<?php if ($count > 0): ?>
|
||||
<div class="hp-stars">
|
||||
<div class="stars-base">★★★★★</div>
|
||||
<div class="stars-fill" style="width:<?php echo (int) $w ?>%">★★★★★</div>
|
||||
</div>
|
||||
|
||||
<div class="hp-note">
|
||||
<?php echo number_format($note, 1, ',', ' ') ?>/5
|
||||
<span class="text-muted">(<?php echo (int) $count ?>)</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hp-row2">
|
||||
<div class="hp-online">
|
||||
<span>📅</span>
|
||||
<span><?php echo h($availability) ?></span>
|
||||
</div>
|
||||
|
||||
<a class="hp-btn" href="<?php echo h($detailUrl) ?>">
|
||||
<?php echo h($translations['home_btn_book'] ?? '') ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
<?php
|
||||
endforeach;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
// silencieux volontaire
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<br><br>
|
||||
<form class="searchbar" action="result_search.php" method="get" id="searchForm">
|
||||
<div class="search-icon">🔍</div>
|
||||
|
||||
<div class="input-wrap">
|
||||
<input type="text" id="specialite" name="specialite" autocomplete="off"
|
||||
placeholder="<?php echo h($translations['n_special'] ?? '') ?>" aria-label="Специальность">
|
||||
<ul class="suggest" id="sg-specialite" hidden></ul>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="input-wrap">
|
||||
<input type="text" id="ville" name="ville" autocomplete="off"
|
||||
placeholder="<?php echo h($translations['gde'] ?? '') ?>" aria-label="Город">
|
||||
<ul class="suggest" id="sg-ville" hidden></ul>
|
||||
</div>
|
||||
|
||||
<!-- ✅ bouton inside -->
|
||||
<button class="hp-btn btn-find--inbar" type="submit">
|
||||
<?php echo h($translations['naiti'] ?? '') ?>
|
||||
</button>
|
||||
</form><br>
|
||||
<div class="spec-cta-wrap">
|
||||
<h3><?php echo h($translations['choix_pro_titre_home'] ?? '') ?></h3>
|
||||
<p><!--<?php echo h($translations['choix_pro_texte_court'] ?? '') ?>--></p>
|
||||
<a class="spec-cta" href="/inscription_praticien.php">
|
||||
<span class="spec-cta__label"><?php echo h($translations['titre_spec'] ?? '') ?></span>
|
||||
</a>
|
||||
</div>
|
||||
<br>
|
||||
<!--<div class="hp-all">
|
||||
<a href="/result_search.php">
|
||||
<?php echo h($translations['smotret_vsex'] ?? '') ?>
|
||||
</a>
|
||||
</div>-->
|
||||
<br>
|
||||
|
||||
<!-- Articles -->
|
||||
<!--<h2 class="home-h2"><?php echo h($translations['home_useful_articles'] ?? '') ?></h2>-->
|
||||
|
||||
<?php
|
||||
function fetch_url(string $url, int $timeout = 4): array
|
||||
{
|
||||
// Retourne [ok(bool), body(string), err(string), http(int)]
|
||||
if (! function_exists('curl_init')) {
|
||||
return [false, '', 'cURL non disponible', 0];
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_CONNECTTIMEOUT => $timeout,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_USERAGENT => 'uslugi.fr (+https://staging.uslugi.fr)',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
|
||||
$body = (string) curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
$http = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$ok = ($err === '' && $http >= 200 && $http < 300 && $body !== '');
|
||||
return [$ok, $body, $err, $http];
|
||||
}
|
||||
function decode_entities(string $s): string
|
||||
{
|
||||
// ENT_HTML5 gère correctement les entités modernes
|
||||
return html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
function fetch_sovetnik_articles(int $limit = 4, bool $debug = false): array
|
||||
{
|
||||
$cacheFile = APP_HTDOCS . '/cache/sovetnik_feed.json';
|
||||
$lockFile = APP_HTDOCS . '/cache/sovetnik_feed.lock';
|
||||
|
||||
// Soft TTL: on essaye de rafraîchir après 15 min
|
||||
// Hard TTL: on accepte d'afficher un cache jusqu'à 24h (pour ne jamais bloquer la page)
|
||||
$ttlSoft = 15 * 60;
|
||||
$ttlHard = 24 * 60 * 60;
|
||||
|
||||
$now = time();
|
||||
$hasCache = is_file($cacheFile);
|
||||
$age = $hasCache ? ($now - filemtime($cacheFile)) : PHP_INT_MAX;
|
||||
|
||||
// Helper pour lire cache
|
||||
$readCache = function () use ($cacheFile, $limit): array {
|
||||
$data = json_decode((string) @file_get_contents($cacheFile), true);
|
||||
return (is_array($data) && $data) ? array_slice($data, 0, $limit) : [];
|
||||
};
|
||||
|
||||
// 1) Cache frais => retour immédiat (rapide)
|
||||
if ($hasCache && $age < $ttlSoft) {
|
||||
return $readCache();
|
||||
}
|
||||
|
||||
// 2) Cache expiré mais pas trop vieux => on le garde en "stale" (fallback instantané)
|
||||
$stale = [];
|
||||
if ($hasCache && $age < $ttlHard) {
|
||||
$stale = $readCache();
|
||||
}
|
||||
|
||||
// 3) On tente un refresh, MAIS sans bloquer longtemps
|
||||
// + lock pour éviter que plusieurs requêtes refreshent en même temps
|
||||
$canRefresh = true;
|
||||
|
||||
if (is_file($lockFile)) {
|
||||
// lock encore récent => on ne refresh pas
|
||||
if (($now - filemtime($lockFile)) < 60) {
|
||||
$canRefresh = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $canRefresh) {
|
||||
// On sert le stale cache (ou vide)
|
||||
return $stale;
|
||||
}
|
||||
|
||||
// On pose le lock (best-effort)
|
||||
@is_dir(dirname($lockFile)) || @mkdir(dirname($lockFile), 0755, true);
|
||||
@file_put_contents($lockFile, (string) $now);
|
||||
|
||||
// Timeout court = clé de la perf
|
||||
$timeout = 1;
|
||||
|
||||
// 1) RSS
|
||||
$feedCandidates = [
|
||||
'https://www.sovetnik.fr/feed/',
|
||||
'https://www.sovetnik.fr/?feed=rss2',
|
||||
'https://www.sovetnik.fr/feed/rss2/',
|
||||
];
|
||||
|
||||
foreach ($feedCandidates as $feedUrl) {
|
||||
[$ok, $xmlStr, $err, $http] = fetch_url($feedUrl, $timeout);
|
||||
|
||||
if ($debug) {
|
||||
echo "\n<!-- Sovetnik RSS try: $feedUrl | ok=" . ($ok ? '1' : '0') . " | http=$http | err=" . htmlspecialchars($err) . " -->\n";
|
||||
}
|
||||
|
||||
if (! $ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_string($xmlStr);
|
||||
if (! $xml || empty($xml->channel->item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($xml->channel->item as $item) {
|
||||
$titleRaw = (string) $item->title;
|
||||
$descRaw = (string) $item->description;
|
||||
|
||||
$out[] = [
|
||||
'url' => (string) $item->link,
|
||||
'title' => trim(decode_entities(strip_tags($titleRaw))),
|
||||
'desc' => trim(decode_entities(strip_tags($descRaw))),
|
||||
'img' => '',
|
||||
];
|
||||
|
||||
if (count($out) >= $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if ($out) {
|
||||
@is_dir(dirname($cacheFile)) || @mkdir(dirname($cacheFile), 0755, true);
|
||||
@file_put_contents($cacheFile, json_encode($out, JSON_UNESCAPED_UNICODE));
|
||||
@unlink($lockFile);
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback API
|
||||
$apiUrl = 'https://www.sovetnik.fr/wp-json/wp/v2/posts?per_page=' . (int) $limit . '&_embed=1';
|
||||
[$ok, $jsonStr, $err, $http] = fetch_url($apiUrl, $timeout);
|
||||
|
||||
if ($debug) {
|
||||
echo "\n<!-- Sovetnik API try: $apiUrl | ok=" . ($ok ? '1' : '0') . " | http=$http | err=" . htmlspecialchars($err) . " -->\n";
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
$arr = json_decode($jsonStr, true);
|
||||
if (is_array($arr)) {
|
||||
$out = [];
|
||||
foreach ($arr as $p) {
|
||||
$title = trim(decode_entities(strip_tags($p['title']['rendered'] ?? '')));
|
||||
$desc = trim(decode_entities(strip_tags($p['excerpt']['rendered'] ?? '')));
|
||||
|
||||
$img = '';
|
||||
if (! empty($p['_embedded']['wp:featuredmedia'][0]['source_url'])) {
|
||||
$img = (string) $p['_embedded']['wp:featuredmedia'][0]['source_url'];
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'url' => (string) ($p['link'] ?? ''),
|
||||
'title' => $title,
|
||||
'desc' => $desc,
|
||||
'img' => $img,
|
||||
];
|
||||
|
||||
if (count($out) >= $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if ($out) {
|
||||
@is_dir(dirname($cacheFile)) || @mkdir(dirname($cacheFile), 0755, true);
|
||||
@file_put_contents($cacheFile, json_encode($out, JSON_UNESCAPED_UNICODE));
|
||||
@unlink($lockFile);
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Échec refresh => on enlève le lock et on sert le stale
|
||||
@unlink($lockFile);
|
||||
return $stale;
|
||||
}
|
||||
|
||||
// Debug optionnel
|
||||
$debugArticles = isset($_GET['debug']) && $_GET['debug'] === '1';
|
||||
$articles = fetch_sovetnik_articles(4, $debugArticles);
|
||||
?>
|
||||
|
||||
|
||||
|
||||
<div class="hp-articles">
|
||||
<?php if (! empty($articles)): ?>
|
||||
<?php foreach ($articles as $a): ?>
|
||||
<a class="ha" href="<?php echo h($a['url']) ?>" target="_blank" rel="noopener">
|
||||
<div class="ha-img" style="<?php echo ! empty($a['img']) ? 'background:url(' . h($a['img']) . ') top center/cover no-repeat;' : '' ?>"></div>
|
||||
<div class="ha-body">
|
||||
<p class="ha-title"><?php echo h($a['title']) ?></p>
|
||||
<p class="ha-sub"><?php echo h(mb_strimwidth($a['desc'], 0, 120, '…', 'UTF-8')) ?></p>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<div class="text-muted"><?php echo h($translations['home_articles_unavailable'] ?? 'Articles indisponibles pour le moment.') ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!--<div class="hp-all">
|
||||
<a href="https://www.sovetnik.fr/" target="_blank" rel="noopener">
|
||||
<?php echo h($translations['home_all_articles'] ?? '') ?>
|
||||
</a>
|
||||
</div>-->
|
||||
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?php echo $translations['copyr'] ?? '' ?><a href="/terms_fr.php" target="_blank"></a></p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- ✅ Scripts recherche + submit (chips = liens donc pas de JS chips) -->
|
||||
<script src="/js/home.js"></script>
|
||||
|
||||
<!-- ✅ Bootstrap -->
|
||||
<script src="/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<?php include __DIR__ . '/includes/accepter_cookies.php'; ?>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><?php echo $translations['insc_client_title'] ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="<?php echo asset('/includes/site_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?php echo asset('/includes/custom_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?php echo asset('/includes/mobil_style.css') ?>">
|
||||
<style>
|
||||
.toggle-password {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
}
|
||||
.strength-bar {
|
||||
height: 8px;
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.strength-fill {
|
||||
flex: 1;
|
||||
background: #ccc;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
.message-erreur {
|
||||
color: red;
|
||||
font-size: 0.9em;
|
||||
display: none;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/* Checkbox custom */
|
||||
.chk-wrap{
|
||||
display:flex; align-items:flex-start; gap:10px; cursor:pointer; user-select:none;
|
||||
}
|
||||
.chk-wrap input{
|
||||
position:absolute; opacity:0; pointer-events:none;
|
||||
}
|
||||
.chk-box{
|
||||
width:22px; height:22px; flex:0 0 22px;
|
||||
border:2px solid #38378B; border-radius:6px; background:#fff;
|
||||
box-shadow:0 2px 6px rgba(0,0,0,.06);
|
||||
display:inline-block; position:relative; transition:.18s ease;
|
||||
}
|
||||
.chk-wrap input:checked + .chk-box{
|
||||
background:#38378B; border-color:#38378B;
|
||||
}
|
||||
.chk-wrap input:checked + .chk-box::after{
|
||||
content:""; position:absolute; left:6px; top:2px; width:6px; height:12px;
|
||||
border:solid #fff; border-width:0 2px 2px 0; transform:rotate(45deg);
|
||||
}
|
||||
.chk-label{
|
||||
color:#111; line-height:1.4; font-size: 12px;
|
||||
}
|
||||
.chk-label a{ color:#38378B; text-decoration:none; }
|
||||
.chk-label a:hover{ text-decoration:none; }
|
||||
|
||||
</style>
|
||||
<?php include_once __DIR__ . '/includes/tracking.php'; ?>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo_u.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-mobile"></a>
|
||||
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-desktop"></a>
|
||||
</div>
|
||||
<div class="lang-switch">
|
||||
<?php require __DIR__ . '/includes/menu_langue.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-actions">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-logo rounded-circle d-inline-flex align-items-center justify-content-center question-btn"
|
||||
type="button" data-bs-toggle="dropdown" data-bs-auto-close="true"
|
||||
aria-expanded="false" aria-label="Помощь">?</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow help-menu menu-panel">
|
||||
<li><a href="/how-it-work.php"><?php echo $translations['kak_rabotaet'] ?></a></a></li>
|
||||
<li><a href="/about.php"><?php echo $translations['pro_nas'] ?></a></li>
|
||||
<li><a href="/faq.php"><?php echo $translations['faq'] ?></a></li>
|
||||
<li><a href="/contact.php"><?php echo $translations['nam_pisat'] ?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main">
|
||||
<div class="card shadow-sm p-4" style="min-width: 380px; max-width: 480px; width: 100%; margin:40px auto;">
|
||||
<h3 class="mb-4 text-center"><?php echo $translations['insc_client_title'] ?></h3>
|
||||
|
||||
<form id="form-inscription" method="POST" action="traitement_inscription_client.php">
|
||||
<div class="mb-3">
|
||||
<input type="text" name="prenom" class="form-control" placeholder="<?php echo $translations['placeholder_prenom'] ?>" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input type="text" name="nom" class="form-control" placeholder="<?php echo $translations['placeholder_nom'] ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-1">
|
||||
<input type="email" name="email" id="email" class="form-control" placeholder="<?php echo $translations['email_placeholder'] ?>" required>
|
||||
</div>
|
||||
<div class="message-erreur" id="email-erreur"><?php echo $translations['email_deja_utilise'] ?></div>
|
||||
|
||||
<div class="mb-3 position-relative">
|
||||
<input type="password" name="mot_de_passe" id="mot_de_passe" class="form-control" placeholder="<?php echo $translations['motdepasse_placeholder'] ?>" required>
|
||||
<span class="toggle-password" onclick="togglePassword('mot_de_passe')">👁️</span>
|
||||
</div>
|
||||
|
||||
<div id="niveau-securite" class="mb-2 text-muted"><?php echo $translations['mdp_regle_min'] ?></div>
|
||||
|
||||
<div class="strength-bar mb-3">
|
||||
<div class="strength-fill"></div>
|
||||
<div class="strength-fill"></div>
|
||||
<div class="strength-fill"></div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation mot de passe -->
|
||||
<div class="mb-3 position-relative">
|
||||
<input type="password"
|
||||
name="confirm_mot_de_passe"
|
||||
id="confirm_mot_de_passe"
|
||||
class="form-control"
|
||||
placeholder="<?php echo $translations['confirm_mdp_placeholder'] ?? 'Confirmer le mot de passe' ?>"
|
||||
required
|
||||
autocomplete="new-password">
|
||||
<span class="toggle-password" onclick="togglePassword('confirm_mot_de_passe')">👁️</span>
|
||||
</div>
|
||||
<div id="confirm-feedback" class="message-erreur" aria-live="polite" style="display:none;"></div>
|
||||
<!-- CGU / Confidentialité (obligatoire) -->
|
||||
<div class="mb-3">
|
||||
<label class="chk-wrap">
|
||||
<input type="checkbox" id="accept_cgu" name="accept_cgu" required>
|
||||
<span class="chk-box" aria-hidden="true"></span>
|
||||
<span class="chk-label"><?php echo $translations['cgu_label_html'] ?></span>
|
||||
</label>
|
||||
<div class="message-erreur" id="cgu-error" aria-live="polite" style="display:none;">
|
||||
<?php echo htmlspecialchars($translations['cgu_obligatoire'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100"><?php echo $translations['btn_sinscrire'] ?></button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('form-inscription');
|
||||
const pwd = document.getElementById('mot_de_passe');
|
||||
const cpwd = document.getElementById('confirm_mot_de_passe');
|
||||
const feedback = document.getElementById('confirm-feedback');
|
||||
|
||||
const MSGS = {
|
||||
mismatch: '<?php echo addslashes($translations["mdp_non_identiques"] ?? "Les mots de passe ne correspondent pas.") ?>',
|
||||
ok: '<?php echo addslashes($translations["mdp_identiques_ok"] ?? "Les mots de passe correspondent.") ?>'
|
||||
};
|
||||
|
||||
function showOk(){
|
||||
feedback.textContent = MSGS.ok;
|
||||
feedback.style.color = 'green';
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
function showErr(){
|
||||
feedback.textContent = MSGS.mismatch;
|
||||
feedback.style.color = 'red';
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
|
||||
function checkMatch(){
|
||||
// Si l’utilisateur n’a pas encore saisi la confirmation, on cache le message
|
||||
if (!cpwd.value) { feedback.style.display = 'none'; form.dataset.confirmOk = '0'; return; }
|
||||
if (pwd.value === cpwd.value) { showOk(); form.dataset.confirmOk = '1'; }
|
||||
else { showErr(); form.dataset.confirmOk = '0'; }
|
||||
}
|
||||
|
||||
pwd.addEventListener('input', checkMatch);
|
||||
cpwd.addEventListener('input', checkMatch);
|
||||
|
||||
form.addEventListener('submit', function(e){
|
||||
// Double sécurité côté front : empêche l’envoi si non identiques
|
||||
if (form.dataset.confirmOk !== '1') {
|
||||
checkMatch();
|
||||
e.preventDefault();
|
||||
cpwd.focus();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
<script>
|
||||
function togglePassword(id) {
|
||||
const input = document.getElementById(id);
|
||||
if (input) {
|
||||
input.type = input.type === "password" ? "text" : "password";
|
||||
}
|
||||
}
|
||||
|
||||
// Traductions envoyées depuis PHP
|
||||
const EMAIL_TAKEN = '<?php echo addslashes($translations['email_deja_utilise']) ?>';
|
||||
const T = {
|
||||
rule: '<?php echo addslashes($translations['mdp_regle_min']) ?>',
|
||||
weak: '<?php echo addslashes($translations['securite_faible']) ?>',
|
||||
medium: '<?php echo addslashes($translations['securite_moyen']) ?>',
|
||||
strong: '<?php echo addslashes($translations['securite_fort']) ?>'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script src="js/verif_email.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('form-inscription');
|
||||
const cgu = document.getElementById('accept_cgu');
|
||||
const err = document.getElementById('cgu-error');
|
||||
|
||||
form.addEventListener('submit', function(e){
|
||||
if (!cgu.checked) {
|
||||
e.preventDefault();
|
||||
err.style.display = 'block';
|
||||
// focus accessible
|
||||
cgu.focus();
|
||||
} else {
|
||||
err.style.display = 'none';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?php echo $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap_global.php';
|
||||
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><?= $translations['insc_praticien_title'] ?></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="<?= asset('/includes/site_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/custom_style.css') ?>">
|
||||
<link rel="stylesheet" href="<?= asset('/includes/mobil_style.css') ?>">
|
||||
<style>
|
||||
|
||||
.toggle-password {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
}
|
||||
.strength-bar {
|
||||
height: 8px;
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.strength-fill {
|
||||
flex: 1;
|
||||
background: #ccc;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
.message-erreur {
|
||||
color: red;
|
||||
font-size: 0.9em;
|
||||
display: none;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/* Checkbox custom */
|
||||
.chk-wrap{
|
||||
display:flex; align-items:flex-start; gap:10px; cursor:pointer; user-select:none;
|
||||
}
|
||||
.chk-wrap input{
|
||||
position:absolute; opacity:0; pointer-events:none;
|
||||
}
|
||||
.chk-box{
|
||||
width:22px; height:22px; flex:0 0 22px;
|
||||
border:2px solid #38378B; border-radius:6px; background:#fff;
|
||||
box-shadow:0 2px 6px rgba(0,0,0,.06);
|
||||
display:inline-block; position:relative; transition:.18s ease;
|
||||
}
|
||||
.chk-wrap input:checked + .chk-box{
|
||||
background:#38378B; border-color:#38378B;
|
||||
}
|
||||
.chk-wrap input:checked + .chk-box::after{
|
||||
content:""; position:absolute; left:6px; top:2px; width:6px; height:12px;
|
||||
border:solid #fff; border-width:0 2px 2px 0; transform:rotate(45deg);
|
||||
}
|
||||
.chk-label{
|
||||
color:#111; line-height:1.4; font-size: 12px;
|
||||
}
|
||||
.chk-label a{ color:#38378B; text-decoration:none; }
|
||||
.chk-label a:hover{ text-decoration:none; }
|
||||
|
||||
</style>
|
||||
<?php include_once __DIR__ . '/includes/tracking.php'; ?>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo_u.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-mobile"></a>
|
||||
|
||||
<a href="/index.php"><img src="/public/uploads/img/logo.png"
|
||||
alt="uslugi.fr"
|
||||
class="logo-desktop"></a>
|
||||
</div>
|
||||
<div class="lang-switch">
|
||||
<?php require __DIR__ . '/includes/menu_langue.php'; ?>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-actions">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-logo rounded-circle d-inline-flex align-items-center justify-content-center question-btn"
|
||||
type="button" data-bs-toggle="dropdown" data-bs-auto-close="true"
|
||||
aria-expanded="false" aria-label="Помощь">?</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow help-menu menu-panel">
|
||||
<li><a href="/how-it-work.php"><?= $translations['kak_rabotaet'] ?></a></a></li>
|
||||
<li><a href="/about.php"><?= $translations['pro_nas'] ?></a></li>
|
||||
<li><a href="/faq.php"><?= $translations['faq'] ?></a></li>
|
||||
<li><a href="/contact.php"><?= $translations['nam_pisat'] ?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main">
|
||||
<div class="auth-center">
|
||||
<?php require __DIR__ . '/choisir_abb.php'; ?>
|
||||
<div class="card shadow-sm p-4" style="min-width: 380px; max-width: 480px; width: 100%; margin:40px auto;">
|
||||
|
||||
<h3 class="mb-4 text-center"><?= $translations['insc_praticien_title'] ?></h3>
|
||||
|
||||
<form id="form-inscription" method="POST" action="traitement_inscription_praticien.php">
|
||||
<!-- Champ SIREN/SIRET -->
|
||||
<input type="hidden" name="abonnement_id" id="abonnement_id" value="">
|
||||
<div class="mb-3">
|
||||
<input type="text"
|
||||
name="siret"
|
||||
id="siret"
|
||||
class="form-control"
|
||||
placeholder="<?= $translations['placeholder_siret'] ?>"
|
||||
required
|
||||
maxlength="17"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9 ]+">
|
||||
<div id="siret-feedback" class="message-erreur" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input type="text" name="prenom" class="form-control" placeholder="<?= $translations['placeholder_prenom'] ?>" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input type="text" name="nom" class="form-control" placeholder="<?= $translations['placeholder_nom'] ?>" required>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<input type="email" name="email" id="email" class="form-control" placeholder="<?= $translations['email_placeholder'] ?>" required>
|
||||
</div>
|
||||
<div class="message-erreur" id="email-erreur"><?= addslashes($translations['email_deja_utilise']) ?></div>
|
||||
|
||||
<div class="mb-3 position-relative">
|
||||
<input type="password" name="mot_de_passe" id="mot_de_passe" class="form-control" placeholder="<?= $translations['motdepasse_placeholder'] ?>" required>
|
||||
<span class="toggle-password" onclick="togglePassword('mot_de_passe')">👁️</span>
|
||||
</div>
|
||||
|
||||
<div id="niveau-securite" class="mb-2 text-muted"><?= $translations['mdp_regle_min'] ?></div>
|
||||
|
||||
<div class="strength-bar mb-3">
|
||||
<div class="strength-fill"></div>
|
||||
<div class="strength-fill"></div>
|
||||
<div class="strength-fill"></div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation mot de passe -->
|
||||
<div class="mb-3 position-relative">
|
||||
<input type="password"
|
||||
name="confirm_mot_de_passe"
|
||||
id="confirm_mot_de_passe"
|
||||
class="form-control"
|
||||
placeholder="<?= $translations['confirm_mdp_placeholder'] ?? 'Confirmer le mot de passe' ?>"
|
||||
required
|
||||
autocomplete="new-password">
|
||||
<span class="toggle-password" onclick="togglePassword('confirm_mot_de_passe')">👁️</span>
|
||||
</div>
|
||||
<div id="confirm-feedback" class="message-erreur" aria-live="polite" style="display:none;"></div>
|
||||
<!-- CGU / Confidentialité (obligatoire) -->
|
||||
<div class="mb-3">
|
||||
<label class="chk-wrap">
|
||||
<input type="checkbox" id="accept_cgu" name="accept_cgu" required>
|
||||
<span class="chk-box" aria-hidden="true"></span>
|
||||
<span class="chk-label"><?= $translations['cgu_label_html'] ?></span>
|
||||
</label>
|
||||
<div class="message-erreur" id="cgu-error" aria-live="polite" style="display:none;">
|
||||
<?= htmlspecialchars($translations['cgu_obligatoire'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100"><?= $translations['btn_sinscrire'] ?></button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('form-inscription');
|
||||
const pwd = document.getElementById('mot_de_passe');
|
||||
const cpwd = document.getElementById('confirm_mot_de_passe');
|
||||
const feedback = document.getElementById('confirm-feedback');
|
||||
|
||||
const MSGS = {
|
||||
mismatch: '<?= addslashes($translations["mdp_non_identiques"] ?? "Les mots de passe ne correspondent pas.") ?>',
|
||||
ok: '<?= addslashes($translations["mdp_identiques_ok"] ?? "Les mots de passe correspondent.") ?>'
|
||||
};
|
||||
|
||||
function showOk(){
|
||||
feedback.textContent = MSGS.ok;
|
||||
feedback.style.color = 'green';
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
function showErr(){
|
||||
feedback.textContent = MSGS.mismatch;
|
||||
feedback.style.color = 'red';
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
|
||||
function checkMatch(){
|
||||
// Si l’utilisateur n’a pas encore saisi la confirmation, on cache le message
|
||||
if (!cpwd.value) { feedback.style.display = 'none'; form.dataset.confirmOk = '0'; return; }
|
||||
if (pwd.value === cpwd.value) { showOk(); form.dataset.confirmOk = '1'; }
|
||||
else { showErr(); form.dataset.confirmOk = '0'; }
|
||||
}
|
||||
|
||||
pwd.addEventListener('input', checkMatch);
|
||||
cpwd.addEventListener('input', checkMatch);
|
||||
|
||||
form.addEventListener('submit', function(e){
|
||||
// Double sécurité côté front : empêche l’envoi si non identiques
|
||||
if (form.dataset.confirmOk !== '1') {
|
||||
checkMatch();
|
||||
e.preventDefault();
|
||||
cpwd.focus();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
function togglePassword(id) {
|
||||
const input = document.getElementById(id);
|
||||
if (input) {
|
||||
input.type = input.type === "password" ? "text" : "password";
|
||||
}
|
||||
}
|
||||
// Traductions envoyées depuis PHP
|
||||
const EMAIL_TAKEN = '<?= addslashes($translations['email_deja_utilise']) ?>';
|
||||
const T = {
|
||||
rule: '<?= addslashes($translations['mdp_regle_min']) ?>',
|
||||
weak: '<?= addslashes($translations['securite_faible']) ?>',
|
||||
medium: '<?= addslashes($translations['securite_moyen']) ?>',
|
||||
strong: '<?= addslashes($translations['securite_fort']) ?>'
|
||||
};
|
||||
</script>
|
||||
|
||||
<!--***********************************************************
|
||||
Verification ajax pour SIRET
|
||||
**************************************************************-->
|
||||
<script>
|
||||
// Vérifie Luhn (pour SIRET)
|
||||
function luhnCheck(num) {
|
||||
const arr = (num + '').split('').reverse().map(x => parseInt(x, 10));
|
||||
const sum = arr.reduce((acc, val, i) => {
|
||||
if (i % 2) {
|
||||
val *= 2;
|
||||
if (val > 9) val -= 9;
|
||||
}
|
||||
return acc + val;
|
||||
}, 0);
|
||||
return sum % 10 === 0;
|
||||
}
|
||||
|
||||
(function(){
|
||||
const form = document.getElementById('form-inscription'); // <-- manquait ici
|
||||
const siretInput = document.getElementById('siret');
|
||||
const feedback = document.getElementById('siret-feedback');
|
||||
|
||||
// init
|
||||
form.dataset.siretExists = '0';
|
||||
let debounceId = null;
|
||||
let lastQueried = '';
|
||||
|
||||
const MSG = {
|
||||
invalide: '<?= addslashes($translations["siret_format_invalide"]) ?>',
|
||||
valide: '<?= addslashes($translations["siret_valide_format"]) ?>',
|
||||
existe: '<?= addslashes($translations["siret_trouve"]) ?>',
|
||||
nexiste: '<?= addslashes($translations["siret_non_trouve"]) ?>',
|
||||
attente: '<?= addslashes($translations["siret_verification_en_cours"] ?? "Vérification en cours…") ?>'
|
||||
};
|
||||
|
||||
function formatSiret(value) {
|
||||
value = value.replace(/\D/g, '');
|
||||
if (value.length > 3) value = value.slice(0,3) + ' ' + value.slice(3);
|
||||
if (value.length > 7) value = value.slice(0,7) + ' ' + value.slice(7);
|
||||
if (value.length > 11) value = value.slice(0,11) + ' ' + value.slice(11);
|
||||
return value;
|
||||
}
|
||||
|
||||
function setFeedback(text, color) {
|
||||
feedback.textContent = text;
|
||||
feedback.style.color = color;
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
|
||||
function checkSiretImmediate() {
|
||||
// formatage visuel
|
||||
const formatted = formatSiret(siretInput.value);
|
||||
if (siretInput.value !== formatted) siretInput.value = formatted;
|
||||
|
||||
const raw = siretInput.value.replace(/\s+/g, '');
|
||||
|
||||
// On ne valide QUE le SIRET (14). Si 9 => on ne dit rien (évite faux positifs SIREN)
|
||||
if (raw.length === 0) { feedback.style.display = 'none'; form.dataset.siretExists = '0'; return; }
|
||||
|
||||
if (raw.length !== 14) {
|
||||
setFeedback(MSG.invalide, 'red');
|
||||
form.dataset.siretExists = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!luhnCheck(raw)) {
|
||||
setFeedback(MSG.invalide, 'red');
|
||||
form.dataset.siretExists = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
// Format OK + Luhn OK → on consulte l’API
|
||||
setFeedback(MSG.attente, '#666');
|
||||
form.dataset.siretExists = '0';
|
||||
|
||||
// Evite d’appeler si même valeur que la dernière fois
|
||||
if (raw === lastQueried) return;
|
||||
lastQueried = raw;
|
||||
|
||||
// Débounce réseau (600ms après dernière frappe)
|
||||
clearTimeout(debounceId);
|
||||
debounceId = setTimeout(() => {
|
||||
fetch('/js/ajax_verif_siret.php?siret=' + encodeURIComponent(raw)) // <-- ajuste le chemin si besoin
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data && data.exists) {
|
||||
setFeedback(MSG.existe, 'green');
|
||||
form.dataset.siretExists = '1';
|
||||
} else {
|
||||
setFeedback(MSG.nexiste, 'red');
|
||||
form.dataset.siretExists = '0';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// En cas d’erreur réseau, on reste prudent
|
||||
setFeedback(MSG.nexiste, 'red');
|
||||
form.dataset.siretExists = '0';
|
||||
});
|
||||
}, 600);
|
||||
}
|
||||
|
||||
siretInput.addEventListener('input', checkSiretImmediate);
|
||||
|
||||
// (Optionnel) Bloquer la soumission si tu EXIGES la présence INSEE
|
||||
form.addEventListener('submit', function(e){
|
||||
const raw = siretInput.value.replace(/\s+/g, '');
|
||||
if (raw.length !== 14 || !luhnCheck(raw)) {
|
||||
e.preventDefault();
|
||||
setFeedback(MSG.invalide, 'red');
|
||||
siretInput.focus();
|
||||
return;
|
||||
}
|
||||
// Exiger existence INSEE :
|
||||
// if (form.dataset.siretExists !== '1') {
|
||||
// e.preventDefault();
|
||||
// setFeedback(MSG.nexiste, 'red');
|
||||
// siretInput.focus();
|
||||
// }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="js/verif_email.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('form-inscription');
|
||||
const cgu = document.getElementById('accept_cgu');
|
||||
const err = document.getElementById('cgu-error');
|
||||
|
||||
form.addEventListener('submit', function(e){
|
||||
if (!cgu.checked) {
|
||||
e.preventDefault();
|
||||
err.style.display = 'block';
|
||||
// focus accessible
|
||||
cgu.focus();
|
||||
} else {
|
||||
err.style.display = 'none';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('form-inscription');
|
||||
const hidden = document.getElementById('abonnement_id');
|
||||
const err = document.getElementById('abo-error');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', function(e){
|
||||
if (!hidden || !hidden.value) {
|
||||
e.preventDefault();
|
||||
if (err) err.style.display = 'block';
|
||||
document.getElementById('abo-block')?.scrollIntoView({behavior:'smooth', block:'start'});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?= $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user