Files

576 lines
22 KiB
PHP

<?php
require_once __DIR__ . '/../includes/init_client.php';
// Support recherche ville en russe + latin
// Essaie d'abord /public/includes/, sinon /includes/ (selon ton projet)
$__paths = [
__DIR__ . '/../includes/search_helpers.php',
dirname(__DIR__, 2) . '/includes/search_helpers.php',
];
foreach ($__paths as $__p) {if (is_file($__p)) {require_once $__p;
break;}}
unset($__paths, $__p);
/* =========================
Paramètres / Recherche
========================= */
$perPage = 12;
$page = max(1, (int) ($_GET['page'] ?? 1));
$qRaw = trim((string) ($_GET['q'] ?? ''));
$wRaw = trim((string) ($_GET['where'] ?? ''));
$q = mb_strtolower($qRaw);
$w = mb_strtolower($wRaw);
$isSearch = ($q !== '' || $w !== '');
$currentLang = $lang ?? 'ru';
if (! in_array($currentLang, ['ru', 'fr', 'en'], true)) {
$currentLang = 'ru';
}
/* =========================
Helpers
========================= */
function video_thumb_info(?string $url): ?array
{
if (! $url || ! trim($url)) {
return null;
}
$u = trim($url);
// YouTube
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"];
}
// Dailymotion
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"];
}
// Fichiers directs
$path = parse_url($u, PHP_URL_PATH) ?? '';
if (preg_match('~\\.(mp4|webm|ogg)$~i', $path)) {
return ['provider' => 'file', 'id' => null, 'thumb' => null];
}
if (strpos(strtolower($u), 'vimeo.com') !== false) {
return ['provider' => 'vimeo', 'id' => null, 'thumb' => null];
}
return ['provider' => 'other', 'id' => null, 'thumb' => null];
}
function fullName(array $p): string
{
return trim(($p['prenom_affiche'] ?? $p['prenom'] ?? '') . ' ' . ($p['nom_affiche'] ?? $p['nom'] ?? ''));
}
function excerpt_words(string $html, int $words = 60): string
{
$plain = trim(preg_replace('/\\s+/', ' ', strip_tags($html)));
if ($plain === '') {
return '';
}
$parts = preg_split('/\\s+/', $plain);
if (count($parts) <= $words) {
return $plain;
}
return implode(' ', array_slice($parts, 0, $words)) . '…';
}
/* =========================
Requêtes (COUNT + LIST)
========================= */
$whereSql = [];
$params = [];
/*==========================BLOCK WHERE AVEC ABONNEMENT VALIDE ==========================*/
$whereSql[] = "(p.date_expiration IS NOT NULL AND p.date_expiration >= DATE_SUB(NOW(), INTERVAL 14 DAY))";
/*=======================================================================================*/
if ($q !== '') {
$words = preg_split('/\s+/', $q, -1, PREG_SPLIT_NO_EMPTY);
$and = [];
foreach ($words as $i => $word) {
$k1 = ":q_name_$i";
$k2 = ":q_spec_$i";
$and[] = "(
LOWER(CONCAT(COALESCE(pt.prenom_affiche, p.prenom, ''),' ',COALESCE(pt.nom_affiche, p.nom, ''))) LIKE $k1
OR LOWER(COALESCE(pt.specialite, p.specialite, '')) LIKE $k2
)";
$params[$k1] = '%' . $word . '%';
$params[$k2] = '%' . $word . '%';
}
$whereSql[] = '(' . implode(' AND ', $and) . ')';
}
if ($w !== '') {
// Candidats latins à partir d'une saisie RU (alias + translittération)
$villeCands = city_query_candidates($w);
// Ajoute aussi la saisie brute (utile si déjà en latin)
$villeCands[] = mb_strtolower($w, 'UTF-8');
$villeCands = array_values(array_unique($villeCands));
$ors = [];
// Ville (LOWER(...)) : on cherche par préfixe
foreach ($villeCands as $i => $cand) {
$key = ":wv$i";
$ors[] = "LOWER(COALESCE(pt.ville_affichee, p.ville, '')) LIKE $key";
$params[$key] = $cand . '%'; // ← passe en '%'.$cand.'%' si tu veux "contient"
}
// Code postal : on garde un LIKE simple sur la saisie brute
$ors[] = "COALESCE(p.code_postal,'') LIKE :wcp";
$params[':wcp'] = '%' . $w . '%';
$whereSql[] = '(' . implode(' OR ', $ors) . ')';
}
$whereClause = $whereSql ? (' WHERE ' . implode(' AND ', $whereSql)) : '';
/* Total pour pagination (utile seulement si !isSearch, mais on calcule une fois) */
$sqlCount = "
SELECT COUNT(*)
FROM praticien p
LEFT JOIN praticien_traduction pt
ON pt.praticien_id = p.id
AND pt.langue = :lang
$whereClause
";
$stmt = $pdo->prepare($sqlCount);
$paramsCount = $params;
$paramsCount[':lang'] = $currentLang;
$stmt->execute($paramsCount);
$total = (int) $stmt->fetchColumn();
$totalPages = max(1, (int) ceil($total / $perPage));
/* Liste (paginée si pas de recherche, sinon tout) */
$sqlList = "
SELECT
p.id,
p.titre,
p.nom,
p.prenom,
p.photo,
p.ville,
p.code_postal,
p.video,
COALESCE(pt.nom_affiche, p.nom) AS nom_affiche,
COALESCE(pt.prenom_affiche, p.prenom) AS prenom_affiche,
COALESCE(pt.specialite, p.specialite) AS specialite_affichee,
COALESCE(pt.ville_affichee, p.ville) AS ville_affichee,
COALESCE(pt.presentation, p.presentation) AS presentation_affichee
FROM praticien p
LEFT JOIN praticien_traduction pt
ON pt.praticien_id = p.id
AND pt.langue = :lang
$whereClause
ORDER BY COALESCE(pt.specialite, p.specialite) ASC, COALESCE(pt.nom_affiche, p.nom) ASC
";
if (! $isSearch) {
$sqlList .= " LIMIT :limit OFFSET :offset";
}
$stmt = $pdo->prepare($sqlList);
$params[':lang'] = $currentLang;
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, PDO::PARAM_STR);
}
if (! $isSearch) {
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', ($page - 1) * $perPage, PDO::PARAM_INT);
}
$stmt->execute();
$praticiens = $stmt->fetchAll(PDO::FETCH_ASSOC);
/* =========================
Rendu partiel AJAX (#results)
========================= */
function render_results(array $praticiens, array $translations, bool $isSearch, int $page, int $totalPages): void
{
?>
<?php if ($isSearch): ?>
<h1 class="results-title"><?php echo htmlspecialchars($translations['res_poisk'] ?? 'Résultats', ENT_QUOTES, 'UTF-8') ?></h1>
<div id="resultCount" class="result-count">
<?php
$count = count($praticiens);
echo $count === 0
? htmlspecialchars($translations['ne_naideno'] ?? 'Aucun résultat', ENT_QUOTES, 'UTF-8')
: htmlspecialchars(($translations['naideno'] ?? 'Résultats') . " : " . $count, ENT_QUOTES, 'UTF-8');
?>
</div>
<?php endif; ?>
<div class="pro-list" id="list">
<?php if (empty($praticiens)): ?>
<?php else: ?>
<?php foreach ($praticiens as $p):
$name = fullName($p);
$spec = $p['specialite_affichee'] ?? $p['specialite'] ?? '';
$ville = $p['ville_affichee'] ?? $p['ville'] ?? '';
$cp = $p['code_postal'] ?? '';
$present = excerpt_words((string) ($p['presentation_affichee'] ?? $p['presentation'] ?? ''), 35);
$vinfo = ! empty($p['video']) ? video_thumb_info($p['video']) : null;
$thumb = $vinfo['thumb'] ?? null;
$altVid = htmlspecialchars($translations['video_alt'] ?? 'Vidéo', ENT_QUOTES, 'UTF-8');
$href = ! empty($p['video']) ? htmlspecialchars($p['video'], ENT_QUOTES, 'UTF-8') : '';
?>
<article class="pro-card praticien"
data-name="<?php echo htmlspecialchars(mb_strtolower($name), ENT_QUOTES, 'UTF-8') ?>"
data-spec="<?php echo htmlspecialchars(mb_strtolower($spec), ENT_QUOTES, 'UTF-8') ?>"
data-ville="<?php echo htmlspecialchars(mb_strtolower($ville), ENT_QUOTES, 'UTF-8') ?>"
data-cp="<?php echo htmlspecialchars(mb_strtolower($cp), ENT_QUOTES, 'UTF-8') ?>">
<!-- Photo -->
<div class="pro-photo">
<a href="/public/client/detail_praticien.php?id=<?php echo (int) $p['id'] ?>"><img
src="<?php echo ! empty($p['photo']) ? '/public/uploads/' . htmlspecialchars($p['photo'], ENT_QUOTES, 'UTF-8') : '/public/uploads/img/user_placeholder.png' ?>"
alt="<?php echo htmlspecialchars($translations['alt_photo'] ?? 'Photo du praticien', ENT_QUOTES, 'UTF-8') ?>"
loading="lazy"></a>
</div>
<!-- Infos -->
<div class="pro-info">
<h2 class="pro-name"><a href="/public/client/detail_praticien.php?id=<?php echo (int) $p['id'] ?>"><?php echo htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?></a></h2>
<?php if ($spec): ?><div class="pro-spec"><?php echo htmlspecialchars($spec, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if ($present): ?>
<p class="pro-desc"><?php echo nl2br(htmlspecialchars($present, ENT_QUOTES, 'UTF-8')) ?></p>
<?php endif; ?>
<a href="#" class="favori_lien" data-id="<?php echo (int) $p['id'] ?>" data-loading="0">
<?php echo htmlspecialchars($translations['plus_favorits'] ?? 'Ajouter aux favoris', ENT_QUOTES, 'UTF-8') ?>
</a>
<a href="/public/client/detail_praticien.php?id=<?php echo (int) $p['id'] ?>">
<?php echo htmlspecialchars($translations['plus_details'] ?? 'Détails', ENT_QUOTES, 'UTF-8') ?>
</a>
</div>
<!-- Vidéo -->
<?php if (! empty($p['video'])): ?>
<div class="pro-video-col">
<a class="pro-video"
href="#"
data-bs-toggle="modal"
data-bs-target="#videoModal"
data-yturl="<?php echo $href ?>"
aria-label="<?php echo $altVid ?>">
<?php if ($thumb): ?>
<img src="<?php echo htmlspecialchars($thumb, ENT_QUOTES, 'UTF-8') ?>" alt="<?php echo $altVid ?>" loading="lazy">
<?php else: ?>
<img src="/public/uploads/img/video_placeholder.jpg" alt="<?php echo $altVid ?>" loading="lazy">
<?php endif; ?>
</a>
</div>
<?php endif; ?>
<!-- CTA -->
<div class="pro-cta-col">
<a class="pro-cta" href="/public/client/reserver_rdv.php?praticien_id=<?php echo (int) $p['id'] ?>">
<?php echo htmlspecialchars($translations['den'] ?? 'Prendre RDV', ENT_QUOTES, 'UTF-8') ?>
</a>
</div>
</article>
<?php endforeach; ?>
<?php endif; ?>
</div>
<?php if (! $isSearch && $totalPages > 1): ?>
<nav class="pager" aria-label="Pagination">
<a class="pager-arrow <?php echo $page <= 1 ? 'is-disabled' : '' ?>"
href="?page=<?php echo max(1, $page - 1) ?>" aria-label="Page précédente">&laquo;&laquo;</a>
<?php
$start = max(1, $page - 2);
$end = min($totalPages, $page + 2);
for ($p = $start; $p <= $end; $p++):
?>
<a class="pager-page <?php echo $p === $page ? 'is-active' : '' ?>" href="?page=<?php echo $p ?>"><?php echo $p ?></a>
<?php endfor; ?>
<a class="pager-arrow <?php echo $page >= $totalPages ? 'is-disabled' : '' ?>"
href="?page=<?php echo min($totalPages, $page + 1) ?>" aria-label="Page suivante">&raquo;&raquo;</a>
</nav>
<?php endif;
}
/* Si appel AJAX, ne renvoyer que le fragment #results */
if (isset($_GET['ajax']) && $_GET['ajax'] === '1') {
header('Content-Type: text/html; charset=utf-8');
ob_start();
render_results($praticiens, $translations, $isSearch, $page, $totalPages);
echo ob_get_clean();
exit;
}
require_once '../includes/header.php';
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title><?php echo htmlspecialchars($translations['menu_liste_specialistes'] ?? 'Liste des praticiens', ENT_QUOTES, 'UTF-8') ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Design -->
<link rel="stylesheet" href="../../includes/custom_style.css">
<link rel="stylesheet" href="../../includes/mobil_style.css">
<style>
/* Pagination pills (<< >> ovale + ronds numérotés) */
.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,.06);transition:background .2s, transform .1s, opacity .2s;
}
.pager-arrow:hover{background:#f8f9ff;transform:translateY(-1px)}
.pager-arrow.is-disabled{opacity:.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,.06);
transition:background .2s, transform .1s, color .2s, border-color .2s;
}
.pager-page:hover{background:#f8f9ff;transform:translateY(-1px)}
.pager-page.is-active{background:#E03C3F;color:#fff;border-color:#E03C3F;transform:none}
</style>
</head>
<body>
<!-- HERO + BARRE DE RECHERCHE -->
<section class="hero">
<br>
<div class="searchbar" role="search">
<span class="search-icon" aria-hidden="true">🔎</span>
<div class="input-wrap">
<input id="q" type="text"
placeholder="<?php echo htmlspecialchars($translations['n_special'] ?? 'spécialité, nom…', ENT_QUOTES, 'UTF-8') ?>"
aria-label="spécialité, nom" value="<?php echo htmlspecialchars($qRaw, ENT_QUOTES, 'UTF-8') ?>">
</div>
<div class="divider" aria-hidden="true"></div>
<div class="input-wrap">
<input id="where" type="text"
placeholder="<?php echo htmlspecialchars($translations['gde'] ?? 'ville ou code postal', ENT_QUOTES, 'UTF-8') ?>"
aria-label="ville ou code postal" value="<?php echo htmlspecialchars($wRaw, ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
</section>
<!-- LISTE + PAGINATION (wrapper remplaçable en AJAX) -->
<div class="container">
<div id="results">
<?php render_results($praticiens, $translations, $isSearch, $page, $totalPages); ?>
</div>
</div>
<footer class="site-footer">
<div class="container">
<p><?php echo $translations['copyr'] ?></p>
</div>
</footer>
<!-- 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"><?php echo htmlspecialchars($translations['video_titre'] ?? 'Vidéo') ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?php echo htmlspecialchars($translations['fermer'] ?? 'Fermer') ?>"></button>
</div>
<div class="modal-body pt-0">
<div class="ratio ratio-16x9 rounded-3 overflow-hidden">
<iframe id="ytPlayer"
src=""
title="YouTube 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">
<?php echo htmlspecialchars($translations['fermer'] ?? 'Fermer') ?>
</button>
</div>
</div>
</div>
</div>
<!-- JS -->
<script>
const csrfToken = <?php echo json_encode(ensure_csrf_token(), JSON_UNESCAPED_UNICODE) ?>;
</script>
<script>
(function(){
// Convertit n'importe quelle URL YouTube en URL d'embed nocookie
function toEmbed(url) {
if (!url) return '';
try {
const u = new URL(url), host = u.hostname;
let id = '';
if (host.includes('youtu.be')) {
id = u.pathname.split('/').filter(Boolean)[0] || '';
} else if (u.searchParams.get('v')) {
id = u.searchParams.get('v');
} else if (u.pathname.includes('/embed/')) {
id = u.pathname.split('/embed/')[1].split('/')[0];
} else if (u.pathname.includes('/shorts/')) {
id = u.pathname.split('/shorts/')[1].split('/')[0];
}
id = (id || '').replace(/[^a-zA-Z0-9_-]/g, '');
return id ? `https://www.youtube-nocookie.com/embed/${id}` : '';
} catch { return ''; }
}
const modal = document.getElementById('videoModal');
const iframe = document.getElementById('ytPlayer');
document.addEventListener('click', function(e) {
const a = e.target.closest('a.pro-video[data-yturl]');
if (!a) return;
e.preventDefault();
const embed = toEmbed(a.getAttribute('data-yturl'));
if (embed) iframe.src = embed + '?autoplay=1&rel=0&modestbranding=1';
}, false);
modal.addEventListener('hidden.bs.modal', function () { iframe.src = ''; });
// Recherche AJAX sur toute la table
const q = document.getElementById('q');
const where = document.getElementById('where');
const results = document.getElementById('results');
let t;
function reload(page=1) {
const qs = new URLSearchParams({
ajax: '1',
q: q.value.trim(),
where: where.value.trim(),
page: page
});
fetch(location.pathname + '?' + qs.toString(), { headers:{'X-Requested-With':'XMLHttpRequest'} })
.then(r => r.text())
.then(html => { results.innerHTML = html; })
.catch(()=>{ /* silencieux */ });
}
function onInput(){ clearTimeout(t); t = setTimeout(() => reload(1), 220); }
q.addEventListener('input', onInput);
where.addEventListener('input', onInput);
// Pagination progressive enhancement (charge dans #results)
document.addEventListener('click', function(e){
const a = e.target.closest('.pager a[href]');
if (!a) return;
e.preventDefault();
const url = new URL(a.getAttribute('href'), location.href);
// Conserve recherche si non vide
if (q.value.trim()) url.searchParams.set('q', q.value.trim());
if (where.value.trim()) url.searchParams.set('where', where.value.trim());
url.searchParams.set('ajax', '1');
fetch(url.toString(), { headers:{'X-Requested-With':'XMLHttpRequest'} })
.then(r=>r.text()).then(html=>{ results.innerHTML = html; });
}, false);
// Favoris (SweetAlert2)
function ajouterFavori(el, praticienId) {
if (el.dataset.loading === '1') return;
el.dataset.loading = '1';
const formData = new FormData();
formData.append('praticien_id', String(praticienId));
formData.append('csrf_token', csrfToken);
fetch('/public/client/ajouter_favori.php', {
method: 'POST',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
})
.then(async (r) => {
const text = await r.text();
let data;
try {
data = JSON.parse(text);
} catch (e) {
throw new Error(text || 'Erreur réseau');
}
if (!r.ok || !data.ok) {
throw new Error(data.message || 'Erreur.');
}
el.classList.add('ajoute');
el.setAttribute('aria-pressed', 'true');
el.title = data.message || '';
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: data.already_exists ? 'info' : 'success',
title: <?php echo json_encode($translations['sw_favori_ajoute_title'] ?? 'Favori ajouté !', JSON_UNESCAPED_UNICODE) ?>,
text: data.message || '',
timer: 1800,
showConfirmButton: false
});
} else {
alert(data.message || 'OK');
}
})
.catch((err) => {
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'error',
title: 'Oups',
text: err.message || 'Erreur.'
});
} else {
console.error(err);
alert('Erreur.');
}
})
.finally(() => {
el.dataset.loading = '0';
});
}
document.addEventListener('click', (e)=>{
const a = e.target.closest('.favori_lien');
if (!a) return;
e.preventDefault();
const id = a.dataset.id;
const T = {
title: <?php echo json_encode($translations['confirmation'] ?? 'Confirmation') ?>,
text: <?php echo json_encode($translations['voulez_vous_ajouter_favori'] ?? 'Ajouter ce praticien à vos favoris ?') ?>,
ok: <?php echo json_encode($translations['btn_oui'] ?? 'Oui') ?>,
cancel:<?php echo json_encode($translations['btn_annuler'] ?? 'Annuler') ?>
};
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'question',
title: T.title,
text: T.text,
showCancelButton: true,
confirmButtonText: T.ok,
cancelButtonText: T.cancel,
}).then(res => { if (res.isConfirmed) ajouterFavori(a, id); });
} else {
if (confirm(T.text)) ajouterFavori(a, id);
}
});
})();
</script>
<script src="/js/sweetalert2@11.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
</body>
</html>