8b6a0139db
Co-authored-by: Cursor <cursoragent@cursor.com>
317 lines
12 KiB
PHP
317 lines
12 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Forbion\Dav\Mail;
|
|
|
|
use Sabre\VObject\Component\VEvent;
|
|
|
|
final class InviteRenderer {
|
|
|
|
/**
|
|
* @param array{
|
|
* method: string,
|
|
* subject: string,
|
|
* data: array<string,mixed>,
|
|
* vevent: VEvent
|
|
* } $ctx
|
|
* @return array{subject?:string, html:string, text:string}
|
|
*/
|
|
public static function render(array $ctx): array {
|
|
$method = (string)($ctx['method'] ?? 'request');
|
|
$subject = (string)($ctx['subject'] ?? '');
|
|
$data = (array)($ctx['data'] ?? []);
|
|
/** @var VEvent $vevent */
|
|
$vevent = $ctx['vevent'];
|
|
|
|
// --- tokens (Figma) ---
|
|
$W = 564;
|
|
$border = '#D7D7D7';
|
|
$textMain = '#151515';
|
|
$textMuted = '#8A8A8A';
|
|
|
|
// --- brand/logo/icons ---
|
|
$logoUrl = (string)($data['logo_url'] ?? 'https://forbion.f7cloud.ru/themes/forbion/images/logo_gorizontal.png');
|
|
$brand = (string)($data['brand_name'] ?? 'Forbion F7');
|
|
|
|
$baseIcons = (string)($data['icons_base'] ?? 'https://forbion.f7cloud.ru/themes/forbion/images/');
|
|
$icons = [
|
|
'title' => (string)($data['icon_title'] ?? ($baseIcons . 'text.png')),
|
|
'when' => (string)($data['icon_when'] ?? ($baseIcons . 'date.png')),
|
|
'location' => (string)($data['icon_location'] ?? ($baseIcons . 'location.png')),
|
|
'description' => (string)($data['icon_description'] ?? ($baseIcons . 'notes.png')),
|
|
'link' => (string)($data['icon_link'] ?? ($baseIcons . 'text.png')),
|
|
'attendees' => (string)($data['icon_attendees'] ?? ($baseIcons . 'user.png')),
|
|
];
|
|
|
|
// --- main strings ---
|
|
$invitee = (string)($data['invitee_name'] ?? '');
|
|
$title = (string)($data['meeting_title'] ?? '');
|
|
|
|
$topLine = match (strtolower($method)) {
|
|
'cancel' => "{$invitee} отменил(а) событие «{$title}»",
|
|
'reply' => "{$invitee} ответил(а) на приглашение «{$title}»",
|
|
default => "{$invitee} приглашает вас принять участие в событии «{$title}»",
|
|
};
|
|
|
|
$heading = match (strtolower($method)) {
|
|
'cancel' => 'Отмена встречи',
|
|
'reply' => 'Ответ на приглашение',
|
|
default => 'Приглашение на встречу',
|
|
};
|
|
|
|
// fields
|
|
$when = (string)($data['meeting_when'] ?? '');
|
|
$locationText = (string)($data['meeting_location'] ?? '');
|
|
$locationHtml = (string)($data['meeting_location_html'] ?? htmlspecialchars($locationText, ENT_QUOTES));
|
|
|
|
$desc = (string)($data['meeting_description'] ?? '');
|
|
$linkText = (string)($data['meeting_url'] ?? '');
|
|
$linkHtml = (string)($data['meeting_url_html'] ?? htmlspecialchars($linkText, ENT_QUOTES));
|
|
|
|
// buttons
|
|
$acceptUrl = (string)($data['accept_url'] ?? '');
|
|
$declineUrl = (string)($data['decline_url'] ?? '');
|
|
$moreUrl = (string)($data['more_url'] ?? '');
|
|
|
|
$people = self::peopleLines($vevent);
|
|
|
|
$esc = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES);
|
|
|
|
// --- fonts: exactly like your site (TTF) ---
|
|
$fontCss = '
|
|
<style type="text/css">
|
|
@font-face {
|
|
font-family: "Raleway";
|
|
src: url("https://forbion.f7cloud.ru/themes/forbion/fonts/raleway-medium.ttf") format("truetype");
|
|
font-weight: 500;
|
|
font-style: normal;
|
|
font-display: swap;
|
|
}
|
|
@font-face {
|
|
font-family: "Raleway";
|
|
src: url("https://forbion.f7cloud.ru/themes/forbion/fonts/ralewayt.ttf") format("truetype");
|
|
font-weight: 600;
|
|
font-style: normal;
|
|
font-display: swap;
|
|
}
|
|
|
|
/* Global inside card */
|
|
.forbion-mail,
|
|
.forbion-mail * {
|
|
font-family: "Raleway", Arial, sans-serif !important;
|
|
font-variant-numeric: lining-nums;
|
|
}
|
|
|
|
/* Force links to look like text (esp. auto-linked emails) */
|
|
.forbion-mail a,
|
|
.forbion-mail a:visited,
|
|
.forbion-mail a:hover,
|
|
.forbion-mail a:active {
|
|
color: '.$textMain.' !important;
|
|
text-decoration: none !important;
|
|
}
|
|
|
|
/* if some client still converts to links */
|
|
.forbion-mail a[href^="mailto:"],
|
|
.forbion-mail a[x-apple-data-detectors],
|
|
.forbion-mail a[href^="tel:"],
|
|
.forbion-mail a[href^="sms:"] {
|
|
color: #151515 !important;
|
|
text-decoration: none !important;
|
|
border-bottom: 0 !important;
|
|
}
|
|
|
|
</style>';
|
|
|
|
$logoHtml = $logoUrl
|
|
? '<img src="'.$esc($logoUrl).'" alt="'.$esc($brand).'" style="display:block;margin:0 auto;border:0;outline:none;text-decoration:none;max-width:305px;height:auto;width:180px;">'
|
|
: '<div style="font-family:Raleway,Arial,sans-serif;font-weight:500;font-size:16px;line-height:20px;color:'.$textMain.';text-align:center;">'.$esc($brand).'</div>';
|
|
|
|
// Row builder: 12px between items, 4px between label/value
|
|
$row = function (string $iconUrl, string $label, string $valueHtml) use ($esc, $textMuted, $textMain): string {
|
|
if (trim(strip_tags($valueHtml)) === '') {
|
|
return '';
|
|
}
|
|
|
|
$icon = $iconUrl
|
|
? '<img src="'.$esc($iconUrl).'" width="20" height="20" alt="" style="display:block;width:20px;height:20px;border:0;outline:none;text-decoration:none;">'
|
|
: '';
|
|
|
|
return '
|
|
<tr>
|
|
<td style="padding:0 0 12px 0;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
|
<tr>
|
|
<td width="20" valign="top" style="padding:0 12px 0 0;">'.$icon.'</td>
|
|
<td valign="top" style="padding:0;margin:0;">
|
|
<div style="font-family:Raleway,Arial,sans-serif;font-weight:500;font-size:16px;line-height:20px;color:'.$textMuted.';margin:0 0 4px 0;padding:0;">'.$esc($label).'</div>
|
|
<div style="font-family:Raleway,Arial,sans-serif;font-weight:500;font-size:14px;line-height:20px;color:'.$textMain.';margin:0;padding:0;text-decoration:none;">
|
|
<span style="color:'.$textMain.';text-decoration:none;">'.$valueHtml.'</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>';
|
|
};
|
|
|
|
$peopleHtml = $people ? implode('<br>', array_map([self::class, 'escNoLinkify'], $people)) : '';
|
|
|
|
|
|
// Buttons: height 36, radius 8, padding 8/12, min-width 107
|
|
$buttonsHtml = '';
|
|
if ($acceptUrl && $declineUrl) {
|
|
$buttonsHtml = '
|
|
<tr>
|
|
<td style="padding:20px 16px 0 16px;text-align:center;">
|
|
<a href="'.$esc($acceptUrl).'"
|
|
style="display:inline-block;min-width:107px;height:36px;padding:8px 12px;
|
|
box-sizing:border-box;border-radius:8px;
|
|
background:#76B82A;color:#ffffff;text-decoration:none;
|
|
font-family:Raleway,Arial,sans-serif;font-weight:500;font-size:14px;line-height:20px;
|
|
text-align:center;vertical-align:middle;margin-right:12px;">
|
|
Принять
|
|
</a>
|
|
<a href="'.$esc($declineUrl).'"
|
|
style="display:inline-block;min-width:107px;height:36px;padding:8px 12px;
|
|
box-sizing:border-box;border-radius:8px;
|
|
background:#EFEFEF;color:'.$textMain.';text-decoration:none;
|
|
font-family:Raleway,Arial,sans-serif;font-weight:500;font-size:14px;line-height:20px;
|
|
text-align:center;vertical-align:middle;">
|
|
Отклонить
|
|
</a>
|
|
</td>
|
|
</tr>';
|
|
if ($moreUrl) {
|
|
$buttonsHtml .= '
|
|
<tr>
|
|
<td style="padding:12px 16px 0 16px;text-align:center;">
|
|
<a href="'.$esc($moreUrl).'"
|
|
style="font-family:Raleway,Arial,sans-serif;font-weight:500;font-size:14px;line-height:20px;
|
|
color:'.$textMain.';text-decoration:underline;">
|
|
Ещё варианты
|
|
</a>
|
|
</td>
|
|
</tr>';
|
|
}
|
|
}
|
|
|
|
// --- HTML ---
|
|
// Required paddings:
|
|
// inside card: top/bottom 32, sides 16
|
|
// logo -> topLine: 20
|
|
// topLine -> heading: 36
|
|
// heading -> list: 20
|
|
$html = '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'.$fontCss.'</head>
|
|
<body style="margin:0;padding:0;background:#ffffff;">'.$fontCss.'
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff;padding:0;margin:0;">
|
|
<tr>
|
|
<td align="center" style="padding:16px 0;">
|
|
<table role="presentation" class="forbion-mail" width="'.$W.'" cellpadding="0" cellspacing="0"
|
|
style="width:'.$W.'px;max-width:'.$W.'px;background:#ffffff;border:1px solid '.$border.';border-radius:8px;overflow:hidden;">
|
|
<tr>
|
|
<td style="padding:32px 16px 0 16px;">
|
|
'.$logoHtml.'
|
|
</td>
|
|
</tr>
|
|
|
|
<tr>
|
|
<td style="padding:20px 16px 0 16px;text-align:center;
|
|
font-family:Raleway,Arial,sans-serif;font-weight:600;font-size:24px;line-height:26px;letter-spacing:0;color:'.$textMain.';">
|
|
'.$esc($topLine).'
|
|
</td>
|
|
</tr>
|
|
|
|
<tr>
|
|
<td style="padding:36px 16px 0 16px;text-align:left;
|
|
font-family:Raleway,Arial,sans-serif;font-weight:500;font-size:24px;line-height:20px;letter-spacing:0;color:'.$textMain.';">
|
|
'.$esc($heading).'
|
|
</td>
|
|
</tr>
|
|
|
|
<tr>
|
|
<td style="padding:20px 16px 0 16px;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">'.
|
|
$row($icons['title'], 'Название', $esc($title)).
|
|
$row($icons['when'], 'Когда', $esc($when)).
|
|
$row($icons['location'], 'Местонахождение', $locationText ? $locationHtml : '').
|
|
$row($icons['description'], 'Описание', $esc($desc)).
|
|
$row($icons['link'], 'Ссылка', $linkText ? $linkHtml : '').
|
|
$row($icons['attendees'], 'Кто', $peopleHtml).
|
|
'</table>
|
|
</td>
|
|
</tr>
|
|
|
|
'.$buttonsHtml.'
|
|
|
|
<tr>
|
|
<td style="padding:20px 16px 32px 16px;text-align:center;
|
|
font-family:Raleway,Arial,sans-serif;font-weight:500;font-size:12px;line-height:18px;color:'.$textMuted.';">
|
|
Это автоматическое письмо календаря '.$esc($brand).'.
|
|
</td>
|
|
</tr>
|
|
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body></html>';
|
|
|
|
// --- TEXT ---
|
|
$text = $topLine . "\n\n" .
|
|
$heading . "\n" .
|
|
"Название: {$title}\n" .
|
|
($when ? "Когда: {$when}\n" : '') .
|
|
($locationText ? "Местонахождение: {$locationText}\n" : '') .
|
|
($desc ? "Описание: {$desc}\n" : '') .
|
|
($linkText ? "Ссылка: {$linkText}\n" : '');
|
|
|
|
if ($people) {
|
|
$text .= "\nКто:\n - " . implode("\n - ", $people) . "\n";
|
|
}
|
|
if ($acceptUrl && $declineUrl) {
|
|
$text .= "\nПринять: {$acceptUrl}\nОтклонить: {$declineUrl}\n";
|
|
if ($moreUrl) {
|
|
$text .= "Ещё варианты: {$moreUrl}\n";
|
|
}
|
|
}
|
|
|
|
return ['subject' => $subject, 'html' => $html, 'text' => $text];
|
|
}
|
|
|
|
/** @return string[] */
|
|
private static function peopleLines(VEvent $vevent): array {
|
|
$out = [];
|
|
|
|
if (isset($vevent->ORGANIZER)) {
|
|
$org = $vevent->ORGANIZER;
|
|
$orgEmail = self::mailtoToEmail((string)$org->getNormalizedValue());
|
|
$orgName = isset($org->CN) ? (string)$org->CN->getValue() : '';
|
|
$out[] = trim(($orgName ? $orgName.' ' : '').'<' . $orgEmail . '>') . ' (организатор)';
|
|
}
|
|
|
|
foreach ($vevent->select('ATTENDEE') as $att) {
|
|
$email = self::mailtoToEmail((string)$att->getNormalizedValue());
|
|
$name = isset($att['CN']) ? (string)$att['CN']->getValue() : '';
|
|
$line = trim(($name ? $name.' ' : '').'<' . $email . '>');
|
|
if ($line) {
|
|
$out[] = $line;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($out));
|
|
}
|
|
|
|
private static function mailtoToEmail(string $mailto): string {
|
|
return str_starts_with($mailto, 'mailto:') ? substr($mailto, 7) : $mailto;
|
|
}
|
|
|
|
private static function escNoLinkify(string $s): string {
|
|
$out = htmlspecialchars($s, ENT_QUOTES);
|
|
$out = str_replace('@', '@', $out);
|
|
$out = str_replace('.', '.', $out);
|
|
return $out;
|
|
}
|
|
|
|
}
|