From e9d0010451afdccdba5c7a5febab0290ba782d11 Mon Sep 17 00:00:00 2001 From: Oleg Barykin Date: Fri, 17 Apr 2026 20:18:11 +0200 Subject: [PATCH] Version stable, aprelic, connexion CFRS et autres corrections --- aprelik.php | 425 +++ aprelik/index.php | 2 + aprelik_submit.php | 257 ++ connexion.php | 345 +- detail_public copy.php | 316 ++ detail_public.php | 476 ++- faq.php | 980 +++-- form_index.php | 538 +++ includes/custom_style.css | 31 + includes/pretty_urls copy.php | 55 + includes/pretty_urls.php | 104 +- includes/search_helpers copy.php | 65 + includes/search_helpers.php | 452 ++- includes/spec_map.php | 49 +- index.php | 272 +- index_sovetnik.php | 722 ++++ mdp_oublie.php | 120 +- public/cabinet/disponibilites.php | 497 ++- public/cabinet/disponibilites_date.php | 392 +- public/cabinet/supprimer_disponibilite.php | 106 + .../cabinet/supprimer_disponibilite_date.php | 106 + public/change_lang.php | 24 +- public/client/ajouter_favori.php | 103 +- public/client/annuler_rdv.php | 665 ++-- public/client/compte.php | 95 +- public/client/detail_praticien.php | 274 +- public/client/list_praticien.php | 521 +-- public/client/mes_favori.php | 137 +- public/client/mes_rendez_vous.php | 196 +- public/client/modifier.php | 94 +- public/client/supprimer_favori.php | 62 +- public/includes/header.php | 11 +- public/includes/header_cabinet.php | 196 +- public/includes/securite.php | 119 +- public/lang/fr.php | 3351 ++++++++-------- public/lang/fr_form.php | 2095 ++++++++++ public/lang/ru.php | 3368 +++++++++-------- public/lang/ru_form.php | 2105 +++++++++++ public/praticien/compte.php | 95 +- public/praticien/disponibilites.php | 358 +- public/praticien/disponibilites_date.php | 314 +- public/praticien/informations.php | 118 +- public/praticien/modifier.php | 777 ++-- public/praticien/supprimer_disponibilite.php | 80 + .../supprimer_disponibilite_date.php | 80 + registration.php | 576 +++ renvoyer_validat_mail.php | 22 +- result_search.php | 361 +- result_search_original.php | 516 +++ robots.txt | 3 +- router copy.php | 69 + router.php | 140 +- sitemap.php | 153 + sms/config.php | 14 +- sms/config.sample.php | 18 +- traitement_form_index.php | 265 ++ traitement_inscription_client.php | 17 +- traitement_inscription_client_back.php | 292 -- traitement_inscription_praticien.php | 17 +- traitement_inscription_praticien_back.php | 370 -- validation.php | 87 +- validation_back.php | 271 -- 62 files changed, 17483 insertions(+), 7256 deletions(-) create mode 100644 aprelik.php create mode 100644 aprelik/index.php create mode 100644 aprelik_submit.php create mode 100644 detail_public copy.php create mode 100644 form_index.php create mode 100644 includes/pretty_urls copy.php create mode 100644 includes/search_helpers copy.php create mode 100644 index_sovetnik.php create mode 100644 public/cabinet/supprimer_disponibilite.php create mode 100644 public/cabinet/supprimer_disponibilite_date.php create mode 100644 public/lang/fr_form.php create mode 100644 public/lang/ru_form.php create mode 100644 public/praticien/supprimer_disponibilite.php create mode 100644 public/praticien/supprimer_disponibilite_date.php create mode 100644 registration.php create mode 100644 result_search_original.php create mode 100644 router copy.php create mode 100644 sitemap.php create mode 100644 traitement_form_index.php delete mode 100644 traitement_inscription_client_back.php delete mode 100644 traitement_inscription_praticien_back.php delete mode 100644 validation_back.php diff --git a/aprelik.php b/aprelik.php new file mode 100644 index 0000000..2275d8c --- /dev/null +++ b/aprelik.php @@ -0,0 +1,425 @@ + + + + + + +Запись в Апрелик 2026-2027 — USLUGI.FR + + + + + +
+
+ +
+

Студия-мастерская
«Апрелик»

+

Русский язык и театр в Париже

