Files
maria_uslugi/admin/tools/mailing_praticiens.php
2026-03-05 10:25:39 +01:00

365 lines
15 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/../../public/includes/init_admin.php';
const ADMIN_PRACTICIEN_ID = 88888888;
$me = (int)($_SESSION['reference_id'] ?? 0);
if ($me !== ADMIN_PRACTICIEN_ID) {
http_response_code(403);
echo '<div class="container py-5"><div class="alert alert-danger">'
. htmlspecialchars($translations['non_autorise'] ?? 'Доступ запрещён.', ENT_QUOTES, 'UTF-8')
. '</div></div>';
exit;
}
$mustReauth=function(int $ttl=600){$t=(int)($_SESSION['admin_reauth_at']??0);return ($t===0||(time()-$t)>$ttl);};
if ($mustReauth()){ $qs=http_build_query(['page'=>'reauth','redir'=>($_SERVER['REQUEST_URI']??'index.php?page=maintenance')]); header('Location:?'.$qs); exit; }
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$CSRF = $_SESSION['csrf_token'];
function e($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
// --------- Template HTML du mail ---------
function buildAdminMailHtml(string $subject, string $bodyHtml): string {
global $translations;
$brand = '<img src="https://uslugi.fr/public/uploads/img/logo_mail.png" width="150" alt="USLUGI.FR">';
$brandBas = $translations['mailing_praticiens_brand_bas'] ?? 'УСЛУГИ.ФР';
$ctaUrl = 'https://uslugi.fr/';
$ctaLabel = $translations['mailing_praticiens_cta_open_site'] ?? 'Открыть сайт';
$thanks = $translations['mailing_praticiens_footer_thanks'] ?? 'Спасибо';
return '<!DOCTYPE html><html lang="ru"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>'.e($subject).'</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:#fff;border-radius:8px;overflow:hidden;">
<tr>
<td style="background:#38378B;color:#fff;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.6;">'.$bodyHtml.'</td>
</tr>
<tr>
<td align="center" style="padding:8px 24px 24px 24px;">
<a href="'.e($ctaUrl).'" target="_blank"
style="display:inline-block;text-decoration:none;padding:12px 20px;border-radius:999px;background:#E03C3F;color:#fff;font-weight:600;">
'.e($ctaLabel).'
</a>
</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').' '.$brandBas.' — '.e($thanks).'</td>
</tr>
</table>
</td></tr>
</table>
</body></html>';
}
function sendPraticienMail(string $to, string $subject, string $html): bool {
// Optionnel: définir From/Reply-To ici, sinon mail_config.php fait foi
[$ok, $err] = uslugi_mail($to, $subject, $html, null, [
// 'from' => ['no-reply@uslugi.fr', 'USLUGI.FR'],
// 'reply_to' => ['support@uslugi.fr', 'USLUGI.FR'],
// 'headers' => ['X-USLUGI-Source' => 'admin_mailing_praticiens'],
]);
if (!$ok && $err) {
error_log('mailing_praticiens send error to '.$to.' : '.$err);
}
return $ok;
}
// --------- Traitement POST ---------
$flash = null;
if (($_POST['do'] ?? '') === 'send_mailing') {
try {
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) {
throw new Exception('CSRF');
}
$subject = trim($_POST['subject'] ?? '');
$body = trim($_POST['body'] ?? '');
$ids = $_POST['recipients'] ?? [];
if ($subject === '' || $body === '') {
throw new Exception('EMPTY');
}
if (!is_array($ids) || count($ids) === 0) {
throw new Exception('NO_RECIPIENTS');
}
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v)=>$v>0)));
if (!$ids) {
throw new Exception('NO_RECIPIENTS');
}
// Récupérer les emails
$in = implode(',', array_fill(0, count($ids), '?'));
$sql = "SELECT id, prenom, nom, email FROM praticien WHERE id IN ($in) AND email <> ''";
$st = $pdo->prepare($sql);
$st->execute($ids);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
if (!$rows) {
throw new Exception('NO_EMAILS');
}
$sent = 0;
$total = count($rows);
foreach ($rows as $r) {
$email = trim($r['email'] ?? '');
if ($email === '') continue;
// On laisse TinyMCE produire le HTML, on l’utilise tel quel
$html = buildAdminMailHtml($subject, $body);
if (sendPraticienMail($email, $subject, $html)) {
$sent++;
}
// usleep(100000); // 0.1s
}
$flash = [
'type' => 'success',
'msg' => ($translations['mailing_praticiens_sent_count'] ?? 'Отправлено писем: %d / %d')
? sprintf((string)($translations['mailing_praticiens_sent_count'] ?? 'Отправлено писем: %d / %d'), $sent, $total)
: ('Отправлено писем: '.$sent.' / '.$total)
];
} catch (Throwable $e) {
$code = $e->getMessage();
$msg = $translations['mailing_praticiens_err_send'] ?? 'Ошибка при отправке.';
if ($code === 'EMPTY') $msg = $translations['mailing_praticiens_err_empty'] ?? 'Тема и текст письма обязательны.';
if ($code === 'NO_RECIPIENTS') $msg = $translations['mailing_praticiens_err_no_recipients'] ?? 'Выберите хотя бы одного практикующего.';
if ($code === 'CSRF') $msg = $translations['mailing_praticiens_err_csrf'] ?? 'Ошибка безопасности. Обновите страницу.';
$flash = ['type'=>'danger','msg'=>$msg];
}
}
// --------- Récupération des praticiens ---------
$sql = "
SELECT
pr.id,
pr.nom,
pr.prenom,
pr.email,
pr.abonnement_id,
pr.date_expiration,
ab.nom AS abo_nom
FROM praticien pr
LEFT JOIN abonnement ab ON ab.id = pr.abonnement_id
ORDER BY pr.nom ASC, pr.prenom ASC
";
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
// Préparer options de filtre par abonnement
$aboOptions = [];
foreach ($rows as $r) {
if (!empty($r['abonnement_id'])) {
$aid = (int)$r['abonnement_id'];
$aboOptions[$aid] = $r['abo_nom'] ?: (($translations['mailing_praticiens_tarif_id'] ?? 'ID %d') ? sprintf((string)($translations['mailing_praticiens_tarif_id'] ?? 'ID %d'), $aid) : ('ID '.$aid));
}
}
ksort($aboOptions);
$today = new DateTimeImmutable('today');
?>
<div class="shadow p-4 rounded bg-light">
<h2 class="mb-3"><?= e($translations['mailing_praticiens_title'] ?? 'Массовая рассылка практикующим') ?></h2>
<?php if ($flash): ?>
<div class="alert alert-<?= e($flash['type']) ?>"><?= e($flash['msg']) ?></div>
<?php endif; ?>
<form method="post" id="mailingForm">
<input type="hidden" name="csrf" value="<?= e($CSRF) ?>">
<input type="hidden" name="do" value="send_mailing">
<div class="mb-3">
<label class="form-label"><?= e($translations['mailing_praticiens_subject_label'] ?? 'Тема письма') ?></label>
<input type="text" name="subject" class="form-control" required>
</div>
<div class="mb-4">
<label class="form-label"><?= e($translations['mailing_praticiens_body_label'] ?? 'Текст письма') ?></label>
<textarea id="mailBody" name="body" class="form-control" rows="8"></textarea>
<div class="form-text">
<?= e($translations['mailing_praticiens_body_help'] ?? 'Текст будет отправлен как HTML-письмо с шапкой USLUGI.FR и красной кнопкой «Открыть сайт».') ?>
</div>
</div>
<hr class="my-4">
<h4 class="mb-3"><?= e($translations['mailing_praticiens_list_title'] ?? 'Список практикующих') ?></h4>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label"><?= e($translations['mailing_praticiens_filter_status'] ?? 'Фильтр по статусу') ?></label>
<select id="filter_status" class="form-select">
<option value="all"><?= e($translations['mailing_praticiens_status_all'] ?? 'Все практикующие') ?></option>
<option value="active"><?= e($translations['mailing_praticiens_status_active'] ?? 'С активной подпиской') ?></option>
<option value="expired"><?= e($translations['mailing_praticiens_status_expired'] ?? 'С истекшей подпиской') ?></option>
<option value="noabo"><?= e($translations['mailing_praticiens_status_noabo'] ?? 'Без подписки') ?></option>
</select>
</div>
<div class="col-md-4">
<label class="form-label"><?= e($translations['mailing_praticiens_filter_tarif'] ?? 'Фильтр по тарифу') ?></label>
<select id="filter_abo" class="form-select">
<option value=""><?= e($translations['mailing_praticiens_tarif_all'] ?? 'Все тарифы') ?></option>
<?php foreach ($aboOptions as $aid => $name): ?>
<option value="<?= (int)$aid ?>">
<?= e($translations[$name] ?? $name) ?>
(<?= e(sprintf(
$translations['mailing_praticiens_tarif_id_label'] ?? 'ID %d',
(int)$aid
)) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label"><?= e($translations['mailing_praticiens_search_label'] ?? 'Поиск') ?></label>
<input type="text" id="filter_search" class="form-control" placeholder="<?= e($translations['mailing_praticiens_search_ph'] ?? 'Имя, фамилия, email...') ?>">
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="form-check">
<input type="checkbox" id="chk_all" class="form-check-input">
<label for="chk_all" class="form-check-label"><?= e($translations['mailing_praticiens_select_all_visible'] ?? 'Выбрать всех в текущем списке') ?></label>
</div>
<button type="submit" class="btn btn-danger" style="border-radius:999px;">
<?= e($translations['mailing_praticiens_send_btn'] ?? 'Отправить письмо выбранным') ?>
</button>
</div>
<div class="table-responsive">
<table class="table table-bordered table-hover align-middle" id="tabPraticiens">
<thead class="table-light">
<tr>
<th style="width:40px;"></th>
<th><?= e($translations['mailing_praticiens_th_prenom'] ?? 'Имя') ?></th>
<th><?= e($translations['mailing_praticiens_th_nom'] ?? 'Фамилия') ?></th>
<th><?= e($translations['mailing_praticiens_th_email'] ?? 'Email') ?></th>
<th><?= e($translations['mailing_praticiens_th_tarif'] ?? 'Тариф') ?></th>
<th><?= e($translations['mailing_praticiens_th_exp'] ?? 'Окончание') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $r):
$expRaw = $r['date_expiration'] ?? null;
$status = 'noabo';
$dateFmt = '';
if ($expRaw) {
try {
$exp = new DateTimeImmutable($expRaw);
$dateFmt = $exp->format('Y-m-d H:i');
$status = ($exp < $today) ? 'expired' : 'active';
} catch (Throwable $e) {
$status = 'noabo';
}
}
$hay = mb_strtolower(
trim(($r['prenom'] ?? '').' '.($r['nom'] ?? '').' '.($r['email'] ?? '').' '.($r['abo_nom'] ?? '')),
'UTF-8'
);
$aboId = (int)($r['abonnement_id'] ?? 0);
?>
<tr data-status="<?= e($status) ?>" data-abo="<?= $aboId ?: '' ?>" data-hay="<?= e($hay) ?>">
<td>
<input type="checkbox"
class="form-check-input row-chk"
name="recipients[]"
value="<?= (int)$r['id'] ?>">
</td>
<td><?= e($r['prenom']) ?></td>
<td><?= e($r['nom']) ?></td>
<td><?= e($r['email']) ?></td>
<td><?php $k=trim((string)($r['abo_nom']??'')); echo e($translations[$k] ?? $k); ?></td>
<td><?= e($dateFmt) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?>
<tr><td colspan="6" class="text-muted text-center"><?= e($translations['mailing_praticiens_none'] ?? 'Нет практикующих.') ?></td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</form>
</div>
<!-- TinyMCE -->
<script src="https://cdn.tiny.cloud/1/l9t0aozxqrwrd6cv3gbopqgt7bi299cjtwwxvyhylwa1kjey/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>
<script>
tinymce.init({
selector: '#mailBody',
menubar: false,
height: 260,
language: 'ru',
plugins: 'link lists',
toolbar: 'undo redo | bold italic underline | bullist numlist | link removeformat',
branding: false
});
// Filtres JS
(function(){
const statusSel = document.getElementById('filter_status');
const aboSel = document.getElementById('filter_abo');
const searchInp = document.getElementById('filter_search');
const rows = Array.from(document.querySelectorAll('#tabPraticiens tbody tr'));
const chkAll = document.getElementById('chk_all');
function applyFilters(){
const st = statusSel.value;
const abo = aboSel.value;
const q = (searchInp.value || '').trim().toLowerCase();
rows.forEach(tr => {
let ok = true;
const trStatus = tr.dataset.status || '';
const trAbo = tr.dataset.abo || '';
const hay = (tr.dataset.hay || '').toLowerCase();
if (st !== 'all' && trStatus !== st) ok = false;
if (abo && trAbo !== abo) ok = false;
if (q && !hay.includes(q)) ok = false;
tr.style.display = ok ? '' : 'none';
});
}
statusSel.addEventListener('change', applyFilters);
aboSel.addEventListener('change', applyFilters);
searchInp.addEventListener('input', applyFilters);
// Sélectionner tous les visibles
if (chkAll) {
chkAll.addEventListener('change', function(){
const checked = this.checked;
rows.forEach(tr => {
if (tr.style.display === 'none') return;
const cb = tr.querySelector('.row-chk');
if (cb) cb.checked = checked;
});
});
}
})();
</script>