647 lines
31 KiB
PHP
647 lines
31 KiB
PHP
<?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(); ?>
|