+
+
+
Набор открыт
+

Запись на 2026-2027
учебный год

+
+

Заполните форму — заявка поступит напрямую в школу.

+
+ +
+
+ + +
+ + + +
+ Пожалуйста, проверьте форму: +
    + +
  • + +
+
+ + +
+ + + +
+
Данные ребёнка
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
Данные родителей
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
Выбор расписания
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ > + +
+ +
+ > + +
+
+ +
+ +

Форма используется для сохранения ваших контактных данных и последующей информационной рассылки с платформы uslugi.fr

+ +
+ + USLUGI.FR + +
+
+
+ +
+
+ + \ No newline at end of file diff --git a/aprelik/index.php b/aprelik/index.php new file mode 100644 index 0000000..67ae15f --- /dev/null +++ b/aprelik/index.php @@ -0,0 +1,2 @@ + 0 && mb_strlen($value) > $maxLength) { + $value = mb_substr($value, 0, $maxLength); + } + + return $value; +} + +function aprelik_get_pdo(): PDO +{ + foreach (['pdo', 'db', 'database', 'conn'] as $name) { + if (isset($GLOBALS[$name]) && $GLOBALS[$name] instanceof PDO) { + return $GLOBALS[$name]; + } + } + + throw new RuntimeException('PDO connection not found in bootstrap_global.php'); +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + aprelik_redirect('/aprelik.php'); +} + +if (! empty($_POST['website'] ?? '')) { + $_SESSION['aprelik_success'] = 'Спасибо. Форма отправлена.'; + aprelik_redirect('/aprelik.php'); +} + +$data = [ + 'child_last_name' => aprelik_clean($_POST['child_last_name'] ?? '', 120), + 'child_first_name' => aprelik_clean($_POST['child_first_name'] ?? '', 120), + 'child_birthdate' => aprelik_clean($_POST['child_birthdate'] ?? '', 10), + + 'parent1_last_name' => aprelik_clean($_POST['parent1_last_name'] ?? '', 120), + 'parent1_first_name' => aprelik_clean($_POST['parent1_first_name'] ?? '', 120), + 'parent1_language' => aprelik_clean($_POST['parent1_language'] ?? '', 120), + + 'parent2_last_name' => aprelik_clean($_POST['parent2_last_name'] ?? '', 120), + 'parent2_first_name' => aprelik_clean($_POST['parent2_first_name'] ?? '', 120), + 'parent2_language' => aprelik_clean($_POST['parent2_language'] ?? '', 120), + + 'street_address' => aprelik_clean($_POST['street_address'] ?? '', 255), + 'postal_code' => aprelik_clean($_POST['postal_code'] ?? '', 30), + 'city' => aprelik_clean($_POST['city'] ?? '', 120), + + 'parent1_phone' => aprelik_clean($_POST['parent1_phone'] ?? '', 40), + 'parent2_phone' => aprelik_clean($_POST['parent2_phone'] ?? '', 40), + 'email' => aprelik_clean($_POST['email'] ?? '', 190), + + 'consent_rgpd' => ! empty($_POST['consent_rgpd']) ? 1 : 0, + 'consent_email_diffusion' => ! empty($_POST['consent_email_diffusion']) ? 1 : 0, + + 'school_day_address' => aprelik_clean($_POST['school_day_address'] ?? '', 255), + 'age_group' => aprelik_clean($_POST['age_group'] ?? '', 80), + 'notes' => aprelik_clean($_POST['notes'] ?? '', 5000), +]; + +$errors = []; + +if ($data['child_last_name'] === '') { + $errors[] = 'Укажите фамилию ребёнка.'; +} + +if ($data['child_first_name'] === '') { + $errors[] = 'Укажите имя ребёнка.'; +} + +if ($data['child_birthdate'] === '') { + $errors[] = 'Укажите дату рождения ребёнка.'; +} else { + $dt = DateTime::createFromFormat('Y-m-d', $data['child_birthdate']); + if (! $dt || $dt->format('Y-m-d') !== $data['child_birthdate']) { + $errors[] = 'Некорректная дата рождения.'; + } +} + +if ($data['parent1_last_name'] === '') { + $errors[] = 'Укажите фамилию родителя 1.'; +} + +if ($data['parent1_first_name'] === '') { + $errors[] = 'Укажите имя родителя 1.'; +} + +if ($data['parent1_language'] === '') { + $errors[] = 'Укажите язык родителя 1.'; +} + +if ($data['street_address'] === '') { + $errors[] = 'Укажите адрес.'; +} + +if ($data['postal_code'] === '') { + $errors[] = 'Укажите индекс.'; +} + +if ($data['city'] === '') { + $errors[] = 'Укажите город.'; +} + +if ($data['parent1_phone'] === '') { + $errors[] = 'Укажите телефон родителя 1.'; +} + +if ($data['email'] === '' || filter_var($data['email'], FILTER_VALIDATE_EMAIL) === false) { + $errors[] = 'Укажите корректный email.'; +} + +if ($data['school_day_address'] === '') { + $errors[] = 'Выберите адрес занятий.'; +} + +if ($data['age_group'] === '') { + $errors[] = 'Выберите возрастную группу.'; +} + +if ($data['consent_email_diffusion'] !== 1) { + $errors[] = 'Нужно согласиться на информационную рассылку.'; +} + +if ($data['consent_rgpd'] !== 1) { + $errors[] = 'Нужно согласиться на обработку персональных данных.'; +} + +if ($errors !== []) { + $_SESSION['aprelik_errors'] = $errors; + $_SESSION['aprelik_old'] = $data; + aprelik_redirect('/aprelik.php'); +} + +try { + $pdo = aprelik_get_pdo(); + + $sql = " + INSERT INTO aprelik ( + child_last_name, + child_first_name, + child_birthdate, + parent1_last_name, + parent1_first_name, + parent1_language, + parent2_last_name, + parent2_first_name, + parent2_language, + street_address, + postal_code, + city, + parent1_phone, + parent2_phone, + email, + consent_rgpd, + consent_email_diffusion, + ip_address, + user_agent, + school_day_address, + age_group, + notes + ) VALUES ( + :child_last_name, + :child_first_name, + :child_birthdate, + :parent1_last_name, + :parent1_first_name, + :parent1_language, + :parent2_last_name, + :parent2_first_name, + :parent2_language, + :street_address, + :postal_code, + :city, + :parent1_phone, + :parent2_phone, + :email, + :consent_rgpd, + :consent_email_diffusion, + :ip_address, + :user_agent, + :school_day_address, + :age_group, + :notes + ) + ON DUPLICATE KEY UPDATE + child_last_name = VALUES(child_last_name), + child_first_name = VALUES(child_first_name), + child_birthdate = VALUES(child_birthdate), + parent1_last_name = VALUES(parent1_last_name), + parent1_first_name = VALUES(parent1_first_name), + parent1_language = VALUES(parent1_language), + parent2_last_name = VALUES(parent2_last_name), + parent2_first_name = VALUES(parent2_first_name), + parent2_language = VALUES(parent2_language), + street_address = VALUES(street_address), + postal_code = VALUES(postal_code), + city = VALUES(city), + parent1_phone = VALUES(parent1_phone), + parent2_phone = VALUES(parent2_phone), + consent_rgpd = VALUES(consent_rgpd), + consent_email_diffusion = VALUES(consent_email_diffusion), + ip_address = VALUES(ip_address), + user_agent = VALUES(user_agent), + school_day_address = VALUES(school_day_address), + age_group = VALUES(age_group), + notes = VALUES(notes), + updated_at = CURRENT_TIMESTAMP + "; + + $stmt = $pdo->prepare($sql); + $stmt->execute([ + ':child_last_name' => $data['child_last_name'], + ':child_first_name' => $data['child_first_name'], + ':child_birthdate' => $data['child_birthdate'], + ':parent1_last_name' => $data['parent1_last_name'], + ':parent1_first_name' => $data['parent1_first_name'], + ':parent1_language' => $data['parent1_language'], + ':parent2_last_name' => $data['parent2_last_name'] !== '' ? $data['parent2_last_name'] : null, + ':parent2_first_name' => $data['parent2_first_name'] !== '' ? $data['parent2_first_name'] : null, + ':parent2_language' => $data['parent2_language'] !== '' ? $data['parent2_language'] : null, + ':street_address' => $data['street_address'], + ':postal_code' => $data['postal_code'], + ':city' => $data['city'], + ':parent1_phone' => $data['parent1_phone'], + ':parent2_phone' => $data['parent2_phone'] !== '' ? $data['parent2_phone'] : null, + ':email' => mb_strtolower($data['email']), + ':consent_rgpd' => $data['consent_rgpd'], + ':consent_email_diffusion' => $data['consent_email_diffusion'], + ':ip_address' => substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45) ?: null, + ':user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255) ?: null, + ':school_day_address' => $data['school_day_address'], + ':age_group' => $data['age_group'], + ':notes' => $data['notes'] !== '' ? $data['notes'] : null, + ]); + + $_SESSION['aprelik_success'] = 'Спасибо. Ваша заявка сохранена.'; + aprelik_redirect('/aprelik.php'); +} catch (Throwable $e) { + $_SESSION['aprelik_errors'] = ['Произошла ошибка при сохранении формы.']; + $_SESSION['aprelik_old'] = $data; + aprelik_redirect('/aprelik.php'); +} diff --git a/connexion.php b/connexion.php index fe60525..ad8edaa 100644 --- a/connexion.php +++ b/connexion.php @@ -1,62 +1,99 @@ time()) { + $remaining = $blockedUntil - time(); + $erreur = sprintf( + $translations['trop_de_tentatives_connexion'] ?? 'Trop de tentatives. Réessayez dans %d secondes.', + $remaining + ); } 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'])) { - $_SESSION['pending_validation_resend'] = [ - 'compte_id' => (int) $compte['id'], - 'email' => (string) $compte['email'], - 'authorized' => true, - 'expires_at' => time() + 900, // 15 min - ]; - - $erreur = $translations['tvalidat_erreur_email_non_valide'] ?? $translations['erreur_email_non_valide'] ?? 'Veuillez valider votre adresse email avant de vous connecter.'; - } 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; - } - } + if ($email === '' || $mot_de_passe === '') { + $erreur = $translations['erreur_champs_vides']; } else { - $erreur = $translations['erreur_identifiants']; + + $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'])) { + + // ✅ reset brute force + $_SESSION['login_tries'] = 0; + unset($_SESSION['login_blocked_until']); + + if (empty($compte['email_valide'])) { + + $_SESSION['pending_validation_resend'] = [ + 'compte_id' => (int) $compte['id'], + 'email' => (string) $compte['email'], + 'authorized' => true, + 'expires_at' => time() + 900, + ]; + + $erreur = $translations['tvalidat_erreur_email_non_valide'] ?? $translations['erreur_email_non_valide'] ?? 'Veuillez valider votre adresse email.'; + + } else { + + session_regenerate_id(true); + + $_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(); + + // 🔁 redirection next + $next = $_POST['next'] ?? $_GET['next'] ?? ($_SESSION['redirect_to'] ?? null); + $next = next_safe($next); + + if ($next) { + unset($_SESSION['redirect_to']); + header('Location: ' . $next); + exit; + } + + // 🎯 fallback 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 { + + // ❌ Mauvais login + $_SESSION['login_tries']++; + + // ⏱️ Blocage après 5 tentatives + if ($_SESSION['login_tries'] >= 3) { + $_SESSION['login_blocked_until'] = time() + 600; // 10 min + } + + // 🐢 ralentissement progressif + sleep(min($_SESSION['login_tries'], 3)); + + $erreur = $translations['erreur_identifiants']; + } } } } @@ -66,17 +103,13 @@ <?php echo htmlspecialchars($translations['seo_connexion_title'], ENT_QUOTES, 'UTF-8') ?> - - - - - - - + + + + - - + @@ -84,130 +117,126 @@ -
+
+
+

-
-

+ + = time() + && ! empty($email) + && ! empty($_SESSION['pending_validation_resend']['email']) + && hash_equals( + (string) $_SESSION['pending_validation_resend']['email'], + (string) $email + ); + ?> +
- -
-
- -
-
- - - 👁️ - -
+
+
+

+
+
- - + - // 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'); }); -})(); - - + - - + \ No newline at end of file diff --git a/detail_public copy.php b/detail_public copy.php new file mode 100644 index 0000000..4cbf825 --- /dev/null +++ b/detail_public copy.php @@ -0,0 +1,316 @@ +ID invalide.
"; 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 "
".$translations['praticien_introuvable']."
"; 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); +?> + + + + + <?= $name . " - " . $p['specialite'] . " - " . htmlspecialchars($translations['seo_detail_public_title'], ENT_QUOTES, 'UTF-8')?> +"> + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+

+
:
+ +
:
+
:
+
:
+
:
+
:
+
+
+ + +
+ Photo +
+ + + + + + +
+ + +
+ +
+
+ +
+
★★★★★
+
★★★★★
+
+
+
: /5
+
()
+
+
+
+ + +
+
+ + + + +
+ + +
+
★★★★★
+
★★★★★
+
+
+ + • +
+
+ +
+ +
+ + +
+ + + +
+
+
+
+
+ + + + + + +
+
+

+
+
+ + diff --git a/detail_public.php b/detail_public.php index 4cbf825..9237860 100644 --- a/detail_public.php +++ b/detail_public.php @@ -1,87 +1,272 @@ ID invalide.
"; exit; } + $u = trim($url); -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]; -} + 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"]; + } -// 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 "
".$translations['praticien_introuvable']."
"; exit; } + 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"]; + } -$name = $name = trim($p['prenom'] . ' ' . $p['nom']); -$photo = !empty($p['photo']) ? "/public/uploads/".ltrim($p['photo'],'/') : "/public/uploads/img/user_placeholder.png"; + $path = parse_url($u, PHP_URL_PATH) ?? ''; + if (preg_match('~\.(mp4|webm|ogg)$~i', $path)) { + return ['provider' => 'file', 'id' => null, 'thumb' => null]; + } -// 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); + if (stripos($u, 'vimeo.com') !== false) { + return ['provider' => 'vimeo', 'id' => null, 'thumb' => null]; + } -// 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]; + return ['provider' => 'other', 'id' => null, 'thumb' => null]; + } -$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); + // ID praticien + $praticien_id = (int) ($_GET['id'] ?? 0); + if ($praticien_id <= 0) { + echo "
ID invalide.
"; + exit; + } + + // 1) Praticien + $st = $pdo->prepare(" + SELECT + p.id, + p.titre, + p.nom, + p.prenom, + p.telephone, + p.adresse, + p.ville, + p.code_postal, + p.specialite, + p.presentation, + p.photo, + p.video, + p.disponibilite, + COALESCE(pt.nom_affiche, p.nom) AS nom_affiche, + COALESCE(pt.prenom_affiche, p.prenom) AS prenom_affiche, + COALESCE(pt.ville_affichee, p.ville) AS ville_affichee, + COALESCE(pt.titre, p.titre) AS titre_affiche, + COALESCE(pt.specialite, p.specialite) AS specialite_affichee, + COALESCE(pt.presentation, p.presentation) AS presentation_affichee, + COALESCE(pt.disponibilite, p.disponibilite) AS disponibilite_affichee + FROM praticien p + LEFT JOIN praticien_traduction pt + ON pt.praticien_id = p.id + AND pt.langue = :lang + WHERE p.id = :id + LIMIT 1 +"); + $st->execute([ + ':lang' => $currentLang, + ':id' => $praticien_id, + ]); + $p = $st->fetch(PDO::FETCH_ASSOC); + + if (! $p) { + echo "
" . ($translations['praticien_introuvable'] ?? 'Praticien introuvable') . "
"; + exit; + } + + $name = trim(($p['prenom_affiche'] ?? $p['prenom'] ?? '') . ' ' . ($p['nom_affiche'] ?? $p['nom'] ?? '')); + $photo = ! empty($p['photo']) + ? "/public/uploads/" . ltrim((string) $p['photo'], '/') + : "/public/uploads/img/user_placeholder.png"; + + // Slug spécialité SEO depuis la valeur BDD + $specSlug = ''; + + if (! empty($p['specialite'])) { + $specDb = mb_strtolower(trim((string) $p['specialite']), 'UTF-8'); + + // 1) correspondance exacte + foreach (seo_spec_map() as $slug => $cfg) { + $dbVal = mb_strtolower(trim((string) ($cfg['db'] ?? '')), 'UTF-8'); + if ($dbVal !== '' && $dbVal === $specDb) { + $specSlug = $slug; + break; + } + } + + // 2) commence par + if ($specSlug === '') { + foreach (seo_spec_map() as $slug => $cfg) { + $dbVal = mb_strtolower(trim((string) ($cfg['db'] ?? '')), 'UTF-8'); + if ($dbVal !== '' && mb_stripos($specDb, $dbVal, 0, 'UTF-8') === 0) { + $specSlug = $slug; + break; + } + } + } + + // 3) contient + if ($specSlug === '') { + foreach (seo_spec_map() as $slug => $cfg) { + $dbVal = mb_strtolower(trim((string) ($cfg['db'] ?? '')), 'UTF-8'); + if ($dbVal !== '' && mb_stripos($specDb, $dbVal, 0, 'UTF-8') !== false) { + $specSlug = $slug; + break; + } + } + } + + // 4) fallback propre : slug du texte libre + if ($specSlug === '') { + $specSlug = seo_slug((string) $p['specialite']); + } + } + + if ($specSlug === '') { + $specSlug = 'specialiste'; + } + + // Slug ville SEO + $citySlug = ''; + if (! empty($p['ville'])) { + $citySlug = seo_city_slug_from_input((string) $p['ville']); + } + if ($citySlug === '') { + $citySlug = 'france'; + } + + // Slug nom + $nameSlug = seo_slug($name); + if ($nameSlug === '') { + $nameSlug = 'profil'; + } + + // URL SEO finale + $prettyPath = '/specialistes/' . rawurlencode($specSlug) . '/' . rawurlencode($citySlug) . '/' . rawurlencode($nameSlug . '-' . (int) $p['id']) . '/'; + $canonicalUrl = 'https://uslugi.fr' . $prettyPath; + + // Redirection 301 depuis ancienne URL + $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''; + if ( + ($currentPath === '/detail_public.php' || $currentPath === '/specialistes') + && (int) ($_GET['id'] ?? 0) > 0 + ) { + $target = $prettyPath; + + $extra = $_GET; + unset($extra['id'], $extra['slug']); + + if (! empty($extra)) { + $target .= '?' . http_build_query($extra); + } + + if (($_SERVER['REQUEST_URI'] ?? '') !== $target) { + header('Location: ' . $target, true, 301); + exit; + } + } + + // Variables SEO + $seoTitle = $name; + if (! empty($p['specialite_affichee'])) { + $seoTitle .= ' — ' . $p['specialite_affichee']; + } + if (! empty($p['ville_affichee'])) { + $seoTitle .= ' à ' . $p['ville_affichee']; + } + $seoTitle .= ' | Uslugi.fr'; + + $presentationShort = trim(strip_tags((string) ($p['presentation_affichee'] ?? $p['presentation'] ?? ''))); + if ($presentationShort !== '') { + $presentationShort = mb_substr($presentationShort, 0, 155, 'UTF-8'); + } + + $seoDesc = $presentationShort !== '' + ? $presentationShort + : ( + $name + . (! empty($p['specialite_affichee']) ? ' — ' . $p['specialite_affichee'] : '') + . (! empty($p['ville_affichee']) ? ' à ' . $p['ville_affichee'] : '') + . '.' + ); + + // 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); ?> - + - <?= $name . " - " . $p['specialite'] . " - " . htmlspecialchars($translations['seo_detail_public_title'], ENT_QUOTES, 'UTF-8')?> -"> - - +<?php echo h($seoTitle) ?> + + + + + + + + + - + - - - + + +
@@ -130,75 +315,84 @@ else: ?>
-

-
:
- -
:
-
:
-
:
-
:
-
:
+

+
:
+ +
:
+
:
+
:
+
:
+
:
- Photo -
+ <?php
+                echo h(
+                    trim($name)
+                    . (! empty($p['specialite_affichee']) ? ' — ' . $p['specialite_affichee'] : '')
+                    . (! empty($p['ville_affichee']) ? ' à ' . $p['ville_affichee'] : '')
+                    . ' — Uslugi.fr'
+            );
+            ?> + - + - + @@ -207,14 +401,16 @@ else: ?>
- +
★★★★★
-
★★★★★
+
★★★★★
-
: /5
-
()
+
: /5
+
()
@@ -222,31 +418,31 @@ else: ?>
- +
★★★★★
-
★★★★★
+
★★★★★
- - • + + •
-
+
-
+
- - + +
@@ -259,8 +455,8 @@ else: ?> -->
- - - - $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\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\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); -?> - - - -
- - - -
-
-

-

-
-
- - -
- -
- -
@@ -680,7 +489,10 @@ else: ?> - +
+

+

+