Init Git Prod Mars 2026

This commit is contained in:
Oleg Barykin committed 2026-03-05 10:25:39 +01:00
commit d81efe93b1
362 files changed
+86627

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
session_start();
header('Content-Type: text/html; charset=UTF-8');
$rdvId = (int)($_GET['rdv'] ?? 0);
$role = $_SESSION['role'] ?? '';
if ($role === 'praticien') {
$backUrl = '/public/praticien/rendez_vous.php';
$backText = 'Мои консультации';
} else {
$backUrl = '/public/client/mes_rendez_vous.php';
$backText = 'Мои консультации';
}
?>
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Спасибо — uslugi.fr</title>
<style>
:root{
--color-hero:#8CA9FF; --brand-blue:#38378B; --brand-red:#E03C3F;
--card:#fff; --text:#1d1d1f; --muted:#6b7280;
--radius:22px; --shadow:0 12px 30px rgba(0,0,0,.10);
}
*{box-sizing:border-box} html,body{height:100%}
body{margin:0;background:var(--color-hero);font-family:Montserrat,system-ui,Arial,sans-serif;
color:var(--text);display:flex;align-items:center;justify-content:center;padding:24px;}
.card{width:min(760px,100%);background:var(--card);border-radius:var(--radius);
box-shadow:var(--shadow);padding:28px 22px;text-align:center;border:1px solid rgba(56,55,139,.08);}
h1{margin:6px 0 10px;font-size:clamp(20px,2.8vw,26px);color:var(--brand-blue)}
p{margin:0 auto 12px;max-width:60ch;line-height:1.6}
.muted{color:var(--muted);font-size:14px}
.actions{margin-top:18px;display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
.btn{appearance:none;border:0;cursor:pointer;padding:12px 18px;font-weight:700;border-radius:999px;
text-decoration:none;display:inline-block;transition:.2s transform,.2s background-color,.2s box-shadow}
.btn:active{transform:translateY(1px)}
.btn-primary{background:var(--brand-red);color:#fff;box-shadow:0 8px 18px rgba(56,55,139,.25)}
.btn-primary:hover{background:#E03C3F}
.btn-ghost{background:transparent;color:var(--brand-blue);border:1px solid rgba(56,55,139,.25)}
</style>
</head>
<body>
<main class="card" role="main">
<h1>Спасибо за использование видеосвязи на uslugi.fr</h1>
<p>Надеемся, что ваш опыт был комфортным и полезным. <br>Спасибо за доверие! <br>Команда <strong>uslugi.fr</strong>.</p>
<?php if ($rdvId): ?>
<p class="muted">ID консультации: #<?= $rdvId ?></p>
<?php endif; ?>
<div class="actions">
<a class="btn btn-primary" href="https://uslugi.fr/">Вернуться на сайт</a>
<a class="btn btn-ghost" href="<?= htmlspecialchars($backUrl) ?>"><?= htmlspecialchars($backText) ?></a>
</div>
</main>
</body>
</html>
+288
View File
@@ -0,0 +1,288 @@
<?php
/**
* /visio/join.php?rdv=ID[&start=1]
* - Accès: client OU praticien liés au RDV
* - Fenêtre horaire respectée (config)
* - Création de salle SEULEMENT si praticien + start=1
* - Jitsi via external_api.js
*/
$config = require __DIR__ . '/visio_config.php';
// Sécurité & DB
require_once $config['paths']['securite'];
require_login_or_redirect(['client','praticien']);
require_once $config['paths']['db'];
// TZ cohérente
date_default_timezone_set('Europe/Paris');
$tz = new DateTimeZone('Europe/Paris');
// Paramètres
$rdv_id = (int)($_GET['rdv'] ?? 0);
if ($rdv_id <= 0) { http_response_code(400); exit('Paramètre manquant.'); }
// RDV
$stmt = $pdo->prepare("
SELECT rv.id, rv.praticien_id, rv.client_id, rv.date_rdv
FROM rendez_vous rv
WHERE rv.id = ?
LIMIT 1
");
$stmt->execute([$rdv_id]);
$rdv = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$rdv) { http_response_code(404); exit('Rendez-vous introuvable.'); }
// Rôle & appartenance
$role = $_SESSION['role'] ?? '';
$refId = (int)($_SESSION['reference_id'] ?? 0);
$ok = ($role === 'praticien' && $refId === (int)$rdv['praticien_id'])
|| ($role === 'client' && $refId === (int)$rdv['client_id']);
if (!$ok) { http_response_code(403); exit('Accès refusé.'); }
// Fenêtre d’accès temporelle (si ta BDD est en UTC, décommente la conversion)
// $start = (new DateTime($rdv['date_rdv'], new DateTimeZone('UTC')))->setTimezone($tz);
$start = new DateTime($rdv['date_rdv'], $tz);
$open = (clone $start)->modify('-' . (int)$config['open_before_minutes'] . ' minutes');
$close = (clone $start)->modify('+' . (int)$config['close_after_minutes'] . ' minutes');
$now = new DateTime('now', $tz);
if ($now < $open) { http_response_code(403); exit('La salle ouvre à '.$open->format('H:i').'.'); }
if ($now > $close){ http_response_code(403); exit('La salle a fermé à '.$close->format('H:i').'.'); }
// Création/obtention de salle
require_once __DIR__ . '/visio_create_or_get.php';
$allowCreate = ($role === 'praticien' && isset($_GET['start']) && $_GET['start'] == '1');
$roomSlug = visio_room_for_rdv($pdo, $rdv_id, $config['slug_secret'], $allowCreate);
if (!$roomSlug) {
// Pas de salle -> on affiche un message UX propre + code HTTP 403
header('Content-Type: text/html; charset=UTF-8', true, 403);
echo <<<HTML
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ваша консультация скоро начнётся</title>
<style>
:root{
--brand-blue:#38378B;
--brand-red:#E03C3F;
--brand-mint:#06FFA8;
--text:#1d1d1f;
--muted:#6b7280;
--bg:#8CA9FF;
--card:#ffffff;
--radius:20px;
--shadow:0 10px 30px rgba(0,0,0,.08);
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:var(--bg);
color:var(--text);
font-family: "Montserrat",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
display:flex; align-items:center; justify-content:center; padding:24px;
}
.msg{
width:min(720px, 100%);
background:var(--card);
border-radius:var(--radius);
box-shadow:var(--shadow);
padding:28px 24px;
text-align:center;
border:1px solid rgba(56,55,139,.08);
}
.msg__icon{
width:64px; height:64px; margin:4px auto 14px;
display:grid; place-items:center;
border-radius:50%;
background:rgba(56,55,139,.07);
}
.msg__icon svg{width:34px; height:34px; display:block; color:var(--brand-blue)}
.msg__title{
margin:0 0 10px;
font-weight:700;
font-size:clamp(18px,2.6vw,24px);
color:var(--brand-blue);
letter-spacing:.2px;
}
.msg__text{
margin:0 auto 16px;
max-width:46ch;
line-height:1.55;
color:#2b2b2b;
font-size:clamp(14px,2vw,16px);
}
.msg__hint{
color:var(--muted);
font-size:13px;
margin-top:10px;
}
.msg__actions{
margin-top:18px; display:flex; gap:10px; justify-content:center; flex-wrap:wrap;
}
.btn{
appearance:none; border:0; cursor:pointer;
padding:10px 18px; border-radius:999px; font-weight:600;
transition:.2s transform ease, .2s box-shadow ease, .2s background-color ease;
}
.btn:active{ transform:translateY(1px); }
.btn-primary{
background:var(--brand-red); color:#fff;
box-shadow:0 6px 16px rgba(56,55,139,.25);
}
.btn-primary:hover{ background:#E03C3F; }
.btn-ghost{
background:transparent; color:var(--brand-blue);
border:1px solid rgba(56,55,139,.25);
}
.badge{
display:inline-block; margin:auto; margin-bottom:8px;
padding:4px 10px; border-radius:999px;
background:rgba(6,255,168,.14); color:#0a7a5f; font-weight:600; font-size:12px;
border:1px solid rgba(6,255,168,.25);
}
a { text-decoration: none;}
</style>
</head>
<body>
<main class="msg" role="alert" aria-live="polite">
<div class="msg__icon" aria-hidden="true">
<!-- clock icon -->
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 6v6l4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/>
</svg>
</div>
<div class="badge">Ожидание подключения</div>
<h1 class="msg__title">Ваша консультация скоро начнётся</h1>
<p class="msg__text">
Пожалуйста, оставайтесь на этой странице — специалист подключится в ближайшее время.
Если окно не открылось автоматически, нажмите «Обновить».
</p>
<div class="msg__actions">
<button class="btn btn-primary" onclick="location.reload()">Обновить</button>
<a class="btn btn-ghost" href="/public/client/mes_rendez_vous.php">Мои консультации</a>
</div>
<p class="msg__hint">Подсказка: проверьте доступ к камере и микрофону заранее.</p>
</main>
</body>
</html>
HTML;
exit;
}
// Nom affiché
//$displayName = htmlspecialchars($_SESSION['display_name'] ?? ($role === 'praticien' ? 'Praticien' : 'Client'));
//$jitsiDomain = $config['jitsi_domain'];
require_once __DIR__ . '/nom_afficher.php';
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Visio — RDV #<?= (int)$rdv_id ?></title>
<style>
html, body { height: 100%; margin: 0; }
#jitsi { height: 100%; }
</style>
<script src="https://<?= htmlspecialchars($jitsiDomain) ?>/external_api.js"></script>
</head>
<body>
<div id="jitsi"></div>
<script>
(function () {
const domain = "<?= htmlspecialchars($jitsiDomain) ?>";
const REDIRECT_URL = "/visio/fin.php?rdv=<?= (int)$rdv_id ?>";
const isPraticien = <?= json_encode(($role ?? '') === 'praticien') ?>;
const options = {
roomName: "<?= htmlspecialchars($roomSlug) ?>",
parentNode: document.querySelector('#jitsi'),
userInfo: { displayName: <?= json_encode($displayName) ?> },
configOverwrite: {
prejoinConfig: { enabled: true },
startWithAudioMuted: false,
startWithVideoMuted: false,
disableDeepLinking: true,
enableClosePage: false
}
};
const api = new JitsiMeetExternalAPI(domain, options);
// Redirection quand l’utilisateur quitte
api.addListener('videoConferenceLeft', () => { window.location.assign(REDIRECT_URL); });
api.addListener('readyToClose', () => { window.location.assign(REDIRECT_URL); });
// ---- Bandeau “durée dépassée” (praticien uniquement) ----
if (isPraticien) {
// Charger Montserrat si absent
(function ensureMontserrat(){
const id = 'montserrat-font-link';
if (!document.getElementById(id)) {
const pre = document.createElement('link');
pre.rel = 'preconnect'; pre.href = 'https://fonts.gstatic.com'; pre.crossOrigin = 'anonymous';
const link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600;700&display=swap';
document.head.appendChild(pre);
document.head.appendChild(link);
}
})();
const dureeMinutes = <?= (int)($rdv['duree_minutes'] ?? 60) ?>; // durée prévue (BDD)
const startTime = new Date("<?= (new DateTime($rdv['date_rdv'], new DateTimeZone('Europe/Paris')))->format('Y-m-d H:i:s') ?>");
const plannedEnd = new Date(startTime.getTime() + dureeMinutes * 60000);
function showOvertimeBanner() {
if (document.getElementById('overtime-banner')) return;
const banner = document.createElement('div');
banner.id = 'overtime-banner';
banner.textContent = "⚠️ Время консультации истекло. Пожалуйста, завершите встречу.";
Object.assign(banner.style, {
position: 'fixed',
bottom: '10px',
left: '50%',
transform: 'translateX(-50%)',
background: '#E03C3F',
color: '#fff',
padding: '10px 18px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
zIndex: '9999',
boxShadow: '0 6px 16px rgba(0,0,0,.2)',
fontFamily: "Montserrat,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
});
document.body.appendChild(banner);
}
function checkOvertime() {
const now = new Date();
if (now > plannedEnd) showOvertimeBanner();
}
// Check à T0 (au cas où on lance en retard) puis toutes les 60s
checkOvertime();
setInterval(checkOvertime, 60000);
}
// Nettoyage
window.addEventListener('beforeunload', () => api.dispose());
})();
</script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
<?php
/**
* /nom_afficher.php
* Affiche le nom et prenom de la personne connecté lors de visio en Jitsi
*/
$displayName = 'Invité';
try {
if ($refId > 0 && in_array($role, ['praticien','client'], true)) {
// Adapte les noms de tables/colonnes si besoin
if ($role === 'praticien') {
$sql = "SELECT nom, prenom FROM praticien WHERE id = :id LIMIT 1";
} else { // client
$sql = "SELECT nom, prenom FROM client WHERE id = :id LIMIT 1";
}
$stmt = $pdo->prepare($sql);
$stmt->execute(['id' => $refId]);
if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$pn = trim(($row['prenom'] ?? '') . ' ' . ($row['nom'] ?? ''));
if ($pn !== '') {
$displayName = $pn;
} else {
$displayName = ($role === 'praticien') ? 'Praticien' : 'Client';
}
} else {
$displayName = ($role === 'praticien') ? 'Praticien' : 'Client';
}
}
} catch (Throwable $e) {
// En cas d’erreur, on garde un fallback propre
$displayName = ($role === 'praticien') ? 'Praticien' : 'Client';
}
// Optionnel: mémoriser pour d'autres pages
$_SESSION['display_name'] = $displayName;
// Sécuriser pour l'injecter côté JS
$displayName = htmlspecialchars($displayName, ENT_QUOTES, 'UTF-8');
$jitsiDomain = $config['jitsi_domain'];
+25
View File
@@ -0,0 +1,25 @@
<?php
/**
* Configuration du module Visio
* Adapte les chemins si tes includes sont ailleurs.
*/
require_once __DIR__ . '/../public/includes/init_pr_cl.php';
return [
// Domaine Jitsi public gratuit (pour MVP)
'jitsi_domain' => 'meet.uslugi.fr',
// Ouvrir la salle X minutes AVANT l'heure du RDV
'open_before_minutes' => 15,
// Fermer l'accès X minutes APRES l'heure du RDV
'close_after_minutes' => 120,
// Secret pour générer un slug de salle non devinable (change-le !)
'slug_secret' => getenv('JITSI_SLUG_SECRET') ?: '',
// Chemins vers tes includes du projet
'paths' => [
'db' => __DIR__ . '/../includes/db.php',
'securite' => __DIR__ . '/../public/includes/securite.php',
],
];
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* Retourne le room_slug pour un RDV.
* - Si existe en BD -> le retourne
* - Sinon, ne crée QUE si $allowCreate = true (praticien + start=1)
* Table attendue: visio_sessions (id, rendez_vous_id UNIQUE, room_slug, created_at)
*/
function visio_room_for_rdv(PDO $pdo, int $rdvId, string $secret, bool $allowCreate): ?string
{
// 1) Déjà créée ?
$sel = $pdo->prepare("SELECT room_slug FROM visio_sessions WHERE rendez_vous_id = ? LIMIT 1");
$sel->execute([$rdvId]);
$row = $sel->fetch(PDO::FETCH_ASSOC);
if ($row && !empty($row['room_slug'])) {
return $row['room_slug'];
}
if (!$allowCreate) {
return null; // patient avant ouverture par praticien
}
// 2) Créer un slug non prévisible
// -> stable pour la séance, mais non devinable
$raw = $rdvId . '|' . $secret . '|' . random_bytes(8);
$slug = 'uslugi-' . substr(hash('sha256', $raw), 0, 20);
// 3) Insérer (idempotent via UNIQUE(rendez_vous_id))
$ins = $pdo->prepare("
INSERT INTO visio_sessions (rendez_vous_id, room_slug)
VALUES (:r, :s)
ON DUPLICATE KEY UPDATE room_slug = VALUES(room_slug)
");
$ins->execute(['r' => $rdvId, 's' => $slug]);
// 4) Relire pour retourner la valeur
$sel->execute([$rdvId]);
$row = $sel->fetch(PDO::FETCH_ASSOC);
return $row['room_slug'] ?? null;
}