Init Git Prod Mars 2026
This commit is contained in:
commit
d81efe93b1
362 files changed
+86627
No files matched your search
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
|
||||
|
||||
|
||||
|
||||
$client_id = $_SESSION['reference_id'];
|
||||
$praticien_id = intval($_POST['praticien_id'] ?? 0);
|
||||
|
||||
if ($praticien_id > 0) {
|
||||
|
||||
|
||||
// Évite les doublons
|
||||
$check = $pdo->prepare("SELECT * FROM praticien_client WHERE client_id = ? AND praticien_id = ?");
|
||||
$check->execute([$client_id, $praticien_id]);
|
||||
|
||||
if (!$check->fetch()) {
|
||||
$stmt = $pdo->prepare("INSERT INTO praticien_client (client_id, praticien_id) VALUES (?, ?)");
|
||||
$stmt->execute([$client_id, $praticien_id]);
|
||||
echo $translations['praticien_ajoute_favoris'];
|
||||
} else {
|
||||
echo $translations['praticien_deja_ajoute'];
|
||||
}
|
||||
} else {
|
||||
echo $translations['praticien_id_invalide'];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
require_once __DIR__ . '/../../push/push_endpoint.php';
|
||||
|
||||
|
||||
// PHPMailer (config centralisée)
|
||||
|
||||
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
// --------- Réponse JSON utilitaire ----------
|
||||
function json_out(bool $ok, string $msg, array $extra = []): never {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok'=>$ok, 'message'=>$msg] + $extra);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --------- Envoi e-mail via PHPMailer (avec ICS optionnel + fallback) ----------
|
||||
function send_email_phpmailer(
|
||||
string $toEmail,
|
||||
string $subject,
|
||||
string $textBody,
|
||||
string $htmlBody,
|
||||
?string $icsFilename = null,
|
||||
?string $icsContent = null
|
||||
): 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'];
|
||||
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Encoding = 'base64';
|
||||
$mail->XMailer = '';
|
||||
|
||||
// Debug ponctuel : ?debug_mail=1
|
||||
if (!empty($_GET['debug_mail'])) {
|
||||
$mail->SMTPDebug = 2;
|
||||
$mail->Debugoutput = function($str,$lvl){ error_log("SMTP[$lvl] ".$str); };
|
||||
}
|
||||
|
||||
if (!empty($MAILCFG['bounce'])) $mail->Sender = $MAILCFG['bounce'];
|
||||
|
||||
$from = $MAILCFG['from'] ?? ['no-reply@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);
|
||||
|
||||
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 = $htmlBody;
|
||||
$mail->AltBody = $textBody;
|
||||
|
||||
if ($icsFilename && $icsContent !== null && empty($_GET['no_ics'])) {
|
||||
$mail->addStringAttachment(
|
||||
$icsContent,
|
||||
$icsFilename,
|
||||
'base64',
|
||||
'text/calendar; method=CANCEL; charset=UTF-8'
|
||||
);
|
||||
}
|
||||
|
||||
$mail->send();
|
||||
return [true, null];
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$err = 'PHPMailer error: '.$e->getMessage();
|
||||
if (!empty($mail->ErrorInfo)) $err .= ' | ErrorInfo: '.$mail->ErrorInfo;
|
||||
error_log($err);
|
||||
|
||||
// Fallback : réessaye SANS ICS
|
||||
try {
|
||||
if ($icsFilename && $icsContent !== null && empty($_GET['no_ics'])) {
|
||||
$mail->clearAttachments();
|
||||
$mail->send();
|
||||
return [true, 'sent_without_ics'];
|
||||
}
|
||||
} catch (Throwable $e2) {
|
||||
$err2 = 'PHPMailer fallback error: '.$e2->getMessage();
|
||||
if (!empty($mail->ErrorInfo)) $err2 .= ' | ErrorInfo: '.$mail->ErrorInfo;
|
||||
error_log($err2);
|
||||
}
|
||||
return [false, $err];
|
||||
}
|
||||
}
|
||||
|
||||
// --------- Helpers e-mail (HTML/Texte) ----------
|
||||
function email_html_cancel(array $data, array $t): string {
|
||||
$brand = '<img src="https://uslugi.fr/public/uploads/img/logo_mail.png" width="150" alt="USLUGI.FR">';
|
||||
$brand_bas ='УСЛУГИ.ФР';
|
||||
return '<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>USLUGI.FR</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:#ffffff;border-radius:8px;overflow:hidden;">
|
||||
<tr><td style="background:#38378B;color:#ffffff;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.5;">'.nl2br(htmlspecialchars($data['hello_line'])).'</td></tr>
|
||||
<tr><td style="padding:8px 24px;color:#111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($data['intro'])).'</td></tr>
|
||||
<tr><td style="padding:16px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;">
|
||||
<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;border-bottom:1px solid #e5e7eb;">'.htmlspecialchars($data['who_label']).'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($data['who_value']).'</td></tr>
|
||||
<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($data['date_label']).'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($data['when']).'</td></tr>
|
||||
<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($data['statut_label']).'</td><td style="padding:14px 16px;font-size:14px;color:#111;">'.htmlspecialchars($data['statut_value']).'</td></tr>
|
||||
</table>
|
||||
</td></tr>'.
|
||||
(!empty($data['cta_url']) ? '<tr><td align="center" style="padding:8px 24px 24px 24px;"><a href="'.htmlspecialchars($data['cta_url']).'" target="_blank" style="display:inline-block;text-decoration:none;padding:12px 20px;border-radius:999px;background:#E03C3F;color:#fff;font-weight:600;">'.htmlspecialchars($data['cta_label']).'</a></td></tr>' : '').
|
||||
'<tr><td style="padding:8px 24px 24px 24px;color:#111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($data['footer'])).'</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').' '.$brand_bas.' — '.htmlspecialchars($t['merci_simple'] ?? 'Merci').'</td></tr></table>
|
||||
</td></tr>
|
||||
</table></body></html>';
|
||||
}
|
||||
function email_text_cancel(array $data, array $t): string {
|
||||
$lines = [];
|
||||
$lines[] = $data['hello_line']; $lines[]='';
|
||||
$lines[] = $data['intro']; $lines[]='';
|
||||
$lines[] = $data['who_label'].': '.$data['who_value'];
|
||||
$lines[] = $data['date_label'].': '.$data['when'];
|
||||
$lines[] = $data['statut_label'].': '.$data['statut_value'];
|
||||
if (!empty($data['cta_url'])) { $lines[]=''; $lines[] = $data['cta_label'].': '.$data['cta_url']; }
|
||||
$lines[]=''; $lines[] = $data['footer'];
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
// --------- ICS CANCEL ----------
|
||||
function ics_escape(string $s): string {
|
||||
$s = str_replace("\\","\\\\",$s);
|
||||
$s = str_replace(";","\;",$s);
|
||||
$s = str_replace(",","\,",$s);
|
||||
return preg_replace("/\r\n|\r|\n/","\\n",$s);
|
||||
}
|
||||
function build_ics_cancel(
|
||||
string $uid,
|
||||
DateTime $start,
|
||||
?DateTime $end,
|
||||
string $timezone,
|
||||
string $summary,
|
||||
string $organizerEmail,
|
||||
array $attendees // [['cn'=>..., 'email'=>...]]
|
||||
): string {
|
||||
$lines=[];
|
||||
$lines[]="BEGIN:VCALENDAR";
|
||||
$lines[]="VERSION:2.0";
|
||||
$lines[]="PRODID:-//УСЛУГИ.ФР//RDV//FR";
|
||||
$lines[]="METHOD:CANCEL";
|
||||
$lines[]="BEGIN:VEVENT";
|
||||
$lines[]="UID:".$uid;
|
||||
$lines[]="DTSTAMP:".gmdate('Ymd\THis\Z');
|
||||
$lines[]="STATUS:CANCELLED";
|
||||
$lines[]="SEQUENCE:2";
|
||||
$lines[]="SUMMARY:".ics_escape($summary);
|
||||
if ($start) $lines[]="DTSTART;TZID={$timezone}:".$start->format('Ymd\THis');
|
||||
if ($end) $lines[]="DTEND;TZID={$timezone}:".$end->format('Ymd\THis');
|
||||
$lines[]="ORGANIZER:mailto:".$organizerEmail;
|
||||
foreach ($attendees as $a) {
|
||||
if (empty($a['email'])) continue;
|
||||
$cn = ics_escape($a['cn'] ?? '');
|
||||
$lines[]="ATTENDEE;ROLE=REQ-PARTICIPANT;CN={$cn}:mailto:".$a['email'];
|
||||
}
|
||||
$lines[]="END:VEVENT";
|
||||
$lines[]="END:VCALENDAR";
|
||||
return implode("\r\n",$lines)."\r\n";
|
||||
}
|
||||
|
||||
// =================== LOGIQUE ===================
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
json_out(false, 'Méthode invalide.');
|
||||
}
|
||||
|
||||
$client_id = $_SESSION['reference_id'] ?? null;
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if (!$client_id || $id <= 0) {
|
||||
json_out(false, $translations['erreur_generique'] ?? 'Erreur.');
|
||||
}
|
||||
|
||||
try {
|
||||
// =========================
|
||||
// SNAPSHOT AVANT ANNULATION
|
||||
// =========================
|
||||
$beforeStmt = $pdo->prepare("
|
||||
SELECT statut, date_rdv, duree_minutes, lieu, praticien_id, client_id
|
||||
FROM rendez_vous
|
||||
WHERE id = ? AND client_id = ?
|
||||
LIMIT 1
|
||||
");
|
||||
$beforeStmt->execute([$id, $client_id]);
|
||||
$beforeRow = $beforeStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$beforeRow) {
|
||||
json_out(false, $translations['erreur_generique'] ?? 'Rendez-vous introuvable.');
|
||||
exit;
|
||||
}
|
||||
// ✅ compte_id praticien + client (pour badge counters)
|
||||
$praticienCompteId = 0;
|
||||
$clientCompteId = 0;
|
||||
|
||||
if (!empty($beforeRow['praticien_id'])) {
|
||||
$st = $pdo->prepare("SELECT id FROM compte WHERE role='praticien' AND reference_id=? LIMIT 1");
|
||||
$st->execute([(int)$beforeRow['praticien_id']]);
|
||||
$praticienCompteId = (int)($st->fetchColumn() ?: 0);
|
||||
}
|
||||
if (!empty($beforeRow['client_id'])) {
|
||||
$st = $pdo->prepare("SELECT id FROM compte WHERE role='client' AND reference_id=? LIMIT 1");
|
||||
$st->execute([(int)$beforeRow['client_id']]);
|
||||
$clientCompteId = (int)($st->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
// 1) Annuler (contrainte client_id)
|
||||
$stmt = $pdo->prepare("UPDATE rendez_vous SET statut = 'annulé' WHERE id = ? AND client_id = ?");
|
||||
$stmt->execute([$id, $client_id]);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
// Rien modifié -> soit déjà annulé, soit pas à ce client, soit ID invalide
|
||||
json_out(false, $translations['aucune_modification'] ?? 'Aucune modification effectuée.');
|
||||
}
|
||||
|
||||
// =========================
|
||||
// LOG RDV (rdv_actions_log) : cancel
|
||||
// =========================
|
||||
$beforeJson = json_encode([
|
||||
'statut' => $beforeRow['statut'],
|
||||
'date_rdv' => $beforeRow['date_rdv'],
|
||||
'duree_minutes' => (int)$beforeRow['duree_minutes'],
|
||||
'lieu' => $beforeRow['lieu'],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$afterJson = json_encode([
|
||||
'statut' => 'annulé',
|
||||
'date_rdv' => $beforeRow['date_rdv'],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$logStmt = $pdo->prepare("
|
||||
INSERT INTO rdv_actions_log
|
||||
(rdv_id, action, by_user_id, by_role, before_json, after_json, created_at)
|
||||
VALUES
|
||||
(?, 'cancel', ?, 'client', ?, ?, NOW())
|
||||
");
|
||||
$logStmt->execute([(int)$id, (int)$client_id, $beforeJson, $afterJson]);
|
||||
// =========================
|
||||
// PUSH RDV — annulation (client)
|
||||
// =========================
|
||||
try {
|
||||
// ✅ Badge RDV : +1 pour le praticien (cancel par client)
|
||||
if ($praticienCompteId > 0) {
|
||||
$pdo->prepare("
|
||||
INSERT INTO push_badge_counters (compte_id, msg_unread, rdv_unread)
|
||||
VALUES (:cid, 0, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
rdv_unread = LEAST(99, rdv_unread + 1),
|
||||
updated_at = NOW()
|
||||
")->execute([':cid' => $praticienCompteId]);
|
||||
}
|
||||
|
||||
// 🔔 Praticien : RDV annulé par le client
|
||||
push_rdv_to_praticien(
|
||||
$pdo,
|
||||
(int)$beforeRow['praticien_id'],
|
||||
$translations['push_rdv_cancel_praticien_title'] ?? 'Запись отменена',
|
||||
$translations['push_rdv_cancel_praticien_body'] ?? 'Клиент отменил запись.',
|
||||
'/public/praticien/rendez_vous.php?id='.(int)$id,
|
||||
[
|
||||
'rdv_id' => (int)$id,
|
||||
'action' => 'cancel',
|
||||
'from_role' => 'client',
|
||||
'client_id' => (int)$beforeRow['client_id'],
|
||||
'praticien_id' => (int)$beforeRow['praticien_id'],
|
||||
'date_rdv' => $beforeRow['date_rdv'],
|
||||
]
|
||||
);
|
||||
|
||||
// 🔔 Client : confirmation annulation
|
||||
push_rdv_to_client(
|
||||
$pdo,
|
||||
(int)$beforeRow['client_id'],
|
||||
$translations['push_rdv_cancel_client_title'] ?? 'Запись отменена',
|
||||
$translations['push_rdv_cancel_client_body'] ?? 'Ваша запись была успешно отменена.',
|
||||
'/public/client/mes_rendez_vous.php',
|
||||
[
|
||||
'rdv_id' => (int)$id,
|
||||
'action' => 'cancel'
|
||||
]
|
||||
);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('push rdv cancel (client) failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
|
||||
// 2) Infos RDV pour e-mails
|
||||
$info = $pdo->prepare("
|
||||
SELECT rv.date_rdv, rv.duree_minutes, rv.uuid_ical,
|
||||
p.email AS praticien_email, p.nom AS praticien_nom, p.prenom AS praticien_prenom,
|
||||
c.prenom AS client_prenom, c.nom AS client_nom, c.email AS client_email
|
||||
FROM rendez_vous rv
|
||||
JOIN praticien p ON rv.praticien_id = p.id
|
||||
JOIN client c ON rv.client_id = c.id
|
||||
WHERE rv.id = ? AND rv.client_id = ?
|
||||
");
|
||||
$info->execute([$id, $client_id]);
|
||||
$r = $info->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$r) {
|
||||
json_out(false, $translations['erreur_generique'] ?? 'Erreur.');
|
||||
}
|
||||
|
||||
// 3) Prépare mails
|
||||
$tz = new DateTimeZone('Europe/Paris');
|
||||
$start = new DateTime($r['date_rdv'], $tz);
|
||||
$end = (clone $start)->modify('+'.(int)($r['duree_minutes'] ?? 60).' minutes');
|
||||
$when = $start->format('d/m/Y H:i');
|
||||
|
||||
$pratFull = $r['praticien_prenom'].' '.$r['praticien_nom'];
|
||||
$cliFull = $r['client_prenom'].' '.$r['client_nom'];
|
||||
|
||||
$cta_url_prat = 'https://uslugi.fr/public/praticien/agenda.php';
|
||||
$cta_url_cli = 'https://uslugi.fr/public/client/mes_rendez_vous.php';
|
||||
$cta_label = $translations['consultez_espace_pour_details'] ?? 'Voir les détails';
|
||||
|
||||
$statut_label = $translations['statut_label'] ?? 'Statut';
|
||||
$statut_value = $translations['statut_annule'] ?? 'Annulé';
|
||||
|
||||
// ---- Mail praticien
|
||||
$subject_p = $translations['email_annulation_sujet'] ?? 'Rendez-vous annulé';
|
||||
$hello_p = ($translations['email_bonjour_dr'] ?? 'Bonjour Dr').' '.$pratFull.',';
|
||||
$intro_p = ($translations['email_rdv_du'] ?? 'Le rendez-vous du').' '.$when.' '.($translations['email_avec'] ?? 'avec').' '.$cliFull.' '.($translations['email_annule_par_client'] ?? 'a été annulé par le client.');
|
||||
|
||||
$data_p = [
|
||||
'hello_line' => $hello_p,
|
||||
'intro' => $intro_p,
|
||||
'who_label' => $translations['client_label'] ?? 'Client',
|
||||
'who_value' => $cliFull,
|
||||
'date_label' => $translations['date_label'] ?? 'Date',
|
||||
'when' => $when,
|
||||
'statut_label'=> $statut_label,
|
||||
'statut_value'=> $statut_value,
|
||||
'cta_url' => $cta_url_prat,
|
||||
'cta_label' => $cta_label,
|
||||
'footer' => $translations['merci_simple'] ?? 'Merci.',
|
||||
];
|
||||
$text_p = email_text_cancel($data_p, $translations);
|
||||
$html_p = email_html_cancel($data_p, $translations);
|
||||
|
||||
$ics_p = null; $ics_name_p = null;
|
||||
if (!empty($r['uuid_ical'])) {
|
||||
$ics_name_p = 'rdv_cancel_'.$r['uuid_ical'].'_praticien.ics';
|
||||
$ics_p = build_ics_cancel(
|
||||
$r['uuid_ical'], $start, $end, 'Europe/Paris',
|
||||
($translations['rdv_avec'] ?? 'Rendez-vous avec').' '.$cliFull,
|
||||
'rdv@uslugi.fr',
|
||||
[
|
||||
['cn'=>$cliFull, 'email'=>$r['client_email']],
|
||||
['cn'=>$pratFull, 'email'=>$r['praticien_email']],
|
||||
]
|
||||
);
|
||||
}
|
||||
[$ok_p, $err_p] = send_email_phpmailer($r['praticien_email'], $subject_p, $text_p, $html_p, $ics_name_p, $ics_p);
|
||||
if (!$ok_p) error_log("Annulation RDV: mail praticien NON envoyé: ".$err_p);
|
||||
|
||||
// ---- Mail client
|
||||
$subject_c = $translations['email_annulation_client_sujet'] ?? 'Confirmation d’annulation de votre rendez-vous';
|
||||
$hello_c = sprintf($translations['mail_valid_bonjour_nom'] ?? 'Bonjour %s', $cliFull);
|
||||
$intro_c = ($translations['email_votre_rdv_du'] ?? 'Votre rendez-vous du').' '.$when.' '.($translations['email_avec'] ?? 'avec').' '.(($translations['titre_dr'] ?? 'Dr').' '.$pratFull).' '.($translations['email_a_ete_annule'] ?? 'a été annulé.');
|
||||
|
||||
$who_label_cli = $translations['praticien_label'] ?? ($translations['titre_dr'] ?? 'Dr');
|
||||
$who_value_cli = ($translations['titre_dr'] ?? 'Dr').' '.$pratFull;
|
||||
|
||||
$data_c = [
|
||||
'hello_line' => $hello_c,
|
||||
'intro' => $intro_c,
|
||||
'who_label' => $who_label_cli,
|
||||
'who_value' => $who_value_cli,
|
||||
'date_label' => $translations['date_label'] ?? 'Date',
|
||||
'when' => $when,
|
||||
'statut_label'=> $statut_label,
|
||||
'statut_value'=> $statut_value,
|
||||
'cta_url' => $cta_url_cli,
|
||||
'cta_label' => $cta_label,
|
||||
'footer' => $translations['merci_simple'] ?? 'Merci.',
|
||||
];
|
||||
$text_c = email_text_cancel($data_c, $translations);
|
||||
$html_c = email_html_cancel($data_c, $translations);
|
||||
|
||||
$ics_c = null; $ics_name_c = null;
|
||||
if (!empty($r['uuid_ical'])) {
|
||||
$ics_name_c = 'rdv_cancel_'.$r['uuid_ical'].'_client.ics';
|
||||
$ics_c = build_ics_cancel(
|
||||
$r['uuid_ical'], $start, $end, 'Europe/Paris',
|
||||
($translations['rdv_avec'] ?? 'Rendez-vous avec').' '.(($translations['titre_dr'] ?? 'Dr').' '.$pratFull),
|
||||
'rdv@uslugi.fr',
|
||||
[
|
||||
['cn'=>$cliFull, 'email'=>$r['client_email']],
|
||||
['cn'=>$pratFull, 'email'=>$r['praticien_email']],
|
||||
]
|
||||
);
|
||||
}
|
||||
[$ok_c, $err_c] = send_email_phpmailer($r['client_email'], $subject_c, $text_c, $html_c, $ics_name_c, $ics_c);
|
||||
if (!$ok_c) error_log("Annulation RDV: mail client NON envoyé: ".$err_c);
|
||||
|
||||
json_out(true, $translations['rdv_annule_ok'] ?? 'Rendez-vous annulé.');
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('Annulation RDV error: '.$e->getMessage());
|
||||
json_out(false, $translations['erreur_generique'] ?? 'Erreur serveur.');
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
require_once __DIR__.'/../includes/header.php';
|
||||
|
||||
$praticien_id = (int)($_GET['praticien_id'] ?? 0);
|
||||
if ($praticien_id <= 0) { echo "<div class='container py-5'>ID invalide.</div>"; exit; }
|
||||
|
||||
if (empty($_SESSION['csrf_token'])) $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
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($nom,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'] ?? '')
|
||||
);
|
||||
|
||||
?>
|
||||
<div class="main1">
|
||||
<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="/public/client/detail_praticien.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>
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
|
||||
|
||||
|
||||
$success = $erreur = '';
|
||||
$id = $_SESSION['compte_id'];
|
||||
|
||||
$stmt = $pdo->prepare("SELECT email FROM compte WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$compte = $stmt->fetch();
|
||||
|
||||
if (!$compte) die($translations['compte_introuvable']);
|
||||
|
||||
$email_actuel = $compte['email'];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$nouvel_email = trim($_POST['email'] ?? '');
|
||||
$mdp1 = $_POST['mot_de_passe'] ?? '';
|
||||
$mdp2 = $_POST['confirm_mot_de_passe'] ?? '';
|
||||
|
||||
if (!filter_var($nouvel_email, FILTER_VALIDATE_EMAIL)) {
|
||||
$erreur = $translations['erreur_email_invalide_format'];
|
||||
} elseif ($mdp1 && ($mdp1 !== $mdp2 || strlen($mdp1) < 8)) {
|
||||
$erreur = $translations['erreur_mdp_invalide_ou_non_confirme'];
|
||||
} else {
|
||||
// Vérifie que l'email n’est pas déjà pris (sauf pour soi)
|
||||
$stmt = $pdo->prepare("SELECT id FROM compte WHERE email = ? AND id != ?");
|
||||
$stmt->execute([$nouvel_email, $id]);
|
||||
if ($stmt->fetch()) {
|
||||
$erreur = $translations['email_deja_utilise'];
|
||||
} else {
|
||||
// Mise à jour
|
||||
if ($mdp1) {
|
||||
$hash = password_hash($mdp1, PASSWORD_DEFAULT);
|
||||
$stmt = $pdo->prepare("UPDATE compte SET email = ?, mot_de_passe = ? WHERE id = ?");
|
||||
$stmt->execute([$nouvel_email, $hash, $id]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("UPDATE compte SET email = ? WHERE id = ?");
|
||||
$stmt->execute([$nouvel_email, $id]);
|
||||
}
|
||||
$success = $translations['maj_effectuee_succes'];
|
||||
}
|
||||
}
|
||||
}
|
||||
require_once '../includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="main1">
|
||||
<div class="container py-5">
|
||||
|
||||
|
||||
<?php if ($erreur): ?>
|
||||
<div class="alert alert-danger"><?= $erreur ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success"><?= $success ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" class="shadow p-4 rounded bg-light">
|
||||
<h2 class="text-center mb-4"> <?= $translations['mes_infos_connexion'] ?></h2>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= $translations['label_email'] ?></label>
|
||||
<input type="email" name="email" value="<?= htmlspecialchars($email_actuel) ?>" required class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= $translations['label_nouveau_mdp_facultatif'] ?></label>
|
||||
<div class="field-wrapper">
|
||||
<input type="password" name="mot_de_passe" id="mot_de_passe" placeholder="<?= $translations['nouveau_mdp_placeholder'] ?>" class="form-control">
|
||||
<span class="toggle-password" onclick="toggle('mot_de_passe')">👁️</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="strength-bar mb-1">
|
||||
<div class="strength-fill"></div>
|
||||
<div class="strength-fill"></div>
|
||||
<div class="strength-fill"></div>
|
||||
</div>
|
||||
<div id="niveau-securite" class="mb-3 text-muted"><?= $translations['securite_indetermine'] ?></div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label"><?= $translations['label_confirmer_mdp'] ?></label>
|
||||
<div class="field-wrapper">
|
||||
<input type="password" name="confirm_mot_de_passe" id="confirm_mot_de_passe" placeholder="<?= $translations['confirm_mdp_placeholder'] ?>" class="form-control">
|
||||
<span class="toggle-password" onclick="toggle('confirm_mot_de_passe')">👁️</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<button type="submit" class="btn btn-primary"> <?= $translations['btn_mettre_a_jour'] ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function toggle(id) {
|
||||
const el = document.getElementById(id);
|
||||
el.type = el.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
|
||||
document.getElementById('mot_de_passe').addEventListener('input', function () {
|
||||
const val = this.value;
|
||||
const segments = document.querySelectorAll(".strength-fill");
|
||||
const niveauText = document.getElementById("niveau-securite");
|
||||
|
||||
segments.forEach(s => s.style.backgroundColor = '#e0e0e0');
|
||||
niveauText.textContent = <?= json_encode($translations['securite_indetermine']) ?>;
|
||||
niveauText.className = 'text-muted';
|
||||
|
||||
const longOk = val.length >= 8;
|
||||
const lettre = /[A-Za-z]/.test(val);
|
||||
const chiffre = /\d/.test(val);
|
||||
const maj = /[A-Z]/.test(val);
|
||||
const special = /[^A-Za-z0-9]/.test(val);
|
||||
|
||||
let force = 0;
|
||||
if (longOk && lettre && chiffre) force++;
|
||||
if (maj) force++;
|
||||
if (special) force++;
|
||||
|
||||
if (force === 1) {
|
||||
segments[0].style.backgroundColor = 'red';
|
||||
niveauText.textContent = <?= json_encode($translations['securite_faible']) ?>;
|
||||
niveauText.className = 'text-danger';
|
||||
} else if (force === 2) {
|
||||
segments[0].style.backgroundColor = 'orange';
|
||||
segments[1].style.backgroundColor = 'orange';
|
||||
niveauText.textContent = <?= json_encode($translations['securite_moyen']) ?>;
|
||||
niveauText.className = 'text-warning';
|
||||
} else if (force >= 3) {
|
||||
segments.forEach(s => s.style.backgroundColor = 'green');
|
||||
niveauText.textContent = <?= json_encode($translations['securite_fort']) ?>;
|
||||
niveauText.className = 'text-success';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?= $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
|
||||
|
||||
|
||||
$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);
|
||||
require_once __DIR__.'/../includes/header.php'; // inclut lang + styles
|
||||
?>
|
||||
<div class="main1">
|
||||
<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="/public/client/avis.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,246 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
|
||||
|
||||
$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);
|
||||
require_once __DIR__.'/../includes/header.php'; // inclut lang + styles
|
||||
?>
|
||||
<div class="main1">
|
||||
<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 -->
|
||||
<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>
|
||||
|
||||
</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="/public/client/avis.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,77 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
|
||||
|
||||
if (!isset($_GET['praticien_id'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $translations['id_praticien_manquant']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$praticien_id = intval($_GET['praticien_id']);
|
||||
|
||||
// Récupérer la durée d'un rendez-vous
|
||||
$stmt = $pdo->prepare("SELECT duree_rdv FROM praticien WHERE id = ?");
|
||||
$stmt->execute([$praticien_id]);
|
||||
$duree_minutes = (int)$stmt->fetchColumn();
|
||||
if (!$duree_minutes) {
|
||||
echo json_encode([]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$date_debut = new DateTimeImmutable();
|
||||
$date_fin = $date_debut->modify('+3 weeks');
|
||||
|
||||
// Récupérer toutes les disponibilités du praticien
|
||||
$stmt = $pdo->prepare("SELECT jour_semaine, heure_debut, heure_fin FROM disponibilite WHERE praticien_id = ?");
|
||||
$stmt->execute([$praticien_id]);
|
||||
$dispos = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Récupérer tous les rendez-vous déjà réservés dans cette période
|
||||
$stmt = $pdo->prepare("SELECT date_rdv FROM rendez_vous WHERE praticien_id = ? AND date_rdv BETWEEN ? AND ?");
|
||||
$stmt->execute([$praticien_id, $date_debut->format('Y-m-d H:i:s'), $date_fin->format('Y-m-d H:i:s')]);
|
||||
$reservations_existantes = array_map(function($r) {
|
||||
return (new DateTimeImmutable($r['date_rdv']))->format('Y-m-d H:i');
|
||||
}, $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
|
||||
$events = [];
|
||||
|
||||
$periode = new DatePeriod(
|
||||
$date_debut,
|
||||
new DateInterval('P1D'),
|
||||
$date_fin
|
||||
);
|
||||
|
||||
foreach ($periode as $jour) {
|
||||
$jour_semaine = (int)$jour->format('N'); // 1 = lundi, 7 = dimanche
|
||||
|
||||
|
||||
foreach ($dispos as $dispo) {
|
||||
if ((int)$dispo['jour_semaine'] !== $jour_semaine) continue;
|
||||
|
||||
$heure_debut = DateTime::createFromFormat('H:i:s', $dispo['heure_debut']);
|
||||
$heure_fin = DateTime::createFromFormat('H:i:s', $dispo['heure_fin']);
|
||||
if (!$heure_debut || !$heure_fin) continue;
|
||||
|
||||
$debut_creneau = (new DateTimeImmutable($jour->format('Y-m-d') . ' ' . $heure_debut->format('H:i:s')));
|
||||
$fin_creneau = (new DateTimeImmutable($jour->format('Y-m-d') . ' ' . $heure_fin->format('H:i:s')));
|
||||
|
||||
while ($debut_creneau->add(new DateInterval('PT' . $duree_minutes . 'M')) <= $fin_creneau) {
|
||||
$fin = $debut_creneau->add(new DateInterval('PT' . $duree_minutes . 'M'));
|
||||
$start_str = $debut_creneau->format('Y-m-d H:i');
|
||||
|
||||
if (!in_array($start_str, $reservations_existantes)) {
|
||||
$events[] = [
|
||||
'title' => $debut_creneau->format('H:i'),
|
||||
'start' => $debut_creneau->format(DateTime::ATOM),
|
||||
'end' => $fin->format(DateTime::ATOM)
|
||||
];
|
||||
}
|
||||
|
||||
$debut_creneau = $fin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($events);
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
|
||||
|
||||
// Récupérer les infos du client
|
||||
$stmt = $pdo->prepare("SELECT * FROM client WHERE id = ?");
|
||||
$stmt->execute([$_SESSION['reference_id']]);
|
||||
$client = $stmt->fetch();
|
||||
|
||||
if (!$client) {
|
||||
echo "<p>{$translations['impossible_charger_infos']}</p>";
|
||||
exit;
|
||||
}
|
||||
require_once '../includes/header.php';
|
||||
?>
|
||||
<div class="main1">
|
||||
<div class="container py-5">
|
||||
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-4 text-center"><?= $translations['mes_infos_heading'] ?></h2>
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item"><strong><?= $translations['label_nom'] ?> :</strong> <?= htmlspecialchars($client['nom'] ?? '') ?></li>
|
||||
<li class="list-group-item"><strong><?= $translations['label_prenom'] ?> :</strong> <?= htmlspecialchars($client['prenom'] ?? '') ?></li>
|
||||
<li class="list-group-item"><strong><?= $translations['label_email'] ?> :</strong> <?= htmlspecialchars($client['email'] ?? '') ?></li>
|
||||
<li class="list-group-item"><strong><?= $translations['label_telephone'] ?> :</strong> <?= htmlspecialchars($client['telephone'] ?? '') ?></li>
|
||||
<li class="list-group-item"><strong><?= $translations['label_genre'] ?> :</strong> <?= htmlspecialchars($client['genre'] ?? '') ?></li>
|
||||
<li class="list-group-item"><strong><?= $translations['label_date_naissance'] ?> :</strong> <?= date('d/m/Y', strtotime(htmlspecialchars($client['date_naissance'] ?? ''))) ?></li>
|
||||
<li class="list-group-item"><strong><?= $translations['label_adresse'] ?> :</strong> <?= nl2br(htmlspecialchars($client['adresse'] ?? '')) ?></li>
|
||||
<li class="list-group-item"><strong><?= $translations['label_ville'] ?> :</strong> <?= htmlspecialchars($client['ville'] ?? '') ?></li>
|
||||
<li class="list-group-item"><strong><?= $translations['label_code_postal'] ?> :</strong> <?= htmlspecialchars($client['code_postal'] ?? '') ?></li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<a href="/public/client/modifier.php" class="btn btn-primary">
|
||||
<?= $translations['btn_modifier_mes_infos'] ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?= $translations['copyr'] ?></p>
|
||||
</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,490 @@
|
||||
<?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 !== '');
|
||||
|
||||
/* =========================
|
||||
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'] ?? '').' '.($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[] = "(date_expiration IS NOT NULL AND 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(prenom,''),' ',COALESCE(nom,''))) LIKE $k1
|
||||
OR LOWER(COALESCE(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(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(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".$whereClause;
|
||||
$stmt = $pdo->prepare($sqlCount);
|
||||
$stmt->execute($params);
|
||||
$total = (int)$stmt->fetchColumn();
|
||||
$totalPages = max(1, (int)ceil($total / $perPage));
|
||||
|
||||
/* Liste (paginée si pas de recherche, sinon tout) */
|
||||
$sqlList = "
|
||||
SELECT id, titre, nom, prenom, photo, specialite, ville, code_postal, presentation, video
|
||||
FROM praticien
|
||||
$whereClause
|
||||
ORDER BY specialite ASC, nom ASC
|
||||
";
|
||||
if (!$isSearch) {
|
||||
$sqlList .= " LIMIT :limit OFFSET :offset";
|
||||
}
|
||||
$stmt = $pdo->prepare($sqlList);
|
||||
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"><?= 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'] ?? '';
|
||||
$ville = $p['ville'] ?? '';
|
||||
$cp = $p['code_postal'] ?? '';
|
||||
$present= excerpt_words((string)($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="<?= htmlspecialchars(mb_strtolower($name), ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-spec="<?= htmlspecialchars(mb_strtolower($spec), ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-ville="<?= htmlspecialchars(mb_strtolower($ville), ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-cp="<?= htmlspecialchars(mb_strtolower($cp), ENT_QUOTES, 'UTF-8') ?>">
|
||||
|
||||
<!-- Photo -->
|
||||
<div class="pro-photo">
|
||||
<a href="/public/client/detail_praticien.php?id=<?= (int)$p['id'] ?>"><img
|
||||
src="<?= !empty($p['photo']) ? '/public/uploads/'.htmlspecialchars($p['photo'], ENT_QUOTES, 'UTF-8') : '/public/uploads/img/user_placeholder.png' ?>"
|
||||
alt="<?= 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=<?= (int)$p['id'] ?>"><?= htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?></a></h2>
|
||||
<?php if ($spec): ?><div class="pro-spec"><?= htmlspecialchars($spec, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
|
||||
<?php if ($present): ?>
|
||||
<p class="pro-desc"><?= nl2br(htmlspecialchars($present, ENT_QUOTES, 'UTF-8')) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<a href="#" class="favori_lien" data-id="<?= (int)$p['id'] ?>" data-loading="0">
|
||||
<?= htmlspecialchars($translations['plus_favorits'] ?? 'Ajouter aux favoris', ENT_QUOTES, 'UTF-8') ?>
|
||||
</a>
|
||||
<a href="/public/client/detail_praticien.php?id=<?= (int)$p['id'] ?>">
|
||||
<?= 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="<?= $href ?>"
|
||||
aria-label="<?= $altVid ?>">
|
||||
<?php if ($thumb): ?>
|
||||
<img src="<?= htmlspecialchars($thumb, ENT_QUOTES, 'UTF-8') ?>" alt="<?= $altVid ?>" loading="lazy">
|
||||
<?php else: ?>
|
||||
<img src="/public/uploads/img/video_placeholder.jpg" alt="<?= $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=<?= (int)$p['id'] ?>">
|
||||
<?= 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 <?= $page<=1?'is-disabled':'' ?>"
|
||||
href="?page=<?= max(1,$page-1) ?>" aria-label="Page précédente">««</a>
|
||||
|
||||
<?php
|
||||
$start = max(1, $page-2);
|
||||
$end = min($totalPages, $page+2);
|
||||
for ($p=$start; $p<=$end; $p++):
|
||||
?>
|
||||
<a class="pager-page <?= $p===$page?'is-active':'' ?>" href="?page=<?= $p ?>"><?= $p ?></a>
|
||||
<?php endfor; ?>
|
||||
|
||||
<a class="pager-arrow <?= $page>=$totalPages?'is-disabled':'' ?>"
|
||||
href="?page=<?= min($totalPages,$page+1) ?>" aria-label="Page suivante">»»</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><?= 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="<?= htmlspecialchars($translations['n_special'] ?? 'spécialité, nom…', ENT_QUOTES, 'UTF-8') ?>"
|
||||
aria-label="spécialité, nom" value="<?= htmlspecialchars($qRaw, ENT_QUOTES, 'UTF-8') ?>">
|
||||
</div>
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
<div class="input-wrap">
|
||||
<input id="where" type="text"
|
||||
placeholder="<?= htmlspecialchars($translations['gde'] ?? 'ville ou code postal', ENT_QUOTES, 'UTF-8') ?>"
|
||||
aria-label="ville ou code postal" value="<?= 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><?= $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"><?= htmlspecialchars($translations['video_titre'] ?? 'Vidéo') ?></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?= 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">
|
||||
<?= htmlspecialchars($translations['fermer'] ?? 'Fermer') ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JS -->
|
||||
<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';
|
||||
fetch('/public/client/ajouter_favori.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: new URLSearchParams({ praticien_id: praticienId }).toString()
|
||||
})
|
||||
.then(async (r) => {
|
||||
const text = await r.text();
|
||||
if (!r.ok) throw new Error(text || 'Erreur réseau');
|
||||
const deja = /déjà|already/i.test(text);
|
||||
el.classList.add('ajoute'); el.setAttribute('aria-pressed', 'true'); el.title = text;
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
icon: deja ? 'info' : 'success',
|
||||
title: <?= json_encode($translations['sw_favori_ajoute_title'] ?? 'Favori ajouté !', JSON_UNESCAPED_UNICODE) ?>,
|
||||
text: text,
|
||||
timer: 1800,
|
||||
showConfirmButton: false
|
||||
});
|
||||
} else { alert(text); }
|
||||
})
|
||||
.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: <?= json_encode($translations['confirmation'] ?? 'Confirmation') ?>,
|
||||
text: <?= json_encode($translations['voulez_vous_ajouter_favori'] ?? 'Ajouter ce praticien à vos favoris ?') ?>,
|
||||
ok: <?= json_encode($translations['btn_oui'] ?? 'Oui') ?>,
|
||||
cancel:<?= 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>
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
|
||||
|
||||
|
||||
$client_id = $_SESSION['reference_id'];
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT p.*
|
||||
FROM praticien_client pc
|
||||
JOIN praticien p ON pc.praticien_id = p.id
|
||||
WHERE pc.client_id = ?
|
||||
ORDER BY p.nom ASC
|
||||
");
|
||||
$stmt->execute([$client_id]);
|
||||
$favoris = $stmt->fetchAll();
|
||||
|
||||
// util pour couper les mots de la description texte
|
||||
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
|
||||
function excerpt($text, $len=160){
|
||||
$t = trim(strip_tags((string)$text));
|
||||
if (mb_strlen($t,'UTF-8') <= $len) return $t;
|
||||
return mb_substr($t, 0, $len, 'UTF-8') . '…';
|
||||
}
|
||||
|
||||
|
||||
|
||||
$PAGE_TITLE = $translations['mes_praticiens_favoris_title'] ?? 'Mes praticiens favoris';
|
||||
$HEAD_EXTRA = <<<HTML
|
||||
<script src="/js/sweetalert2@11.js"></script>
|
||||
HTML;
|
||||
|
||||
require_once '../includes/header.php';
|
||||
?>
|
||||
<div class="main1">
|
||||
<div class="container py-5">
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-4 text-center"> <?= $translations['mes_praticiens_favoris_heading'] ?></h2>
|
||||
|
||||
<?php if (empty($favoris)): ?>
|
||||
<div class="alert alert-info"><?= $translations['aucun_favori_pour_le_moment'] ?></div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($favoris as $p): ?>
|
||||
<div class="card mb-4 shadow-sm" id="praticien_<?= $p['id'] ?>">
|
||||
<div class="card-body praticien-card">
|
||||
<div>
|
||||
<?php if ($p['photo']): ?>
|
||||
<img src="/public/uploads/<?= htmlspecialchars($p['photo']) ?>" alt="<?= $translations['alt_photo'] ?>" class="card-img-top">
|
||||
<?php else: ?>
|
||||
<img src="/public/uploads/img/user_placeholder.png" alt="<?= $translations['alt_photo'] ?>" class="card-img-top">
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<h2 class="pro-name"><?= htmlspecialchars($p['prenom']?? '') . ' ' . htmlspecialchars($p['nom']?? '') ?></h5>
|
||||
<p class="mb-1"><strong><?= $translations['label_ville'] ?> :</strong> <?= htmlspecialchars($p['ville'] ?? '') ?></p>
|
||||
<p class="mb-2">
|
||||
<?php if (!empty($p['presentation'])): ?>
|
||||
<?= h(excerpt($p['presentation'], 300)) ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<div class="d-flex gap-3">
|
||||
<button class="btn btn-danger btn-sm" onclick="supprimerFavori(<?= $p['id'] ?>)"> <?= $translations['btn_retirer'] ?></button>
|
||||
<a class="btn btn-outline-secondary btn-sm btn-table" href="/public/client/reserver_rdv.php?praticien_id=<?= $p['id'] ?>"> <?= $translations['btn_rendez_vous'] ?></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function supprimerFavori(praticienId) {
|
||||
Swal.fire({
|
||||
title: <?= json_encode($translations['confirm_supprimer_favori_title']) ?>,
|
||||
text: <?= json_encode($translations['confirm_supprimer_favori_text']) ?>,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#dc3545',
|
||||
cancelButtonColor: '#6c757d',
|
||||
confirmButtonText: <?= json_encode($translations['btn_oui_supprimer']) ?>,
|
||||
cancelButtonText: <?= json_encode($translations['btn_annuler']) ?>
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
fetch('/public/client/supprimer_favori.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'praticien_id=' + praticienId
|
||||
})
|
||||
.then(res => res.text())
|
||||
.then(msg => {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: <?= json_encode($translations['favori_supprime_title']) ?>,
|
||||
text: msg,
|
||||
timer: 1500,
|
||||
showConfirmButton: false
|
||||
});
|
||||
const element = document.getElementById('praticien_' + praticienId);
|
||||
if (element) element.remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?= $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
|
||||
|
||||
|
||||
$pdo->prepare("
|
||||
INSERT INTO push_badge_counters (compte_id, msg_unread, rdv_unread, updated_at)
|
||||
VALUES (:cid, 0, 0, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
rdv_unread = 0,
|
||||
updated_at = NOW()
|
||||
")->execute([':cid' => (int)$_SESSION['compte_id']]);
|
||||
|
||||
|
||||
|
||||
$client_id = $_SESSION['reference_id'];
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
rv.*,
|
||||
p.nom AS nom_praticien,
|
||||
p.prenom AS prenom_praticien,
|
||||
p.email AS email_praticien,
|
||||
p.adresse AS p_adresse,
|
||||
p.ville AS p_ville,
|
||||
p.code_postal AS p_cp
|
||||
FROM rendez_vous rv
|
||||
JOIN praticien p ON rv.praticien_id = p.id
|
||||
WHERE rv.client_id = ? AND rv.date_rdv >= NOW()
|
||||
ORDER BY rv.date_rdv ASC
|
||||
");
|
||||
$stmt->execute([$client_id]);
|
||||
$rdvs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$PAGE_TITLE = $translations['mes_rdv_title'] ?? 'Mes rendez-vous';
|
||||
$HEAD_EXTRA = <<<HTML
|
||||
<script src="/js/sweetalert2@11.js"></script>
|
||||
HTML;
|
||||
require_once '../includes/header.php';
|
||||
?>
|
||||
<div class="main1">
|
||||
<div class="container py-5">
|
||||
<div class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-4 text-center">📅 <?= $translations['mes_rdv_heading'] ?></h2>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle mobile-cards" id="table_rdv">
|
||||
<thead class="table-light">
|
||||
<tr align="center">
|
||||
<th><?= $translations['col_date'] ?></th>
|
||||
<th><?= $translations['col_heure'] ?></th>
|
||||
<th><?= $translations['col_praticien'] ?></th>
|
||||
<th><?= $translations['col_duree'] ?></th>
|
||||
<th><?= $translations['col_statut'] ?></th>
|
||||
<th><?= $translations['col_action'] ?></th>
|
||||
<!-- Chat -->
|
||||
<th><?= $translations['btn_chat'] ?></th>
|
||||
<!-- Visio / Adresse -->
|
||||
<th><?= htmlspecialchars($translations['status_online']) ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($rdvs)): ?>
|
||||
<tr><td colspan="8" class="text-center"><?= $translations['aucun_rdv_a_venir'] ?></td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($rdvs as $r):
|
||||
$dt = new DateTime($r['date_rdv']);
|
||||
$id = (int)$r['id'];
|
||||
$ficheUrl = '/specialiste?id='.(int)$r['praticien_id'];
|
||||
?>
|
||||
<tr>
|
||||
<td><?= $dt->format('d/m/Y') ?></td>
|
||||
<td><?= $dt->format('H:i') ?></td>
|
||||
<td>
|
||||
<a href="/public/client/detail_praticien.php?id=<?= (int)$r['praticien_id'] ?>">
|
||||
<?= htmlspecialchars($r['prenom_praticien'] . ' ' . $r['nom_praticien']) ?>
|
||||
</a>
|
||||
</td>
|
||||
<td><?= (int)$r['duree_minutes'] ?> <?= $translations['min_abbr'] ?></td>
|
||||
|
||||
<td id="statut_<?= $id ?>">
|
||||
<span class="badge rounded-pill <?= $r['statut'] === 'confirmé' ? 'bg-success' : ($r['statut'] === 'prévu' ? 'bg-warning text-dark' : 'bg-secondary') ?>">
|
||||
<?= $r['statut'] === 'confirmé' ? $translations['statut_confirme'] : ($r['statut'] === 'prévu' ? $translations['statut_prevu'] : $translations['statut_annule']) ?>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<?php if ($r['statut'] !== 'annulé'): ?>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="annulerRdv(<?= $id ?>)"> <?= $translations['btn_annuler'] ?></button>
|
||||
<?php else: ?>
|
||||
<em class="text-muted"><?= $translations['statut_annule'] ?></em>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<!-- Chat -->
|
||||
<td>
|
||||
<a class="btn btn-outline-secondary btn-sm btn-table" href="/messages/start.php?praticien_id=<?= (int)$r['praticien_id'] ?>">
|
||||
<?= $translations['chat_praticien'] ?>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<!-- Visio (online) OU Adresse/Lien (offline) -->
|
||||
<td>
|
||||
<?php
|
||||
$lieu = strtolower(trim((string)($r['lieu'] ?? '')));
|
||||
if ($lieu === 'online') {
|
||||
// --- Affichage visio comme avant ---
|
||||
$statut = $r['statut']; // 'confirmé' | 'prévu' | 'annulé'
|
||||
|
||||
$tzParis = new DateTimeZone('Europe/Paris');
|
||||
$now = new DateTime('now', $tzParis);
|
||||
$start = new DateTime($r['date_rdv'], $tzParis);
|
||||
$open = (clone $start)->modify('-15 minutes');
|
||||
$close = (clone $start)->modify('+'.(int)($r['duree_minutes'] ?? 120).' minutes');
|
||||
|
||||
if ($statut === 'confirmé') {
|
||||
if ($now >= $open && $now <= $close) {
|
||||
// Bouton actif
|
||||
?>
|
||||
<a href="/visio/join.php?rdv=<?= (int)$r['id'] ?>&start=1" class="btn btn-outline-secondary btn-sm btn-table">
|
||||
<?= htmlspecialchars($translations['visio_nachalo']) ?>
|
||||
</a>
|
||||
<?php
|
||||
} else {
|
||||
// Messages hors fenêtre
|
||||
?>
|
||||
<small class="text-muted d-block mt-1">
|
||||
<?php
|
||||
if ($now < $open) echo htmlspecialchars(($translations['ouvert_a'] ?? 'Откроется в')).' '.$open->format('H:i');
|
||||
elseif ($now > $close) echo htmlspecialchars(($translations['ferme_a'] ?? 'Закрыто в')).' '.$close->format('H:i');
|
||||
else echo htmlspecialchars(($translations['prev_a'] ?? 'Запланировано на')).' '.$start->format('d/m/Y \à H:i').'.';
|
||||
?>
|
||||
</small>
|
||||
<?php
|
||||
}
|
||||
} elseif ($statut === 'prévu') {
|
||||
// Pas encore confirmé
|
||||
?>
|
||||
<small class="text-danger d-block mt-1">
|
||||
<?= htmlspecialchars($translations['votre_rdv_jdet'] ?? 'En attente de confirmation') ?>
|
||||
</small>
|
||||
<?php
|
||||
} else {
|
||||
?>
|
||||
<small class="text-muted d-block mt-1">
|
||||
<?= htmlspecialchars($translations['statut_annule'] ?? 'Annulé') ?>
|
||||
</small>
|
||||
<?php
|
||||
}
|
||||
|
||||
} else {
|
||||
// --- OFFLINE : afficher adresse (si dispo) + lien fiche praticien ---
|
||||
$addrParts = array_filter([
|
||||
trim((string)($r['p_adresse'] ?? '')),
|
||||
trim((string)($r['p_cp'] ?? '')) . ' ' . trim((string)($r['p_ville'] ?? ''))
|
||||
], fn($x) => $x !== '' && $x !== ' ');
|
||||
$addr = trim(implode(', ', $addrParts));
|
||||
|
||||
if ($addr !== '') {
|
||||
//echo '<div class="mb-1"> '.htmlspecialchars($addr).'</div>';
|
||||
}
|
||||
?>
|
||||
<a href="<?= htmlspecialchars($ficheUrl) ?>" class="btn btn-outline-secondary btn-sm btn-table">
|
||||
<?= htmlspecialchars($translations['voir_fiche_praticien'] ?? 'Voir la fiche') ?>
|
||||
</a>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function annulerRdv(id) {
|
||||
Swal.fire({
|
||||
title: <?= json_encode($translations['confirm_annulation_title']) ?>,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: <?= json_encode($translations['btn_oui_annuler']) ?>,
|
||||
cancelButtonText: <?= json_encode($translations['btn_annuler']) ?>,
|
||||
confirmButtonColor: '#dc3545',
|
||||
cancelButtonColor: '#6c757d'
|
||||
}).then((result) => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
fetch('/public/client/annuler_rdv.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
|
||||
body: new URLSearchParams({ id: String(id) })
|
||||
})
|
||||
.then(r => r.json().catch(() => ({ok:false, message:'Bad JSON'})))
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
Swal.fire({ icon:'success', title: <?= json_encode($translations['rdv_annule_ok']) ?>, text: data.message, timer: 1600, showConfirmButton:false });
|
||||
const statutEl = document.getElementById("statut_" + id);
|
||||
if (statutEl) {
|
||||
statutEl.innerHTML = '<span class="badge bg-secondary"><?= htmlspecialchars($translations['statut_annule']) ?></span>';
|
||||
}
|
||||
} else {
|
||||
Swal.fire({ icon:'error', title: 'Oops', text: data.message || 'Erreur' });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
Swal.fire({ icon:'error', title:'Erreur réseau', text:'Veuillez réessayer.' });
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Affichage mobile -->
|
||||
<script defer>
|
||||
(function(){
|
||||
function initMobileCards(tbl){
|
||||
if (!tbl) return;
|
||||
const heads = Array.from(tbl.querySelectorAll('thead th')).map(th => th.textContent.trim());
|
||||
tbl.querySelectorAll('tbody tr').forEach(tr => {
|
||||
Array.from(tr.children).forEach((td, i) => {
|
||||
if (!td.hasAttribute('data-label')) {
|
||||
td.setAttribute('data-label', heads[i] || '');
|
||||
}
|
||||
});
|
||||
});
|
||||
document.documentElement.classList.add('mc-ready');
|
||||
}
|
||||
function ready(fn){
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
|
||||
else fn();
|
||||
}
|
||||
ready(function(){
|
||||
const tbl = document.getElementById('table_rdv') || document.querySelector('table.mobile-cards');
|
||||
initMobileCards(tbl);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?= $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
require_once __DIR__ . '/../../sms/notify_rdv.php';
|
||||
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
function send_rdv_email_with_ics_phpmailer(
|
||||
string $toEmail, string $toName,
|
||||
string $subject, string $textBody, string $htmlBody,
|
||||
string $ical, string $filename
|
||||
): bool {
|
||||
global $MAILCFG; // vient de /includes/mailer.php
|
||||
|
||||
// garde-fous utiles
|
||||
if (!filter_var($toEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
error_log('PHPMailer RDV: invalid recipient email: ' . var_export($toEmail, true));
|
||||
return false;
|
||||
}
|
||||
if (empty($MAILCFG) || empty($MAILCFG['smtp']['host'])) {
|
||||
error_log('PHPMailer RDV: MAILCFG missing/incomplete.');
|
||||
return false;
|
||||
}
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
try {
|
||||
// SMTP
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $MAILCFG['smtp']['host'];
|
||||
$mail->Port = (int)$MAILCFG['smtp']['port'];
|
||||
$mail->SMTPSecure = $MAILCFG['smtp']['secure'] ?? PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->SMTPAuth = (bool)$MAILCFG['smtp']['auth'];
|
||||
$mail->Username = $MAILCFG['smtp']['username'] ?? '';
|
||||
$mail->Password = $MAILCFG['smtp']['password'] ?? '';
|
||||
|
||||
// Base
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Encoding = 'base64';
|
||||
$mail->XMailer = '';
|
||||
|
||||
// Envelope (Return-Path)
|
||||
if (!empty($MAILCFG['bounce'])) $mail->Sender = $MAILCFG['bounce'];
|
||||
|
||||
// From / Reply-To
|
||||
$from = $MAILCFG['from'] ?? ['no-reply@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] ?? '');
|
||||
|
||||
// Destinataire
|
||||
$mail->addAddress($toEmail, $toName);
|
||||
|
||||
// Anti auto-replies (optionnel)
|
||||
if (!empty($MAILCFG['suppress_auto'])) {
|
||||
$mail->addCustomHeader('Auto-Submitted', 'auto-generated');
|
||||
$mail->addCustomHeader('X-Auto-Response-Suppress', 'All');
|
||||
}
|
||||
|
||||
// Corps
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $htmlBody;
|
||||
$mail->AltBody = $textBody;
|
||||
|
||||
// 📎 Invitation iCalendar
|
||||
$mail->addStringAttachment(
|
||||
$ical,
|
||||
$filename,
|
||||
'base64',
|
||||
'text/calendar; method=REQUEST; charset=UTF-8'
|
||||
);
|
||||
|
||||
$mail->send();
|
||||
return true;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('PHPMailer RDV send exception: '.$e->getMessage());
|
||||
if (property_exists($mail, 'ErrorInfo') && $mail->ErrorInfo) {
|
||||
error_log('PHPMailer RDV ErrorInfo: '.$mail->ErrorInfo);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================== Valeurs par défaut Online/Offline ================== */
|
||||
if (!isset($c_online)) { $c_online = 'online'; }
|
||||
if (!isset($c_offline)) { $c_offline = 'offline'; }
|
||||
|
||||
/* ================== CSRF pour l'appel AJAX ================== */
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
/* ================== Helpers communs (HTML UI) ================== */
|
||||
function ui_head(string $title): void {
|
||||
|
||||
$HEAD_EXTRA = <<<HTML
|
||||
<style>
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0; background:#8CA9FF;
|
||||
font-family:'Montserrat',system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
padding:32px 16px;
|
||||
}
|
||||
.page-loader{ position:fixed; inset:0; z-index:9999; background:#8CA9FF; display:flex; align-items:center; justify-content:center; transition:opacity .25s ease, visibility .25s ease; }
|
||||
.hidden{opacity:0; visibility:hidden;}
|
||||
.loader-card{ background:#fff; color:#38378B; border-radius:22px; padding:20px 22px; box-shadow:0 12px 40px rgba(0,0,0,.08); display:flex; align-items:center; gap:14px; }
|
||||
.loader-spinner{ width:36px; height:36px; border-radius:50%; border:3px solid rgba(56,55,139,.15); border-top-color:#38378B; animation:spin 1s linear infinite; }
|
||||
@keyframes spin{ to{ transform:rotate(360deg);} }
|
||||
.loader-text{ font-weight:700; }
|
||||
.wrap{ width:min(640px, 100%); margin:0 auto; }
|
||||
.msg{ background:#fff; color:rgb(33,37,41); border-radius:22px; box-shadow:0 12px 40px rgba(0,0,0,.08); padding:22px; text-align:center; }
|
||||
.msg h2{ margin:0 0 10px; font-size:16px; font-weight:400; }
|
||||
.msg p{ margin:10px 0; line-height:1.5; }
|
||||
.msg .meta{ margin:12px auto 0; padding:12px 14px; border:1px solid #f11e1eff; border-radius:16px; background:#F7F8FF; color:#f11e1eff; font-weight:600; display:inline-block; }
|
||||
.msg .actions{ margin-top:16px; display:flex; gap:10px; justify-content:center; flex-wrap:wrap; }
|
||||
.btn{ display:inline-block; text-decoration:none; font-weight:700; border-radius:999px; padding:10px 16px; }
|
||||
.btn-primary{ background:#E03C3F; color:#fff; box-shadow:0 8px 22px rgba(0,0,0,.12); }
|
||||
.btn-primary:hover{ filter:brightness(1.05); }
|
||||
.btn-outline{ background:#fff; color:#38378B; border:1px solid #E0E3EB; }
|
||||
.btn-outline:hover{ background:#f8faff; }
|
||||
.msg--ok h2::before{ content:" "; }
|
||||
.msg--err h2{ color:#666666; }
|
||||
.msg--err h2::before{ content:"❌ "; }
|
||||
</style>
|
||||
HTML;
|
||||
?>
|
||||
<div class="page-loader" id="loader" role="status" aria-live="polite">
|
||||
<div class="loader-card">
|
||||
<div class="loader-spinner" aria-hidden="true"></div>
|
||||
<div class="loader-text"><?= htmlspecialchars($GLOBALS['translations']['chargement_validation'] ?? 'Подождите, идёт подтверждение...') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap" id="result"></div>
|
||||
<?php }
|
||||
|
||||
function ui_foot(): void { ?>
|
||||
</body></html>
|
||||
<?php }
|
||||
|
||||
/* =================== Helpers e-mail (HTML/Texte) =================== */
|
||||
function build_email_html(array $data, array $t): string {
|
||||
$brand = '<img src="https://uslugi.fr/public/uploads/img/logo_mail.png" width="150" alt="USLUGI.FR">';
|
||||
$brand_bas ='УСЛУГИ.ФР';
|
||||
return '
|
||||
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>USLUGI.FR</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:#ffffff;border-radius:8px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background:#38378B;color:#ffffff;padding:20px 24px;font-size:18px;font-weight:600;">'.$brand.'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 24px 8px 24px;color:#111111;font-size:16px;line-height:1.5;">'.nl2br(htmlspecialchars($data['hello_line'])).'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 24px;color:#111111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($data['intro'])).'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:16px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px;font-size:14px;color:#6b7280;border-bottom:1px solid #e5e7eb;">'.htmlspecialchars($t['email_avec'] ?? 'Avec').'</td>
|
||||
<td style="padding:14px 16px;font-size:14px;color:#111111;">'.htmlspecialchars($data['who']).'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($t['date_label'] ?? 'Date').'</td>
|
||||
<td style="padding:14px 16px;font-size:14px;color:#111111;">'.htmlspecialchars($data['when']).'</td>
|
||||
</tr>
|
||||
'.(!empty($data['format_line']) ? '
|
||||
<tr>
|
||||
<td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($t['format_label'] ?? 'Формат').'</td>
|
||||
<td style="padding:14px 16px;font-size:14px;color:#111111;">'.htmlspecialchars($data['format_line']).'</td>
|
||||
</tr>' : '').'
|
||||
'.(!empty($data['address_line']) ? '
|
||||
<tr>
|
||||
<td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($t['adresse_label_ru'] ?? 'Адрес').'</td>
|
||||
<td style="padding:14px 16px;font-size:14px;color:#111111;">'.htmlspecialchars($data['address_line']).'</td>
|
||||
</tr>' : '').'
|
||||
</table>
|
||||
</td>
|
||||
</tr>'.
|
||||
(!empty($data['cta_url']) ? '
|
||||
<tr>
|
||||
<td align="center" style="padding:8px 24px 24px 24px;">
|
||||
<a href="'.htmlspecialchars($data['cta_url']).'" target="_blank"
|
||||
style="display:inline-block;text-decoration:none;padding:12px 20px;border-radius:999px;background:#E03C3F;color:#ffffff;font-weight:600;">'.htmlspecialchars($data['cta_label']).'</a>
|
||||
</td>
|
||||
</tr>' : ''
|
||||
).'
|
||||
<tr>
|
||||
<td style="padding:8px 24px 24px 24px;color:#111111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($data['footer'])).'</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').' '.$brand_bas.' — '.htmlspecialchars($t['merci_simple'] ?? 'Merci').'</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>';
|
||||
}
|
||||
|
||||
function build_email_text(array $data, array $t): string {
|
||||
$lines = [];
|
||||
$lines[] = $data['hello_line']; $lines[] = '';
|
||||
$lines[] = $data['intro']; $lines[] = '';
|
||||
$lines[] = ($t['email_avec'] ?? 'Avec').' '.$data['who'];
|
||||
$lines[] = ($t['date_label'] ?? 'Date').' '.$data['when'];
|
||||
if (!empty($data['format_line'])) $lines[] = ($t['format_label'] ?? 'Формат').': '.$data['format_line'];
|
||||
if (!empty($data['address_line'])) $lines[] = ($t['adresse_label_ru'] ?? 'Адрес').': '.$data['address_line'];
|
||||
if (!empty($data['cta_url'])) {
|
||||
$lines[] = '';
|
||||
$lines[] = $data['cta_label'].': '.$data['cta_url'];
|
||||
}
|
||||
$lines[] = '';
|
||||
$lines[] = $data['footer'];
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/* =================== Helpers ICS =================== */
|
||||
function ics_escape(string $s): string {
|
||||
$s = str_replace("\\", "\\\\", $s);
|
||||
$s = str_replace(";", "\;", $s);
|
||||
$s = str_replace(",", "\,", $s);
|
||||
$s = preg_replace("/\r\n|\r|\n/", "\\n", $s);
|
||||
return $s;
|
||||
}
|
||||
|
||||
function build_ics_common_lines(
|
||||
string $uid, DateTime $start, DateTime $end, string $timezone,
|
||||
string $summary, string $description, string $location, array $attendees
|
||||
): string {
|
||||
$lines = [];
|
||||
$lines[] = "BEGIN:VCALENDAR";
|
||||
$lines[] = "VERSION:2.0";
|
||||
$lines[] = "PRODID:-//УСЛУГИ.ФР//RDV//FR";
|
||||
$lines[] = "CALSCALE:GREGORIAN";
|
||||
$lines[] = "METHOD:REQUEST";
|
||||
$lines[] = "BEGIN:VEVENT";
|
||||
$lines[] = "UID:{$uid}";
|
||||
$lines[] = "DTSTAMP:" . gmdate('Ymd\THis\Z');
|
||||
$lines[] = "DTSTART;TZID={$timezone}:" . $start->format('Ymd\THis');
|
||||
$lines[] = "DTEND;TZID={$timezone}:" . $end->format('Ymd\THis');
|
||||
$lines[] = "SUMMARY:" . ics_escape($summary);
|
||||
$lines[] = "DESCRIPTION:" . ics_escape($description);
|
||||
$lines[] = "LOCATION:" . ics_escape($location);
|
||||
$lines[] = "ORGANIZER;CN=УСЛУГИ.ФР:mailto:rdv@uslugi.fr";
|
||||
foreach ($attendees as $a) {
|
||||
if (!isset($a['email']) || !$a['email']) continue;
|
||||
$cn = ics_escape($a['cn'] ?? '');
|
||||
$em = $a['email'];
|
||||
$lines[] = "ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN={$cn};RSVP=TRUE:mailto:{$em}";
|
||||
|
||||
}
|
||||
$lines[] = "STATUS:CONFIRMED";
|
||||
$lines[] = "END:VEVENT";
|
||||
$lines[] = "END:VCALENDAR";
|
||||
return implode("\r\n", $lines) . "\r\n";
|
||||
}
|
||||
|
||||
function build_ics_client(array $rdv, DateTime $start, DateTime $end, string $timezone, string $location, string $ctaUrl, array $t, string $uid): string {
|
||||
$summary = ($t['rdv_avec'] ?? 'Rendez-vous avec').' '.($t['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'];
|
||||
$desc = ($t['votre_rdv_attente'] ?? 'Votre rendez-vous est prévu')."\n\n".
|
||||
($t['email_avec'] ?? 'Avec').' '.($t['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien']."\n".
|
||||
'Email: '.$rdv['email_praticien']."\n".
|
||||
(($rdv['tel_praticien'] ?? '') ? ('Téléphone: '.$rdv['tel_praticien']."\n") : '').
|
||||
($ctaUrl ? ("\n".($t['consultez_espace_pour_details'] ?? 'Consultez votre espace pour les détails')."\n".$ctaUrl) : '');
|
||||
$attendees = [
|
||||
['cn' => $rdv['prenom_praticien'].' '.$rdv['nom_praticien'], 'email' => $rdv['email_praticien']],
|
||||
['cn' => $rdv['prenom_client'].' '.$rdv['nom_client'], 'email' => $rdv['email_client']],
|
||||
];
|
||||
return build_ics_common_lines($uid, $start, $end, $timezone, $summary, $desc, $location, $attendees);
|
||||
}
|
||||
|
||||
function build_ics_praticien(array $rdv, DateTime $start, DateTime $end, string $timezone, string $location, string $ctaUrl, array $t, string $uid): string {
|
||||
$summary = ($t['rdv_avec'] ?? 'Rendez-vous avec').' '.$rdv['prenom_client'].' '.$rdv['nom_client'];
|
||||
$desc = ($t['nouveau_rdv_pris_avec'] ?? 'Nouveau rendez-vous pris avec')."\n\n".
|
||||
($t['client_label'] ?? 'Client').': '.$rdv['prenom_client'].' '.$rdv['nom_client']."\n".
|
||||
'Email: '.$rdv['email_client']."\n".
|
||||
(($rdv['tel_client'] ?? '') ? ('Téléphone: '.$rdv['tel_client']."\n") : '').
|
||||
($ctaUrl ? ("\n".($t['consultez_espace_pour_details'] ?? 'Consultez votre espace pour les détails')."\n".$ctaUrl) : '');
|
||||
$attendees = [
|
||||
['cn' => $rdv['prenom_client'].' '.$rdv['nom_client'], 'email' => $rdv['email_client']],
|
||||
['cn' => $rdv['prenom_praticien'].' '.$rdv['nom_praticien'], 'email' => $rdv['email_praticien']],
|
||||
];
|
||||
return build_ics_common_lines($uid, $start, $end, $timezone, $summary, $desc, $location, $attendees);
|
||||
}
|
||||
|
||||
/* ================== Mode POST (AJAX) : traitement & réponse JSON ================== */
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && ($_POST['action'] ?? '') === 'validate') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// CSRF
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
|
||||
echo json_encode(['ok'=>false, 'html'=>'<div class="msg msg--err"><h2>Сессия истекла</h2><p>Обновите страницу и попробуйте ещё раз.</p></div>']); exit;
|
||||
}
|
||||
|
||||
$client_id = $_SESSION['reference_id'] ?? null;
|
||||
$praticien_id = isset($_POST['praticien_id']) ? (int)$_POST['praticien_id'] : null;
|
||||
$startIso = $_POST['start'] ?? null;
|
||||
$mode_rdv_in = $_POST['mode_rdv'] ?? ($_GET['mode_rdv'] ?? null);
|
||||
|
||||
// Normalisation du mode (défaut = online)
|
||||
$mode_rdv = ($mode_rdv_in === $GLOBALS['c_offline']) ? $GLOBALS['c_offline'] : $GLOBALS['c_online'];
|
||||
|
||||
$planningUrl = '/public/client/reserver_rdv.php?praticien_id='.(int)($praticien_id ?? 0);
|
||||
|
||||
if (!$praticien_id || !$startIso || !$client_id) {
|
||||
$html = '
|
||||
<div class="msg msg--err">
|
||||
<h2>'.htmlspecialchars($translations['donnees_manquantes_reservation'] ?? 'Données manquantes pour la réservation').'</h2>
|
||||
<p>'.htmlspecialchars($translations['retour_fiche_praticien_hint'] ?? 'Merci de revenir sur la fiche du praticien et de choisir un créneau.').'</p>
|
||||
<div class="actions">
|
||||
<a class="btn btn-outline" href="'.htmlspecialchars($planningUrl).'">'.htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche').'</a>
|
||||
</div>
|
||||
</div>';
|
||||
echo json_encode(['ok'=>false, 'html'=>$html]); exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$date_rdv_sql = (new DateTime($startIso))->format('Y-m-d H:i:s');
|
||||
|
||||
// Vérifier si le créneau est encore libre
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM rendez_vous WHERE praticien_id = ? AND date_rdv = ?");
|
||||
$stmt->execute([$praticien_id, $date_rdv_sql]);
|
||||
if ($stmt->fetchColumn() > 0) {
|
||||
$html = '
|
||||
<div class="msg msg--err">
|
||||
<h2>'.htmlspecialchars($translations['creneau_deja_reserve'] ?? 'Créneau déjà réservé').'</h2>
|
||||
<p>'.htmlspecialchars($translations['choisir_autre_creneau'] ?? 'Veuillez choisir un autre créneau.').'</p>
|
||||
<div class="actions">
|
||||
<a class="btn btn-outline" href="'.htmlspecialchars($planningUrl).'">'.htmlspecialchars($translations['retour_au_planning'] ?? 'Retour au planning').'</a>
|
||||
</div>
|
||||
</div>';
|
||||
echo json_encode(['ok'=>false, 'html'=>$html]); exit;
|
||||
}
|
||||
|
||||
// Récupérer durée + adresse praticien
|
||||
$stmt = $pdo->prepare("SELECT duree_rdv, adresse, ville, code_postal FROM praticien WHERE id = ?");
|
||||
$stmt->execute([$praticien_id]);
|
||||
$p = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$duree = (int)($p['duree_rdv'] ?? 60);
|
||||
|
||||
// Adresse complète ?
|
||||
$adresse = trim((string)($p['adresse'] ?? ''));
|
||||
$ville = trim((string)($p['ville'] ?? ''));
|
||||
$code_postal = trim((string)($p['code_postal'] ?? ''));
|
||||
$has_address = ($adresse !== '' && $ville !== '' && $code_postal !== '');
|
||||
|
||||
// Si offline choisi mais adresse manquante → bascule online
|
||||
if ($mode_rdv === $c_offline && !$has_address) {
|
||||
$mode_rdv = $c_online;
|
||||
}
|
||||
|
||||
// Snapshot adresse (pour email/ICS)
|
||||
$addrSnapshot = $has_address ? "{$adresse}, {$code_postal} {$ville}" : '';
|
||||
|
||||
// Insert RDV avec lieu = online/offline
|
||||
$uuid = bin2hex(random_bytes(16));
|
||||
$lieu_insert = ($mode_rdv === $c_offline) ? 'offline' : 'online';
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO rendez_vous (praticien_id, client_id, date_rdv, duree_minutes, lieu, statut, commentaire, uuid_ical, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'prévu', '', ?, NOW())
|
||||
");
|
||||
$stmt->execute([$praticien_id, $client_id, $date_rdv_sql, $duree, $lieu_insert, $uuid]);
|
||||
|
||||
// Récupérer infos pour e-mails & ICS
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT rv.id AS rdv_id, rv.date_rdv, rv.duree_minutes, rv.lieu,
|
||||
p.nom AS nom_praticien, p.prenom AS prenom_praticien, p.email AS email_praticien, p.telephone AS tel_praticien,
|
||||
p.adresse AS p_adresse, p.ville AS p_ville, p.code_postal AS p_cp,
|
||||
c.email AS email_client, c.nom AS nom_client, c.prenom AS prenom_client, c.telephone AS tel_client
|
||||
FROM rendez_vous rv
|
||||
JOIN praticien p ON rv.praticien_id = p.id
|
||||
JOIN client c ON rv.client_id = c.id
|
||||
WHERE rv.uuid_ical = ?
|
||||
");
|
||||
$stmt->execute([$uuid]);
|
||||
$rdv = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$rdv) { throw new RuntimeException('Rendez-vous introuvable après insertion.'); }
|
||||
|
||||
$rdvId = (int)$rdv['rdv_id'];
|
||||
$tz = 'Europe/Paris';
|
||||
$startDT = new DateTime($rdv['date_rdv'], new DateTimeZone($tz));
|
||||
$endDT = (clone $startDT)->modify("+{$duree} minutes");
|
||||
$when = $startDT->format('d/m/Y H:i');
|
||||
|
||||
// RU libellés
|
||||
$ru_online = $translations['option_online'] ?? 'Онлайн';
|
||||
$ru_offline = $translations['option_cabinet'] ?? 'В кабинете';
|
||||
$ru_format = $translations['format_label'] ?? 'Формат';
|
||||
$ru_addrLbl = $translations['adresse_label_ru'] ?? 'Адрес';
|
||||
|
||||
$is_offline = ($mode_rdv === $c_offline);
|
||||
$mode_label_ru = $is_offline ? $ru_offline : $ru_online;
|
||||
|
||||
// ICS LOCATION
|
||||
$location = $is_offline && $has_address ? $addrSnapshot : $ru_online;
|
||||
|
||||
$cta_client_url = 'https://uslugi.fr/public/client/mes_rendez_vous.php';
|
||||
$cta_prat_url = "https://uslugi.fr/public/praticien/rendez_vous.php?id={$rdvId}";
|
||||
|
||||
// ICS
|
||||
$ical_client = build_ics_client($rdv, $startDT, $endDT, $tz, $location, $cta_client_url, $translations, $uuid);
|
||||
$ical_prat = build_ics_praticien($rdv, $startDT, $endDT, $tz, $location, $cta_prat_url, $translations, $uuid);
|
||||
|
||||
// E-mails (ajout format ou adresse)
|
||||
$whoClientSees = ($translations['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'];
|
||||
$whoPratSees = ($translations['client_label'] ?? 'Client').' '.$rdv['prenom_client'].' '.$rdv['nom_client'];
|
||||
$cta_label = $translations['consultez_espace_pour_details'] ?? 'Voir les détails';
|
||||
|
||||
// Client
|
||||
$subject_c = $translations['email_confirmation_rdv_sujet'] ?? 'Confirmation de rendez-vous';
|
||||
$data_c = [
|
||||
'hello_line' => sprintf($translations['mail_valid_bonjour_nom'] ?? 'Bonjour %s', $rdv['prenom_client'].' '.$rdv['nom_client']),
|
||||
'intro' => ($translations['votre_rdv_attente'] ?? 'Votre rendez-vous est en attente de confirmation').' '.
|
||||
($translations['email_avec'] ?? 'avec').' '.$whoClientSees.".\n".
|
||||
($translations['veuillez_trouver_invitation'] ?? "Veuillez trouver l'invitation en pièce jointe."),
|
||||
'who' => $whoClientSees,
|
||||
'when' => $when,
|
||||
'format_line' => $mode_label_ru,
|
||||
'address_line' => ($is_offline && $has_address) ? $addrSnapshot : '',
|
||||
'cta_url' => $cta_client_url,
|
||||
'cta_label' => $cta_label,
|
||||
'footer' => $translations['merci_simple'] ?? 'Merci.',
|
||||
];
|
||||
$ok_c = send_rdv_email_with_ics_phpmailer(
|
||||
$rdv['email_client'], $rdv['prenom_client'].' '.$rdv['nom_client'],
|
||||
$subject_c,
|
||||
build_email_text($data_c, $translations),
|
||||
build_email_html($data_c, $translations),
|
||||
$ical_client, "rdv_client_{$uuid}.ics"
|
||||
);
|
||||
|
||||
// Praticien
|
||||
$subject_p = ($translations['nouveau_rdv_avec'] ?? 'Nouveau rendez-vous avec').' '.$rdv['prenom_client'].' '.$rdv['nom_client'];
|
||||
$data_p = [
|
||||
'hello_line' => ($translations['email_bonjour_dr'] ?? 'Bonjour Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'],
|
||||
'intro' => ($translations['nouveau_rdv_pris_avec'] ?? 'Un nouveau rendez-vous a été pris avec vous.'),
|
||||
'who' => $whoPratSees,
|
||||
'when' => $when,
|
||||
'format_line' => $mode_label_ru,
|
||||
'address_line' => ($is_offline && $has_address) ? $addrSnapshot : '',
|
||||
'cta_url' => $cta_prat_url,
|
||||
'cta_label' => $cta_label,
|
||||
'footer' => $translations['merci_simple'] ?? 'Merci.',
|
||||
];
|
||||
$ok_p = send_rdv_email_with_ics_phpmailer(
|
||||
$rdv['email_praticien'], $rdv['prenom_praticien'].' '.$rdv['nom_praticien'],
|
||||
$subject_p,
|
||||
build_email_text($data_p, $translations),
|
||||
build_email_html($data_p, $translations),
|
||||
$ical_prat, "rdv_praticien_{$uuid}.ics"
|
||||
);
|
||||
|
||||
if (!$ok_c || !$ok_p) {
|
||||
error_log('RDV: un ou plusieurs e-mails ICS n’ont pas été envoyés.');
|
||||
}
|
||||
|
||||
// WhatsApp (inchangé)
|
||||
try {
|
||||
$waTo = $rdv['tel_praticien'] ?? '';
|
||||
$clientFullName = trim(($rdv['prenom_client'] ?? '') . ' ' . ($rdv['nom_client'] ?? ''));
|
||||
$dateHeure = $when;
|
||||
if ($waTo && $clientFullName && $dateHeure && $rdvId) {
|
||||
$waRes = Sms\notify_rdv_en_attente($waTo, $clientFullName, $dateHeure, $rdvId);
|
||||
if ((int)($waRes['http_code'] ?? 0) !== 200) {
|
||||
error_log('WA en_attente non-200: ' . json_encode($waRes, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
} else {
|
||||
error_log('WA en_attente: données incomplètes (tel praticien / nom client / dateHeure / rdvId).');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('WA en_attente_confirmation exception: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Réponse succès (meta inclut le format)
|
||||
$html = '
|
||||
<div class="msg msg--ok">
|
||||
<h2>'.($translations['rdv_confirme_pour_le'] ?? 'Rendez-vous enregistré pour le').' '.$when.' '.($translations['spasibo_za_zapis'] ?? '— спасибо за запись!').'</h2>
|
||||
<div class="meta">'.htmlspecialchars($translations['votre_rdv_attente'] ?? 'Votre rendez-vous est en attente de confirmation').' — '.htmlspecialchars($ru_format).': '.htmlspecialchars($mode_label_ru).'</div>
|
||||
<div class="actions">
|
||||
<a class="btn btn-primary" href="'.htmlspecialchars('/public/client/reserver_rdv.php?praticien_id='.$praticien_id).'">'.htmlspecialchars($translations['retour_au_planning'] ?? 'Retour au planning').'</a>
|
||||
<a class="btn btn-outline" href="/public/client/mes_rendez_vous.php">'.htmlspecialchars($translations['voir_mes_rdv'] ?? 'Voir mes rendez-vous').'</a>
|
||||
</div>
|
||||
</div>';
|
||||
echo json_encode(['ok'=>true, 'html'=>$html]); exit;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$html = '
|
||||
<div class="msg msg--err">
|
||||
<h2>'.htmlspecialchars($translations['erreur_prefixe'] ?? 'Erreur').'</h2>
|
||||
<p>'.htmlspecialchars($e->getMessage()).'</p>
|
||||
<div class="actions">
|
||||
<a class="btn btn-outline" href="'.htmlspecialchars('/public/client/reserver_rdv.php?praticien_id='.(int)($praticien_id ?? 0)).'">'.htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche').'</a>
|
||||
</div>
|
||||
</div>';
|
||||
echo json_encode(['ok'=>false, 'html'=>$html]); exit;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================== Mode GET : UI + loader + JS (déclenche l'AJAX) ================== */
|
||||
$praticien_id = isset($_GET['praticien_id']) ? (int)$_GET['praticien_id'] : null;
|
||||
$startIso = $_GET['start'] ?? null;
|
||||
$modeGet = $_GET['mode_rdv'] ?? null;
|
||||
|
||||
ui_head($translations['validation_rdv_title'] ?? 'Validation du rendez-vous');
|
||||
?>
|
||||
<script>
|
||||
(function(){
|
||||
const loader = document.getElementById('loader');
|
||||
const result = document.getElementById('result');
|
||||
|
||||
const praticien_id = <?= json_encode($praticien_id) ?>;
|
||||
const start = <?= json_encode($startIso) ?>;
|
||||
const csrf = <?= json_encode($CSRF) ?>;
|
||||
const mode_rdv = <?= json_encode($modeGet) ?>;
|
||||
|
||||
if(!praticien_id || !start){
|
||||
loader.classList.add('hidden');
|
||||
const backUrl = praticien_id ? `/public/client/reserver_rdv.php?praticien_id=${praticien_id}` : `/public/client/reserver_rdv.php`;
|
||||
result.innerHTML = `
|
||||
<div class="msg msg--err">
|
||||
<h2><?= htmlspecialchars($translations['donnees_manquantes_reservation'] ?? 'Données manquantes pour la réservation') ?></h2>
|
||||
<p><?= htmlspecialchars($translations['retour_fiche_praticien_hint'] ?? 'Merci de revenir sur la fiche du praticien et de choisir un créneau.') ?></p>
|
||||
<div class="actions">
|
||||
<a class="btn btn-outline" href="${backUrl}"><?= htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche') ?></a>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
action: 'validate',
|
||||
csrf,
|
||||
praticien_id,
|
||||
start
|
||||
});
|
||||
if (mode_rdv) body.set('mode_rdv', mode_rdv);
|
||||
|
||||
fetch(location.pathname, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
result.innerHTML = data.html || '<div class="msg msg--err"><h2>Неизвестная ошибка</h2></div>';
|
||||
})
|
||||
.catch(() => {
|
||||
result.innerHTML = '<div class="msg msg--err"><h2>Сетевая ошибка</h2><p>Попробуйте повторно.</p></div>';
|
||||
})
|
||||
.finally(() => {
|
||||
loader.classList.add('hidden');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php ui_foot(); ?>
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
|
||||
$id = (int)($_SESSION['reference_id'] ?? 0);
|
||||
if (!$id) { http_response_code(403); exit($translations['client_introuvable'] ?? 'Client introuvable'); }
|
||||
|
||||
// Récupérer les données du client
|
||||
$stmt = $pdo->prepare("SELECT * FROM client WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$client = $stmt->fetch();
|
||||
if (!$client) {
|
||||
exit($translations['client_introuvable'] ?? 'Client introuvable');
|
||||
}
|
||||
|
||||
// ---- Traitement du formulaire AVANT tout output ----
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
|
||||
$nom = trim($_POST['nom'] ?? '');
|
||||
$prenom = trim($_POST['prenom'] ?? '');
|
||||
$telephone = trim($_POST['telephone'] ?? '');
|
||||
$genre = trim($_POST['genre'] ?? '');
|
||||
$date_naiss = $_POST['date_naissance'] ?? null;
|
||||
$adresse = trim($_POST['adresse'] ?? '');
|
||||
$ville = trim($_POST['ville'] ?? '');
|
||||
$cp = trim($_POST['code_postal'] ?? '');
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE client SET nom=:nom, prenom=:prenom, telephone=:telephone,
|
||||
genre=:genre, date_naissance=:date_naissance, adresse=:adresse,
|
||||
ville=:ville, code_postal=:code_postal
|
||||
WHERE id=:id
|
||||
");
|
||||
$stmt->execute([
|
||||
'nom' => $nom,
|
||||
'prenom' => $prenom,
|
||||
'telephone' => $telephone,
|
||||
'genre' => $genre,
|
||||
'date_naissance' => $date_naiss ?: null,
|
||||
'adresse' => $adresse,
|
||||
'ville' => $ville,
|
||||
'code_postal' => $cp,
|
||||
'id' => $id
|
||||
]);
|
||||
|
||||
// redirection (évite le repost + actualise la page infos)
|
||||
header('Location: /public/client/informations.php?updated=1');
|
||||
exit;
|
||||
}
|
||||
require_once __DIR__ . '/../includes/header.php';
|
||||
|
||||
?>
|
||||
|
||||
<div class="main1">
|
||||
<div class="container py-5">
|
||||
|
||||
|
||||
<form method="POST" class="shadow p-4 rounded bg-light">
|
||||
<h2 class="mb-4 text-center"><?= $translations['modifier_infos_heading'] ?></h2>
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= $translations['label_nom'] ?> *</label>
|
||||
<input type="text" name="nom" value="<?= htmlspecialchars($client['nom'] ?? '') ?>" required class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= $translations['label_prenom'] ?> *</label>
|
||||
<input type="text" name="prenom" value="<?= htmlspecialchars($client['prenom'] ?? '') ?>" required class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= $translations['label_telephone'] ?> *</label>
|
||||
<input type="tel" name="telephone" id="telephone" value="<?= htmlspecialchars($client['telephone'] ?? '') ?>" class="form-control" placeholder="+33600000000"
|
||||
required
|
||||
pattern="^\+?[1-9]\d{6,14}$"
|
||||
title="<?=htmlspecialchars($translations['telephone_format_message']?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= $translations['label_genre'] ?></label>
|
||||
<select name="genre" class="form-select">
|
||||
<option value=""><?= $translations['select_selectionnez'] ?></option>
|
||||
<option value="<?= $translations['genre_homme'] ?>" <?= $client['genre'] === $translations['genre_homme'] ? 'selected' : '' ?>><?= $translations['genre_homme'] ?></option>
|
||||
<option value="<?= $translations['genre_femme'] ?>" <?= $client['genre'] === $translations['genre_femme'] ? 'selected' : '' ?>><?= $translations['genre_femme'] ?></option>
|
||||
<option value="<?= $translations['genre_autre'] ?>" <?= $client['genre'] === $translations['genre_autre'] ? 'selected' : '' ?>><?= $translations['genre_autre'] ?></option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= $translations['label_date_naissance'] ?></label>
|
||||
<input type="date" name="date_naissance" value="<?= htmlspecialchars($client['date_naissance'] ?? '') ?>" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 input-wrapper">
|
||||
<label class="form-label"><?= $translations['label_adresse'] ?></label>
|
||||
<textarea name="adresse" id="adresse" class="form-control"><?= htmlspecialchars($client['adresse'] ?? '') ?></textarea>
|
||||
<ul id="suggestions" class="list-group position-absolute"></ul>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><?= $translations['label_ville'] ?></label>
|
||||
<input type="text" name="ville" id="ville" value="<?= htmlspecialchars($client['ville'] ?? '') ?>" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label"><?= $translations['label_code_postal'] ?></label>
|
||||
<input type="text" name="code_postal" id="code_postal" value="<?= htmlspecialchars($client['code_postal'] ?? '') ?>" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<button type="submit" class="btn btn-primary"><?= $translations['btn_mettre_a_jour'] ?></button>
|
||||
</div>
|
||||
<?= $translations['champs_obligatoire'] ?>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// === Téléphone: simple & efficace ===
|
||||
const tel = document.getElementById('telephone');
|
||||
|
||||
// 1) Nettoyage en direct (espaces, tirets, points, parenthèses)
|
||||
tel?.addEventListener('input', function () {
|
||||
const pos = this.selectionStart;
|
||||
this.value = this.value.replace(/[\s\-._()/]/g, '');
|
||||
try { this.setSelectionRange(pos, pos); } catch(_) {}
|
||||
});
|
||||
|
||||
// 2) Normalisation au blur
|
||||
tel?.addEventListener('blur', function() {
|
||||
let val = this.value.trim().replace(/[\s\-._()/]/g, '');
|
||||
if (val.startsWith('00')) val = '+' + val.slice(2);
|
||||
if (/^0\d{9}$/.test(val)) val = '+33' + val.substring(1);
|
||||
if (val && !val.startsWith('+')) val = '+' + val;
|
||||
this.value = val;
|
||||
});
|
||||
|
||||
// 3) Au submit : applique la normalisation même si l’utilisateur n’a pas quitté le champ
|
||||
tel?.form?.addEventListener('submit', function (e) {
|
||||
tel.dispatchEvent(new Event('blur'));
|
||||
if (!tel.checkValidity()) {
|
||||
e.preventDefault();
|
||||
tel.reportValidity();
|
||||
tel.focus();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const adresseInput = document.getElementById("adresse");
|
||||
const villeInput = document.getElementById("ville");
|
||||
const cpInput = document.getElementById("code_postal");
|
||||
const suggestions = document.getElementById("suggestions");
|
||||
|
||||
// petite fonction utilitaire : construit "numero + rue"
|
||||
function buildStreet(props) {
|
||||
const num = (props.housenumber || '').trim();
|
||||
const street = (props.street || props.name || '').trim();
|
||||
return [num, street].filter(Boolean).join(' ');
|
||||
}
|
||||
// fallback ville si city manquant (rare)
|
||||
function getCity(props) {
|
||||
if (props.city) return props.city;
|
||||
// ex: "84160, Cucuron, Vaucluse, Provence-Alpes-Côte d'Azur"
|
||||
if (props.context) {
|
||||
const parts = props.context.split(',').map(s => s.trim());
|
||||
// généralement la 2e valeur correspond à la commune
|
||||
return parts[1] || parts[0] || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
let abortCtrl = null;
|
||||
|
||||
adresseInput.addEventListener("input", function () {
|
||||
const query = adresseInput.value.trim();
|
||||
if (query.length < 3) {
|
||||
suggestions.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// annule la requête précédente si on retape vite
|
||||
try { abortCtrl?.abort(); } catch(_) {}
|
||||
abortCtrl = new AbortController();
|
||||
|
||||
fetch("https://api-adresse.data.gouv.fr/search/?limit=5&q=" + encodeURIComponent(query), {
|
||||
signal: abortCtrl.signal
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
suggestions.innerHTML = '';
|
||||
(data.features || []).forEach(feature => {
|
||||
const props = feature.properties || {};
|
||||
const li = document.createElement("li");
|
||||
li.className = 'list-group-item list-group-item-action';
|
||||
li.textContent = props.label || buildStreet(props);
|
||||
|
||||
li.addEventListener("click", function () {
|
||||
// ✅ Adresse = seulement numéro + rue
|
||||
adresseInput.value = buildStreet(props);
|
||||
// ✅ Ville + CP séparés
|
||||
villeInput.value = getCity(props);
|
||||
cpInput.value = props.postcode || '';
|
||||
suggestions.innerHTML = '';
|
||||
});
|
||||
|
||||
suggestions.appendChild(li);
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
// clic hors zone -> ferme la liste
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!suggestions.contains(e.target) && e.target !== adresseInput) {
|
||||
suggestions.innerHTML = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?= $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
|
||||
// Valeurs par défaut au cas où
|
||||
if (! isset($c_online)) {$c_online = 'online';}
|
||||
if (! isset($c_offline)) {$c_offline = 'offline';}
|
||||
|
||||
if (! isset($_GET['praticien_id'])) {
|
||||
exit($translations['praticien_non_precise']);
|
||||
}
|
||||
|
||||
$praticien_id = intval($_GET['praticien_id']);
|
||||
$client_id = $_SESSION['reference_id'] ?? null;
|
||||
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
|
||||
$limit_columns = 5;
|
||||
|
||||
// Infos praticien (+ adresse pour décider des radios)
|
||||
$stmt = $pdo->prepare("SELECT nom, prenom, duree_rdv, adresse, ville, code_postal FROM praticien WHERE id = ?");
|
||||
$stmt->execute([$praticien_id]);
|
||||
$praticien = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$duree_minutes = (int) ($praticien['duree_rdv'] ?? 0);
|
||||
|
||||
if (! $duree_minutes) {
|
||||
die($translations['duree_rdv_non_definie']);
|
||||
}
|
||||
|
||||
// Adresse complète ?
|
||||
$has_address =
|
||||
! empty(trim((string) ($praticien['adresse'] ?? ''))) &&
|
||||
! empty(trim((string) ($praticien['ville'] ?? ''))) &&
|
||||
! empty(trim((string) ($praticien['code_postal'] ?? '')));
|
||||
|
||||
// TZ + fenêtre 21 jours
|
||||
$tz = new DateTimeZone('Europe/Paris');
|
||||
$now = new DateTimeImmutable('now', $tz);
|
||||
$limit = $now->modify('+60 days'); // exclusif dans DatePeriod
|
||||
|
||||
// Récupération des disponibilités HEBDO
|
||||
$stmt = $pdo->prepare("SELECT jour_semaine, heure_debut, heure_fin FROM disponibilite WHERE praticien_id = ?");
|
||||
$stmt->execute([$praticien_id]);
|
||||
$dispos = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Récupération des disponibilités PONCTUELLES (dates)
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT date_dispo, heure_debut, heure_fin
|
||||
FROM disponibilite_date
|
||||
WHERE praticien_id = ?
|
||||
AND date_dispo BETWEEN ? AND ?
|
||||
ORDER BY date_dispo ASC, heure_debut ASC"
|
||||
);
|
||||
$stmt->execute([
|
||||
$praticien_id,
|
||||
$now->format('Y-m-d'),
|
||||
$limit->format('Y-m-d'),
|
||||
]);
|
||||
$dispos_dates_raw = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Index par date (Y-m-d) pour accès rapide dans la boucle
|
||||
$dispos_dates_by_day = [];
|
||||
foreach ($dispos_dates_raw as $row) {
|
||||
$k = (string) $row['date_dispo']; // Y-m-d
|
||||
$dispos_dates_by_day[$k][] = $row;
|
||||
}
|
||||
|
||||
// Récupération des rendez-vous réservés (prochains 21 jours) — on ignore les ANNULÉS
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT date_rdv
|
||||
FROM rendez_vous
|
||||
WHERE praticien_id = ?
|
||||
AND statut <> 'annulé'
|
||||
AND date_rdv BETWEEN ? AND ?"
|
||||
);
|
||||
$stmt->execute([$praticien_id, $now->format('Y-m-d H:i:s'), $limit->format('Y-m-d H:i:s')]);
|
||||
|
||||
$reservations = array_map(
|
||||
fn($r) => (new DateTimeImmutable($r['date_rdv'], $tz))->format('Y-m-d H:i'),
|
||||
$stmt->fetchAll()
|
||||
);
|
||||
|
||||
// Map canon EN -> clé i18n existante
|
||||
$DAY_I18N = [
|
||||
'monday' => 'jour_lundi',
|
||||
'tuesday' => 'jour_mardi',
|
||||
'wednesday' => 'jour_mercredi',
|
||||
'thursday' => 'jour_jeudi',
|
||||
'friday' => 'jour_vendredi',
|
||||
'saturday' => 'jour_samedi',
|
||||
'sunday' => 'jour_dimanche',
|
||||
];
|
||||
|
||||
// Si ta DB ne stocke pas en EN (lundi/Понедельник), normalise vers EN :
|
||||
function normalize_day_key(string $label): ?string
|
||||
{
|
||||
$l = mb_strtolower(trim($label));
|
||||
static $map = [
|
||||
// EN
|
||||
'monday' => 'monday', 'tuesday' => 'tuesday', 'wednesday' => 'wednesday',
|
||||
'thursday' => 'thursday', 'friday' => 'friday', 'saturday' => 'saturday', 'sunday' => 'sunday',
|
||||
// FR
|
||||
'lundi' => 'monday', 'mardi' => 'tuesday', 'mercredi' => 'wednesday',
|
||||
'jeudi' => 'thursday', 'vendredi' => 'friday', 'samedi' => 'saturday', 'dimanche' => 'sunday',
|
||||
// RU
|
||||
'понедельник' => 'monday', 'вторник' => 'tuesday', 'среда' => 'wednesday',
|
||||
'четверг' => 'thursday', 'пятница' => 'friday', 'суббота' => 'saturday', 'воскресенье' => 'sunday',
|
||||
];
|
||||
return $map[$l] ?? null;
|
||||
}
|
||||
|
||||
// Ici on stocke en SET pour fusionner sans doublons : [jourAffichage][slotKey] = DateTimeImmutable
|
||||
$creneaux_par_jour = [];
|
||||
|
||||
$periode = new DatePeriod($now, new DateInterval('P1D'), $limit); // $limit exclusif
|
||||
$step = new DateInterval("PT{$duree_minutes}M");
|
||||
|
||||
foreach ($periode as $jour) {
|
||||
$dayKey = strtolower($jour->format('l')); // monday..sunday
|
||||
$dateKey = $jour->format('Y-m-d');
|
||||
|
||||
// Libellé traduit pour l'affichage
|
||||
$i18nKey = $DAY_I18N[$dayKey] ?? null;
|
||||
$jour_nom = $i18nKey ? ($translations[$i18nKey] ?? ucfirst($dayKey)) : ucfirst($dayKey);
|
||||
|
||||
// clé colonne (inchangée)
|
||||
$colKey = $jour_nom . '<br> ' . $jour->format('d/m');
|
||||
|
||||
// --- 1) Dispos HEBDO ---
|
||||
foreach ($dispos as $dispo) {
|
||||
$dispoKey = normalize_day_key($dispo['jour_semaine'] ?? '');
|
||||
if ($dispoKey !== $dayKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse HH:MM:SS
|
||||
[$h1, $m1, $s1] = array_map('intval', explode(':', (string) $dispo['heure_debut']));
|
||||
[$h2, $m2, $s2] = array_map('intval', explode(':', (string) $dispo['heure_fin']));
|
||||
|
||||
$debut_creneau = $jour->setTime($h1, $m1, $s1);
|
||||
$fin_creneau = $jour->setTime($h2, $m2, $s2);
|
||||
if ($fin_creneau <= $debut_creneau) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for ($t = $debut_creneau; $t->add($step) <= $fin_creneau; $t = $t->add($step)) {
|
||||
if ($t <= $now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slotKey = $t->format('Y-m-d H:i');
|
||||
if (! in_array($slotKey, $reservations, true)) {
|
||||
$creneaux_par_jour[$colKey][$slotKey] = $t; // SET
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2) Dispos PONCTUELLES (par date) ---
|
||||
if (! empty($dispos_dates_by_day[$dateKey])) {
|
||||
foreach ($dispos_dates_by_day[$dateKey] as $row) {
|
||||
// Parse HH:MM:SS
|
||||
[$h1, $m1, $s1] = array_map('intval', explode(':', (string) $row['heure_debut']));
|
||||
[$h2, $m2, $s2] = array_map('intval', explode(':', (string) $row['heure_fin']));
|
||||
|
||||
$debut_creneau = $jour->setTime($h1, $m1, $s1);
|
||||
$fin_creneau = $jour->setTime($h2, $m2, $s2);
|
||||
if ($fin_creneau <= $debut_creneau) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for ($t = $debut_creneau; $t->add($step) <= $fin_creneau; $t = $t->add($step)) {
|
||||
if ($t <= $now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slotKey = $t->format('Y-m-d H:i');
|
||||
if (! in_array($slotKey, $reservations, true)) {
|
||||
$creneaux_par_jour[$colKey][$slotKey] = $t; // SET + fusion
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir SET -> liste triée
|
||||
foreach ($creneaux_par_jour as $k => $set) {
|
||||
$slots = array_values($set);
|
||||
usort($slots, fn(DateTimeImmutable $a, DateTimeImmutable $b) => $a->getTimestamp() <=> $b->getTimestamp());
|
||||
$creneaux_par_jour[$k] = $slots;
|
||||
}
|
||||
|
||||
// Pagination des colonnes
|
||||
$all_keys = array_keys($creneaux_par_jour);
|
||||
$total_pages = (int) ceil(count($all_keys) / $limit_columns);
|
||||
$offset = ($page - 1) * $limit_columns;
|
||||
$visible_keys = array_slice($all_keys, $offset, $limit_columns);
|
||||
require_once '../includes/header.php';
|
||||
|
||||
$PAGE_TITLE = $translations['creneaux_disponibles_title'] ?? 'Créneaux disponibles';
|
||||
$HEAD_EXTRA = <<<HTML
|
||||
<script src="/js/sweetalert2@11.js"></script>
|
||||
HTML;
|
||||
?>
|
||||
<div class="main1">
|
||||
<div class="container py-5">
|
||||
<div class="shadow p-4 rounded bg-light rdv-card">
|
||||
<h3 class="rdv-subtitle"><?php echo htmlspecialchars(($praticien['prenom'] ?? '') . ' ' . ($praticien['nom'] ?? '')) ?></h3>
|
||||
|
||||
<?php if ($total_pages > 1): ?>
|
||||
<div class="pagination">
|
||||
<?php if ($page > 1): ?>
|
||||
<a href="?praticien_id=<?php echo $praticien_id ?>&page=<?php echo $page - 1 ?>">« <?php echo $translations['pagination_precedent'] ?></a>
|
||||
<?php endif; ?>
|
||||
<?php if ($page < $total_pages): ?>
|
||||
<a href="?praticien_id=<?php echo $praticien_id ?>&page=<?php echo $page + 1 ?>"><?php echo $translations['pagination_suivant'] ?> »</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($creneaux_par_jour)): ?>
|
||||
<p class="rdv-empty"><?php echo $translations['aucun_creneau_disponible_3_semaines'] ?><br><br>
|
||||
<a class="btn btn-outline-secondary btn-sm btn-table" href="/messages/start.php?praticien_id=<?php echo $praticien_id ?>">
|
||||
<?php echo $translations['chat_praticien'] ?>
|
||||
</a></p>
|
||||
<?php else: ?>
|
||||
<center><a class="btn btn-outline-secondary btn-sm btn-table" href="/messages/start.php?praticien_id=<?php echo $praticien_id ?>">
|
||||
<?php echo $translations['chat_praticien'] ?>
|
||||
</a></center>
|
||||
<div class="grid-container">
|
||||
<?php foreach ($visible_keys as $jour): ?>
|
||||
<div class="day-column">
|
||||
<h4><?php echo $jour ?></h4>
|
||||
<?php foreach ($creneaux_par_jour[$jour] as $c): ?>
|
||||
<?php
|
||||
$startIso = $c->format(DateTime::ATOM);
|
||||
$slotName = 'mode_rdv_' . $c->format('YmdHi');
|
||||
$onlineLabel = $translations['option_online'] ?? 'Онлайн';
|
||||
$cabinetLabel = $translations['option_cabinet'] ?? 'В кабинете';
|
||||
?>
|
||||
<div class="slot">
|
||||
<div class="slot-time"><?php echo $c->format('H:i') ?></div>
|
||||
|
||||
<?php if ($has_address): ?>
|
||||
<div class="mode-group my-2">
|
||||
<label class="me-3">
|
||||
<input type="radio" name="<?php echo htmlspecialchars($slotName) ?>" value="<?php echo htmlspecialchars($c_online) ?>" checked>
|
||||
<?php echo htmlspecialchars($onlineLabel) ?>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="<?php echo htmlspecialchars($slotName) ?>" value="<?php echo htmlspecialchars($c_offline) ?>">
|
||||
<?php echo htmlspecialchars($cabinetLabel) ?>
|
||||
</label>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="mode-group my-2">
|
||||
<span><?php echo htmlspecialchars($onlineLabel) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<a href="/public/client/valider_rdv.php?praticien_id=<?php echo $praticien_id ?>&start=<?php echo urlencode($startIso) ?>"
|
||||
class="reserver-btn"
|
||||
data-mode-name="<?php echo htmlspecialchars($has_address ? $slotName : '') ?>"
|
||||
data-default-mode="<?php echo htmlspecialchars($c_online) ?>"
|
||||
data-nom="<?php echo htmlspecialchars($praticien['nom'] ?? '') ?>"
|
||||
data-prenom="<?php echo htmlspecialchars($praticien['prenom'] ?? '') ?>"
|
||||
data-start="<?php echo htmlspecialchars($startIso) ?>">
|
||||
<?php echo $translations['btn_reserver'] ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.reserver-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const start = this.dataset.start;
|
||||
const nom = this.dataset.nom;
|
||||
const prenom = this.dataset.prenom;
|
||||
|
||||
const groupName = this.dataset.modeName;
|
||||
let modeVal = this.dataset.defaultMode || 'online';
|
||||
if (groupName) {
|
||||
const checked = this.closest('.slot').querySelector(`input[name="${groupName}"]:checked`);
|
||||
if (checked) modeVal = checked.value;
|
||||
}
|
||||
|
||||
const date = new Date(start);
|
||||
const jour = date.toLocaleDateString('ru-RU', { weekday: 'long', day: '2-digit', month: '2-digit' });
|
||||
const heure = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
Swal.fire({
|
||||
title: <?php echo json_encode($translations['confirmation']) ?>,
|
||||
html: `<b>${prenom} ${nom}<br>${jour} <?php echo $translations['a'] ?> ${heure}</b>`,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: <?php echo json_encode($translations['btn_oui_reserver']) ?>,
|
||||
cancelButtonText: <?php echo json_encode($translations['btn_annuler']) ?>
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
const url = new URL(this.href, location.origin);
|
||||
url.searchParams.set('mode_rdv', modeVal);
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p><?php echo $translations['copyr'] ?></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
|
||||
$client_id = $_SESSION['reference_id'];
|
||||
$praticien_id = intval($_POST['praticien_id'] ?? 0);
|
||||
|
||||
if ($praticien_id > 0) {
|
||||
|
||||
$stmt = $pdo->prepare("DELETE FROM praticien_client WHERE client_id = ? AND praticien_id = ?");
|
||||
$stmt->execute([$client_id, $praticien_id]);
|
||||
echo $translations['favori_supprime_title'];
|
||||
} else {
|
||||
echo $translations['praticien_id_invalide'];
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,647 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/init_client.php';
|
||||
require_once __DIR__ . '/../../push/push_endpoint.php';
|
||||
require_once __DIR__ . '/../../sms/notify_rdv.php';
|
||||
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
/* -------- PHPMailer helper (timeouts ajoutés) -------- */
|
||||
function send_rdv_email_with_ics_phpmailer(
|
||||
|
||||
string $toEmail, string $toName,
|
||||
string $subject, string $textBody, string $htmlBody,
|
||||
string $ical, string $filename
|
||||
): bool {
|
||||
global $MAILCFG; // vient de /includes/mailer.php
|
||||
|
||||
if (!filter_var($toEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
error_log('PHPMailer RDV: invalid recipient email: ' . var_export($toEmail, true));
|
||||
return false;
|
||||
}
|
||||
if (empty($MAILCFG) || empty($MAILCFG['smtp']['host'])) {
|
||||
error_log('PHPMailer RDV: MAILCFG missing/incomplete.');
|
||||
return false;
|
||||
}
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
try {
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $MAILCFG['smtp']['host'];
|
||||
$mail->Port = (int)$MAILCFG['smtp']['port'];
|
||||
$mail->SMTPSecure = $MAILCFG['smtp']['secure'] ?? PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->SMTPAuth = (bool)$MAILCFG['smtp']['auth'];
|
||||
$mail->Username = $MAILCFG['smtp']['username'] ?? '';
|
||||
$mail->Password = $MAILCFG['smtp']['password'] ?? '';
|
||||
|
||||
// 🔒 robustesse
|
||||
$mail->Timeout = 8; // seconds (SMTP)
|
||||
$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 = $htmlBody;
|
||||
$mail->AltBody = $textBody;
|
||||
|
||||
// 📎 Invitation iCalendar
|
||||
$mail->addStringAttachment(
|
||||
$ical,
|
||||
$filename,
|
||||
'base64',
|
||||
'text/calendar; method=REQUEST; charset=UTF-8'
|
||||
);
|
||||
|
||||
$mail->send();
|
||||
return true;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('PHPMailer RDV send exception: '.$e->getMessage());
|
||||
if (!empty($mail->ErrorInfo)) {
|
||||
error_log('PHPMailer RDV ErrorInfo: '.$mail->ErrorInfo);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================== Valeurs par défaut Online/Offline ================== */
|
||||
if (!isset($c_online)) { $c_online = 'online'; }
|
||||
if (!isset($c_offline)) { $c_offline = 'offline'; }
|
||||
|
||||
/* ================== CSRF pour l'appel AJAX ================== */
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
$CSRF = $_SESSION['csrf_token'];
|
||||
|
||||
/* ================== Helpers UI (inchangés) ================== */
|
||||
function ui_head(string $title): void { ?>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><?= htmlspecialchars($title) ?></title>
|
||||
<style>
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0; background:#8CA9FF;
|
||||
font-family:'Montserrat',system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
padding:32px 16px;
|
||||
}
|
||||
.page-loader{ position:fixed; inset:0; z-index:9999; background:#8CA9FF; display:flex; align-items:center; justify-content:center; transition:opacity .25s ease, visibility .25s ease; }
|
||||
.hidden{opacity:0; visibility:hidden;}
|
||||
.loader-card{ background:#fff; color:#38378B; border-radius:22px; padding:20px 22px; box-shadow:0 12px 40px rgba(0,0,0,.08); display:flex; align-items:center; gap:14px; }
|
||||
.loader-spinner{ width:36px; height:36px; border-radius:50%; border:3px solid rgba(56,55,139,.15); border-top-color:#38378B; animation:spin 1s linear infinite; }
|
||||
@keyframes spin{ to{ transform:rotate(360deg);} }
|
||||
.loader-text{ font-weight:700; }
|
||||
.wrap{ width:min(500px, 100%); margin:0 auto; }
|
||||
.msg{ background:#fff; color:rgb(33,37,41); border-radius:22px; box-shadow:0 12px 40px rgba(0,0,0,.08); padding:22px; text-align:center; }
|
||||
.msg h2{ margin:0 0 10px; font-size:16px; font-weight:400; }
|
||||
.msg p{ margin:10px 0; line-height:1.5; }
|
||||
.msg .meta{ margin:12px auto 0; padding:12px 14px; border:1px solid #f11e1eff; border-radius:16px; background:#F7F8FF; color:#f11e1eff; font-weight:600; display:inline-block; }
|
||||
.msg .actions{ margin-top:16px; display:flex; gap:10px; justify-content:center; flex-wrap:wrap; }
|
||||
.card-notice{ background:#fff; color:#111; border-radius:28px; box-shadow:0 20px 60px rgba(0,0,0,.08); padding:24px 22px; text-align:left; }
|
||||
.cn-title{ margin:0 0 6px; font-size:clamp(22px,4.8vw,32px); font-weight:800; }
|
||||
.cn-date{ color:#60646c; font-size:clamp(14px,3.6vw,18px); margin-bottom:12px; }
|
||||
.notice-list{ list-style:none; padding:0; margin:10px 0 0; display:grid; gap:14px; }
|
||||
.notice-list li{ display:grid; grid-template-columns:36px 1fr; align-items:flex-start; gap:10px; }
|
||||
.txt{ line-height:1.5; }
|
||||
.txt-warn{ color:#E03C3F; font-weight:700; }
|
||||
.ico{ width:32px; height:32px; border-radius:8px; display:inline-grid; place-items:center; margin-top:2px; background:#fde9ea; flex:0 0 32px; }
|
||||
.ico svg{ width:24px; height:24px; fill:#E03C3F; }
|
||||
.ico-dark{ background:#eef0f3; }
|
||||
.ico-dark svg{ fill:#333; }
|
||||
.ico-info{ background:#e8f0ff; }
|
||||
.ico-info svg{ color:#2b64d8; }
|
||||
.btn{ display:inline-block; text-decoration:none; font-weight:700; border-radius:999px; padding:12px 18px; }
|
||||
.btn-primary{ background:#E03C3F; color:#fff; box-shadow:0 8px 22px rgba(0,0,0,.12); }
|
||||
.btn-primary:hover{ filter:brightness(1.05); }
|
||||
.btn-outline{ background:#fff; color:#38378B; border:1px solid #E0E3EB; }
|
||||
.btn-outline:hover{ background:#f8faff; }
|
||||
.msg--ok h2::before{ content:" "; }
|
||||
.msg--err h2{ color:#666666; }
|
||||
.msg--err h2::before{ content:"❌ "; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-loader" id="loader" role="status" aria-live="polite">
|
||||
<div class="loader-card">
|
||||
<div class="loader-spinner" aria-hidden="true"></div>
|
||||
<div class="loader-text"><?= htmlspecialchars($GLOBALS['translations']['chargement_validation'] ?? 'Подождите, идёт подтверждение...') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap" id="result"></div>
|
||||
<?php }
|
||||
function ui_foot(): void { ?>
|
||||
</body></html>
|
||||
<?php }
|
||||
|
||||
/* =================== Helpers e-mail (HTML/Texte) =================== */
|
||||
function build_email_html(array $data, array $t): string {
|
||||
$brand = '<img src="https://uslugi.fr/public/uploads/img/logo_mail.png" width="150" alt="USLUGI.FR">';
|
||||
$brand_bas ='УСЛУГИ.ФР';
|
||||
return '
|
||||
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>USLUGI.FR</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:#ffffff;border-radius:8px;overflow:hidden;">
|
||||
<tr><td style="background:#38378B;color:#ffffff;padding:20px 24px;font-size:18px;font-weight:600;">'.$brand.'</td></tr>
|
||||
<tr><td style="padding:24px 24px 8px 24px;color:#111111;font-size:16px;line-height:1.5;">'.nl2br(htmlspecialchars($data['hello_line'])).'</td></tr>
|
||||
<tr><td style="padding:8px 24px;color:#111111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($data['intro'])).'</td></tr>
|
||||
<tr><td style="padding:16px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;">
|
||||
<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;border-bottom:1px solid #e5e7eb;">'.htmlspecialchars($t['email_avec'] ?? 'Avec').'</td><td style="padding:14px 16px;font-size:14px;color:#111111;">'.htmlspecialchars($data['who']).'</td></tr>
|
||||
<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($t['date_label'] ?? 'Date').'</td><td style="padding:14px 16px;font-size:14px;color:#111111;">'.htmlspecialchars($data['when']).'</td></tr>'.
|
||||
(!empty($data['format_line']) ? '<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($t['format_label'] ?? 'Формат').'</td><td style="padding:14px 16px;font-size:14px;color:#111111;">'.htmlspecialchars($data['format_line']).'</td></tr>' : '').
|
||||
(!empty($data['address_line']) ? '<tr><td style="padding:14px 16px;font-size:14px;color:#6b7280;">'.htmlspecialchars($t['adresse_label_ru'] ?? 'Адрес').'</td><td style="padding:14px 16px;font-size:14px;color:#111111;">'.htmlspecialchars($data['address_line']).'</td></tr>' : '').
|
||||
'</table></td></tr>'.
|
||||
(!empty($data['cta_url']) ? '<tr><td align="center" style="padding:8px 24px 24px 24px;"><a href="'.htmlspecialchars($data['cta_url']).'" target="_blank" style="display:inline-block;text-decoration:none;padding:12px 20px;border-radius:999px;background:#E03C3F;color:#ffffff;font-weight:600;">'.htmlspecialchars($data['cta_label']).'</a></td></tr>' : '').
|
||||
'<tr><td style="padding:8px 24px 24px 24px;color:#111111;font-size:16px;line-height:1.6;">'.nl2br(htmlspecialchars($data['footer'])).'</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').' '.$brand_bas.' — '.htmlspecialchars($t['merci_simple'] ?? 'Merci').'</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>';
|
||||
}
|
||||
function build_email_text(array $data, array $t): string {
|
||||
$lines = [];
|
||||
$lines[] = $data['hello_line']; $lines[] = '';
|
||||
$lines[] = $data['intro']; $lines[] = '';
|
||||
$lines[] = ($t['email_avec'] ?? 'Avec').' '.$data['who'];
|
||||
$lines[] = ($t['date_label'] ?? 'Date').' '.$data['when'];
|
||||
if (!empty($data['format_line'])) $lines[] = ($t['format_label'] ?? 'Формат').': '.$data['format_line'];
|
||||
if (!empty($data['address_line'])) $lines[] = ($t['adresse_label_ru'] ?? 'Адрес').': '.$data['address_line'];
|
||||
if (!empty($data['cta_url'])) { $lines[] = ''; $lines[] = $data['cta_label'].': '.$data['cta_url']; }
|
||||
$lines[] = ''; $lines[] = $data['footer'];
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/* =================== Helpers ICS (inchangés) =================== */
|
||||
function ics_escape(string $s): string {
|
||||
$s = str_replace("\\", "\\\\", $s);
|
||||
$s = str_replace(";", "\;", $s);
|
||||
$s = str_replace(",", "\,", $s);
|
||||
$s = preg_replace("/\r\n|\r|\n/", "\\n", $s);
|
||||
return $s;
|
||||
}
|
||||
function build_ics_common_lines(
|
||||
string $uid, DateTime $start, DateTime $end, string $timezone,
|
||||
string $summary, string $description, string $location, array $attendees
|
||||
): string {
|
||||
$lines = [];
|
||||
$lines[] = "BEGIN:VCALENDAR";
|
||||
$lines[] = "VERSION:2.0";
|
||||
$lines[] = "PRODID:-//УСЛУГИ.ФР//RDV//FR";
|
||||
$lines[] = "CALSCALE:GREGORIAN";
|
||||
$lines[] = "METHOD:REQUEST";
|
||||
$lines[] = "BEGIN:VEVENT";
|
||||
$lines[] = "UID:{$uid}";
|
||||
$lines[] = "DTSTAMP:" . gmdate('Ymd\THis\Z');
|
||||
$lines[] = "DTSTART;TZID={$timezone}:" . $start->format('Ymd\THis');
|
||||
$lines[] = "DTEND;TZID={$timezone}:" . $end->format('Ymd\THis');
|
||||
$lines[] = "SUMMARY:" . ics_escape($summary);
|
||||
$lines[] = "DESCRIPTION:" . ics_escape($description);
|
||||
$lines[] = "LOCATION:" . ics_escape($location);
|
||||
$lines[] = "ORGANIZER;CN=УСЛУГИ.ФР:mailto:rdv@uslugi.fr";
|
||||
foreach ($attendees as $a) {
|
||||
if (empty($a['email'])) continue;
|
||||
$cn = ics_escape($a['cn'] ?? '');
|
||||
$em = $a['email'];
|
||||
$lines[] = "ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN={$cn};RSVP=TRUE:mailto:{$em}";
|
||||
}
|
||||
$lines[] = "STATUS:CONFIRMED";
|
||||
$lines[] = "END:VEVENT";
|
||||
$lines[] = "END:VCALENDAR";
|
||||
return implode("\r\n", $lines) . "\r\n";
|
||||
}
|
||||
function build_ics_client(array $rdv, DateTime $start, DateTime $end, string $timezone, string $location, string $ctaUrl, array $t, string $uid): string {
|
||||
$summary = ($t['rdv_avec'] ?? 'Rendez-vous avec').' '.($t['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'];
|
||||
$desc = ($t['votre_rdv_attente'] ?? 'Votre rendez-vous est prévu')."\n\n".
|
||||
($t['email_avec'] ?? 'Avec').' '.($t['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien']."\n".
|
||||
'Email: '.$rdv['email_praticien']."\n".
|
||||
(($rdv['tel_praticien'] ?? '') ? ('Téléphone: '.$rdv['tel_praticien']."\n") : '').
|
||||
($ctaUrl ? ("\n".($t['consultez_espace_pour_details'] ?? 'Consultez votre espace pour les détails')."\n".$ctaUrl) : '');
|
||||
$attendees = [
|
||||
['cn' => $rdv['prenom_praticien'].' '.$rdv['nom_praticien'], 'email' => $rdv['email_praticien']],
|
||||
['cn' => $rdv['prenom_client'].' '.$rdv['nom_client'], 'email' => $rdv['email_client']],
|
||||
];
|
||||
return build_ics_common_lines($uid, $start, $end, $timezone, $summary, $desc, $location, $attendees);
|
||||
}
|
||||
function build_ics_praticien(array $rdv, DateTime $start, DateTime $end, string $timezone, string $location, string $ctaUrl, array $t, string $uid): string {
|
||||
$summary = ($t['rdv_avec'] ?? 'Rendez-vous avec').' '.$rdv['prenom_client'].' '.$rdv['nom_client'];
|
||||
$desc = ($t['nouveau_rdv_pris_avec'] ?? 'Nouveau rendez-vous pris avec')."\n\n".
|
||||
($t['client_label'] ?? 'Client').': '.$rdv['prenom_client'].' '.$rdv['nom_client']."\n".
|
||||
'Email: '.$rdv['email_client']."\n".
|
||||
(($rdv['tel_client'] ?? '') ? ('Téléphone: '.$rdv['tel_client']."\n") : '').
|
||||
($ctaUrl ? ("\n".($t['consultez_espace_pour_details'] ?? 'Consultez votre espace pour les détails')."\n".$ctaUrl) : '');
|
||||
$attendees = [
|
||||
['cn' => $rdv['prenom_client'].' '.$rdv['nom_client'], 'email' => $rdv['email_client']],
|
||||
['cn' => $rdv['prenom_praticien'].' '.$rdv['nom_praticien'], 'email' => $rdv['email_praticien']],
|
||||
];
|
||||
return build_ics_common_lines($uid, $start, $end, $timezone, $summary, $desc, $location, $attendees);
|
||||
}
|
||||
|
||||
/* ================== Mode POST (AJAX) ================== */
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && ($_POST['action'] ?? '') === 'validate') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// CSRF
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
|
||||
echo json_encode(['ok'=>false, 'html'=>'<div class="msg msg--err"><h2>Сессия истекла</h2><p>Обновите страницу и попробуйте ещё раз.</p></div>']); exit;
|
||||
}
|
||||
|
||||
$client_id = $_SESSION['reference_id'] ?? null;
|
||||
$praticien_id = isset($_POST['praticien_id']) ? (int)$_POST['praticien_id'] : null;
|
||||
$startIso = $_POST['start'] ?? null;
|
||||
$mode_rdv_in = $_POST['mode_rdv'] ?? ($_GET['mode_rdv'] ?? null);
|
||||
|
||||
$mode_rdv = ($mode_rdv_in === $GLOBALS['c_offline']) ? $GLOBALS['c_offline'] : $GLOBALS['c_online'];
|
||||
$planningUrl = '/public/client/reserver_rdv.php?praticien_id='.(int)($praticien_id ?? 0);
|
||||
|
||||
if (!$praticien_id || !$startIso || !$client_id) {
|
||||
$html = '
|
||||
<div class="msg msg--err">
|
||||
<h2>'.htmlspecialchars($translations['donnees_manquantes_reservation'] ?? 'Données manquantes pour la réservation').'</h2>
|
||||
<p>'.htmlspecialchars($translations['retour_fiche_praticien_hint'] ?? 'Merci de revenir sur la fiche du praticien et de choisir un créneau.').'</p>
|
||||
<div class="actions">
|
||||
<a class="btn btn-outline" href="'.htmlspecialchars($planningUrl).'">'.htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche').'</a>
|
||||
</div>
|
||||
</div>';
|
||||
echo json_encode(['ok'=>false, 'html'=>$html]); exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$date_rdv_sql = (new DateTime($startIso))->format('Y-m-d H:i:s');
|
||||
|
||||
// Créneau libre ? (libre si seulement "annulé")
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT COUNT(*)
|
||||
FROM rendez_vous
|
||||
WHERE praticien_id = ?
|
||||
AND date_rdv = ?
|
||||
AND statut <> 'annulé'
|
||||
");
|
||||
$stmt->execute([$praticien_id, $date_rdv_sql]);
|
||||
|
||||
if ((int)$stmt->fetchColumn() > 0) {
|
||||
$html = '
|
||||
<div class="msg msg--err">
|
||||
<h2>'.htmlspecialchars($translations['creneau_deja_reserve'] ?? 'Créneau déjà réservé').'</h2>
|
||||
<p>'.htmlspecialchars($translations['choisir_autre_creneau'] ?? 'Veuillez choisir un autre créneau.').'</p>
|
||||
<div class="actions">
|
||||
<a class="btn btn-outline" href="'.htmlspecialchars($planningUrl).'">'.htmlspecialchars($translations['retour_au_planning'] ?? 'Retour au planning').'</a>
|
||||
</div>
|
||||
</div>';
|
||||
echo json_encode(['ok'=>false, 'html'=>$html]); exit;
|
||||
}
|
||||
|
||||
// Récupérer durée + adresse praticien
|
||||
$stmt = $pdo->prepare("SELECT duree_rdv, adresse, ville, code_postal FROM praticien WHERE id = ?");
|
||||
$stmt->execute([$praticien_id]);
|
||||
$p = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$duree = (int)($p['duree_rdv'] ?? 60);
|
||||
|
||||
$adresse = trim((string)($p['adresse'] ?? ''));
|
||||
$ville = trim((string)($p['ville'] ?? ''));
|
||||
$code_postal = trim((string)($p['code_postal'] ?? ''));
|
||||
$has_address = ($adresse !== '' && $ville !== '' && $code_postal !== '');
|
||||
|
||||
if ($mode_rdv === $c_offline && !$has_address) $mode_rdv = $c_online;
|
||||
|
||||
$addrSnapshot = $has_address ? "{$adresse}, {$code_postal} {$ville}" : '';
|
||||
|
||||
// Insert RDV
|
||||
$uuid = bin2hex(random_bytes(16));
|
||||
$lieu_insert = ($mode_rdv === $c_offline) ? 'offline' : 'online';
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO rendez_vous (praticien_id, client_id, date_rdv, duree_minutes, lieu, statut, commentaire, uuid_ical, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'prévu', '', ?, NOW())
|
||||
");
|
||||
$stmt->execute([$praticien_id, $client_id, $date_rdv_sql, $duree, $lieu_insert, $uuid]);
|
||||
|
||||
// Infos pour e-mails & ICS
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT rv.id AS rdv_id, rv.date_rdv, rv.duree_minutes, rv.lieu,
|
||||
p.nom AS nom_praticien, p.prenom AS prenom_praticien, p.email AS email_praticien, p.telephone AS tel_praticien,
|
||||
p.adresse AS p_adresse, p.ville AS p_ville, p.code_postal AS p_cp,
|
||||
c.email AS email_client, c.nom AS nom_client, c.prenom AS prenom_client, c.telephone AS tel_client
|
||||
FROM rendez_vous rv
|
||||
JOIN praticien p ON rv.praticien_id = p.id
|
||||
JOIN client c ON rv.client_id = c.id
|
||||
WHERE rv.uuid_ical = ?
|
||||
");
|
||||
$stmt->execute([$uuid]);
|
||||
$rdv = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$rdv) { throw new RuntimeException('Rendez-vous introuvable après insertion.'); }
|
||||
|
||||
$rdvId = (int)$rdv['rdv_id'];
|
||||
|
||||
// =========================
|
||||
// LOG RDV (rdv_actions_log) : create
|
||||
// =========================
|
||||
$after = json_encode([
|
||||
'statut' => 'prévu',
|
||||
'date_rdv' => $rdv['date_rdv'], // datetime SQL
|
||||
'duree_minutes'=> (int)$rdv['duree_minutes'],
|
||||
'lieu' => $rdv['lieu'], // online/offline (chez toi)
|
||||
'praticien_id' => (int)$praticien_id,
|
||||
'client_id' => (int)$client_id,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$logStmt = $pdo->prepare("
|
||||
INSERT INTO rdv_actions_log
|
||||
(rdv_id, action, by_user_id, by_role, before_json, after_json, created_at)
|
||||
VALUES
|
||||
(?, 'create', ?, 'client', NULL, ?, NOW())
|
||||
");
|
||||
$logStmt->execute([$rdvId, (int)$client_id, $after]);
|
||||
// =========================
|
||||
// =========================
|
||||
// PUSH RDV — création (client)
|
||||
// =========================
|
||||
try {
|
||||
// ✅ Récupérer compte_id du praticien (pour compteur badge)
|
||||
$st = $pdo->prepare("SELECT id FROM compte WHERE role='praticien' AND reference_id=? LIMIT 1");
|
||||
$st->execute([(int)$praticien_id]);
|
||||
$praticienCompteId = (int)($st->fetchColumn() ?: 0);
|
||||
|
||||
// ✅ Récupérer compte_id du client (utile plus tard pour d'autres events, pas obligatoire ici)
|
||||
$st = $pdo->prepare("SELECT id FROM compte WHERE role='client' AND reference_id=? LIMIT 1");
|
||||
$st->execute([(int)$client_id]);
|
||||
$clientCompteId = (int)($st->fetchColumn() ?: 0);
|
||||
|
||||
// ✅ Incrémenter le compteur RDV (badge) pour le praticien
|
||||
if ($praticienCompteId > 0) {
|
||||
$pdo->prepare("
|
||||
INSERT INTO push_badge_counters (compte_id, msg_unread, rdv_unread)
|
||||
VALUES (:cid, 0, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
rdv_unread = LEAST(99, rdv_unread + 1),
|
||||
updated_at = NOW()
|
||||
")->execute([':cid' => $praticienCompteId]);
|
||||
}
|
||||
|
||||
// 🔔 Praticien : nouvelle demande
|
||||
push_rdv_to_praticien(
|
||||
$pdo,
|
||||
(int)$praticien_id,
|
||||
$translations['push_rdv_new_title'] ?? 'Новая запись',
|
||||
$translations['push_rdv_new_body'] ?? 'У вас новая запись на приём.',
|
||||
'/public/praticien/rendez_vous.php?id='.(int)$rdvId,
|
||||
[
|
||||
'rdv_id' => (int)$rdvId,
|
||||
'action' => 'create',
|
||||
'from_role' => 'client',
|
||||
'client_id' => (int)$client_id,
|
||||
'praticien_id' => (int)$praticien_id,
|
||||
'date_rdv' => $rdv['date_rdv'],
|
||||
]
|
||||
);
|
||||
|
||||
// 🔔 Client : confirmation
|
||||
push_rdv_to_client(
|
||||
$pdo,
|
||||
(int)$client_id,
|
||||
$translations['push_rdv_created_title'] ?? 'Запись отправлена',
|
||||
$translations['push_rdv_created_body'] ?? 'Ожидает подтверждения специалиста.',
|
||||
'/public/client/mes_rendez_vous.php',
|
||||
[
|
||||
'rdv_id' => (int)$rdvId,
|
||||
'action' => 'create'
|
||||
]
|
||||
);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('push rdv create failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$tz = 'Europe/Paris';
|
||||
$startDT = new DateTime($rdv['date_rdv'], new DateTimeZone($tz));
|
||||
$endDT = (clone $startDT)->modify("+{$duree} minutes");
|
||||
$when = $startDT->format('d/m/Y H:i');
|
||||
$when_a = $startDT->format('d/m/Y') . ' ' . ($translations['a'] ?? 'в') . ' ' . $startDT->format('H:i');
|
||||
|
||||
$ru_online = $translations['option_online'] ?? 'Онлайн';
|
||||
$ru_offline = $translations['option_cabinet'] ?? 'В кабинете';
|
||||
$is_offline = ($mode_rdv === $c_offline);
|
||||
$mode_label_ru = $is_offline ? $ru_offline : $ru_online;
|
||||
$location = $is_offline && $has_address ? $addrSnapshot : $ru_online;
|
||||
|
||||
$cta_client_url = 'https://uslugi.fr/public/client/mes_rendez_vous.php';
|
||||
$cta_prat_url = "https://uslugi.fr/public/praticien/rendez_vous.php?id={$rdvId}";
|
||||
|
||||
// ======= (1) RÉPONSE IMMÉDIATE AU CLIENT (évite le 503) =======
|
||||
$html = '
|
||||
<div class="card-notice">
|
||||
<h1 class="cn-title">'.($translations['block_1'] ?? 'Спасибо за запись').'</h1>
|
||||
<div class="cn-date">'.htmlspecialchars($when_a).'</div>
|
||||
<ul class="notice-list">
|
||||
<li>
|
||||
<span class="ico">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22a2.5 2.5 0 0 0 2.45-2h-4.9A2.5 2.5 0 0 0 12 22Zm7-6V11a7 7 0 1 0-14 0v5l-2 2v1h18v-1l-2-2Z"/></svg>
|
||||
</span>
|
||||
<span class="txt"><strong class="txt-warn">'.htmlspecialchars($translations['block_2'] ?? 'Ожидает подтверждения').'</strong></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="ico ico-dark">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h16v12H7l-3 3V4z"/></svg>
|
||||
</span>
|
||||
<span class="txt">'.($translations['block_3'] ?? 'Оплата производится напрямую со специалистом.').'</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="ico ico-info">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor"/>
|
||||
<rect x="11" y="6" width="2" height="8" fill="#fff"/>
|
||||
<rect x="11" y="16" width="2" height="2" fill="#fff"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="txt">'.($translations['block_4'] ?? 'Ссылка появится за 15 минут до начала.').'</span>
|
||||
</li>
|
||||
<li>
|
||||
<span> </span>
|
||||
<span class="txt"><a class="btn btn-primary" href="/public/client/mes_rendez_vous.php">'.
|
||||
htmlspecialchars($translations['ecrire_au_specialiste'] ?? ($translations['voir_mes_rdv'] ?? 'Написать специалисту'))
|
||||
.'</a></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>';
|
||||
|
||||
// ⚠️ libérer la session, envoyer la réponse, puis continuer en arrière-plan
|
||||
session_write_close();
|
||||
echo json_encode(['ok'=>true, 'html'=>$html]);
|
||||
if (function_exists('fastcgi_finish_request')) {
|
||||
fastcgi_finish_request(); // le client & Varnish ont reçu la réponse => pas de 503
|
||||
}
|
||||
ignore_user_abort(true);
|
||||
|
||||
// ======= (2) TÂCHES ARRIÈRE-PLAN : e-mails + WhatsApp =======
|
||||
try {
|
||||
// ICS
|
||||
$ical_client = build_ics_client($rdv, $startDT, $endDT, $tz, $location, $cta_client_url, $translations, $uuid);
|
||||
$ical_prat = build_ics_praticien($rdv, $startDT, $endDT, $tz, $location, $cta_prat_url, $translations, $uuid);
|
||||
|
||||
$whoClientSees = ($translations['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'];
|
||||
$whoPratSees = ($translations['client_label'] ?? 'Client').' '.$rdv['prenom_client'].' '.$rdv['nom_client'];
|
||||
$cta_label = $translations['consultez_espace_pour_details'] ?? 'Voir les détails';
|
||||
|
||||
// Client
|
||||
$subject_c = $translations['email_confirmation_rdv_sujet'] ?? 'Confirmation de rendez-vous';
|
||||
$data_c = [
|
||||
'hello_line' => sprintf($translations['mail_valid_bonjour_nom'] ?? 'Bonjour %s', $rdv['prenom_client'].' '.$rdv['nom_client']),
|
||||
'intro' => ($translations['votre_rdv_attente'] ?? 'Votre rendez-vous est en attente de confirmation').' '.
|
||||
($translations['email_avec'] ?? 'avec').' '.$whoClientSees.".\n".
|
||||
($translations['veuillez_trouver_invitation'] ?? "Veuillez trouver l'invitation en pièce jointe."),
|
||||
'who' => $whoClientSees,
|
||||
'when' => $when_a,
|
||||
'format_line' => $mode_label_ru,
|
||||
'address_line' => ($is_offline && $has_address) ? $addrSnapshot : '',
|
||||
'cta_url' => $cta_client_url,
|
||||
'cta_label' => $cta_label,
|
||||
'footer' => $translations['merci_simple'] ?? 'Merci.',
|
||||
];
|
||||
$ok_c = send_rdv_email_with_ics_phpmailer(
|
||||
$rdv['email_client'], $rdv['prenom_client'].' '.$rdv['nom_client'],
|
||||
$subject_c,
|
||||
build_email_text($data_c, $translations),
|
||||
build_email_html($data_c, $translations),
|
||||
$ical_client, "rdv_client_{$uuid}.ics"
|
||||
);
|
||||
|
||||
// Praticien
|
||||
$subject_p = ($translations['nouveau_rdv_avec'] ?? 'Nouveau rendez-vous avec').' '.$rdv['prenom_client'].' '.$rdv['nom_client'];
|
||||
$data_p = [
|
||||
'hello_line' => ($translations['email_bonjour_dr'] ?? 'Bonjour Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'],
|
||||
'intro' => ($translations['nouveau_rdv_pris_avec'] ?? 'Un nouveau rendez-vous a été pris avec vous.'),
|
||||
'who' => $whoPratSees,
|
||||
'when' => $when_a,
|
||||
'format_line' => $mode_label_ru,
|
||||
'address_line' => ($is_offline && $has_address) ? $addrSnapshot : '',
|
||||
'cta_url' => $cta_prat_url,
|
||||
'cta_label' => $cta_label,
|
||||
'footer' => $translations['merci_simple'] ?? 'Merci.',
|
||||
];
|
||||
$ok_p = send_rdv_email_with_ics_phpmailer(
|
||||
$rdv['email_praticien'], $rdv['prenom_praticien'].' '.$rdv['nom_praticien'],
|
||||
$subject_p,
|
||||
build_email_text($data_p, $translations),
|
||||
build_email_html($data_p, $translations),
|
||||
$ical_prat, "rdv_praticien_{$uuid}.ics"
|
||||
);
|
||||
|
||||
if (!$ok_c || !$ok_p) {
|
||||
error_log('RDV: un ou plusieurs e-mails ICS n’ont pas été envoyés.');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('RDV: background email error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
// WhatsApp (ne doit JAMAIS bloquer)
|
||||
try {
|
||||
$waTo = $rdv['tel_praticien'] ?? '';
|
||||
$clientFullName = trim(($rdv['prenom_client'] ?? '') . ' ' . ($rdv['nom_client'] ?? ''));
|
||||
$dateHeure = $when;
|
||||
if ($waTo && $clientFullName && $dateHeure && $rdvId) {
|
||||
// Si votre lib permet un timeout, utilisez-le. Sinon, laissez tel quel.
|
||||
$waRes = Sms\notify_rdv_en_attente($waTo, $clientFullName, $dateHeure, $rdvId /*, ['timeout'=>5] */);
|
||||
if ((int)($waRes['http_code'] ?? 0) !== 200) {
|
||||
error_log('WA en_attente non-200: ' . json_encode($waRes, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
} else {
|
||||
error_log('WA en_attente: données incomplètes (tel praticien / nom client / dateHeure / rdvId).');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('WA en_attente_confirmation exception: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Fin du background
|
||||
exit;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$html = '
|
||||
<div class="msg msg--err">
|
||||
<h2>'.htmlspecialchars($translations['erreur_prefixe'] ?? 'Erreur').'</h2>
|
||||
<p>'.htmlspecialchars($e->getMessage()).'</p>
|
||||
<div class="actions">
|
||||
<a class="btn btn-outline" href="'.htmlspecialchars('/public/client/reserver_rdv.php?praticien_id='.(int)($praticien_id ?? 0)).'">'.htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche').'</a>
|
||||
</div>
|
||||
</div>';
|
||||
echo json_encode(['ok'=>false, 'html'=>$html]); exit;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================== Mode GET : UI + loader + JS ================== */
|
||||
$praticien_id = isset($_GET['praticien_id']) ? (int)$_GET['praticien_id'] : null;
|
||||
$startIso = $_GET['start'] ?? null;
|
||||
$modeGet = $_GET['mode_rdv'] ?? null;
|
||||
|
||||
ui_head($translations['validation_rdv_title'] ?? 'Validation du rendez-vous');
|
||||
?>
|
||||
<script>
|
||||
(function(){
|
||||
const loader = document.getElementById('loader');
|
||||
const result = document.getElementById('result');
|
||||
|
||||
const praticien_id = <?= json_encode($praticien_id) ?>;
|
||||
const start = <?= json_encode($startIso) ?>;
|
||||
const csrf = <?= json_encode($CSRF) ?>;
|
||||
const mode_rdv = <?= json_encode($modeGet) ?>;
|
||||
|
||||
if(!praticien_id || !start){
|
||||
loader.classList.add('hidden');
|
||||
const backUrl = praticien_id ? `/public/client/reserver_rdv.php?praticien_id=${praticien_id}` : `/public/client/reserver_rdv.php`;
|
||||
result.innerHTML = `
|
||||
<div class="msg msg--err">
|
||||
<h2><?= htmlspecialchars($translations['donnees_manquantes_reservation'] ?? 'Données manquantes pour la réservation') ?></h2>
|
||||
<p><?= htmlspecialchars($translations['retour_fiche_praticien_hint'] ?? 'Merci de revenir sur la fiche du praticien et de choisir un créneau.') ?></p>
|
||||
<div class="actions">
|
||||
<a class="btn btn-outline" href="${backUrl}"><?= htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche') ?></a>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({ action: 'validate', csrf, praticien_id, start });
|
||||
if (mode_rdv) body.set('mode_rdv', mode_rdv);
|
||||
|
||||
fetch(location.pathname, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
result.innerHTML = data.html || '<div class="msg msg--err"><h2>Неизвестная ошибка</h2></div>';
|
||||
})
|
||||
.catch(() => {
|
||||
result.innerHTML = '<div class="msg msg--err"><h2>Сетевая ошибка</h2><p>Попробуйте повторно.</p></div>';
|
||||
})
|
||||
.finally(() => {
|
||||
loader.classList.add('hidden');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php ui_foot(); ?>
|
||||
Reference in new issue
Block a user