diff --git a/resources/views/email.phtml b/resources/views/email.phtml index 93c99fe..a8bcb17 100644 --- a/resources/views/email.phtml +++ b/resources/views/email.phtml @@ -24,53 +24,137 @@ use Illuminate\Support\Collection; * @var string $account_url */ -$avatar_size = 48; +// ─── Aesthetic constants ──────────────────────────────────────────────── +$palette = [ + 'paper' => '#f8f1e3', // warm ivory background + 'ink' => '#3a2820', // deep oxblood-brown body text + 'gold' => '#a17536', // aged-gold accents + 'rule' => '#d4be91', // hairline rule + 'mute' => '#7a6a5e', // muted secondary text + 'shadow' => '#5a4a3e', // for darker symbols (death) +]; + +$serif_display = "'EB Garamond', Georgia, 'Times New Roman', serif"; +$serif_body = "Georgia, 'Iowan Old Style', 'Palatino Linotype', serif"; + +$avatar_size = 56; + +// ─── Helpers ──────────────────────────────────────────────────────────── + +$linked_name = static function (Individual $individual): string { + $name = strip_tags($individual->fullName()); + $url = $individual->url(); + $style = 'color:inherit;text-decoration:none;border-bottom:1px solid #c8a96a;'; + + return '' . e($name) . ''; +}; + +$record_label = static function (Fact $fact) use ($linked_name): string { + $record = $fact->record(); + + if ($record instanceof Individual) { + return $linked_name($record); + } + + if ($record instanceof Family) { + $parts = []; + + foreach ([$record->husband(), $record->wife()] as $spouse) { + if ($spouse instanceof Individual) { + $parts[] = $linked_name($spouse); + } + } + + return implode(' & ', $parts); + } + + return e($record->xref()); +}; + +$event_date_display = static function (Fact $fact): string { + $date = $fact->date(); + + if (!$date instanceof Date || !$date->isOK()) { + return ''; + } + + return strip_tags($date->display()); +}; + +$event_kind = static function (Fact $fact): string { + $tag = $fact->tag(); + $parts = explode(':', $tag); + + return end($parts); +}; /** - * Inline HTML for a single circular avatar. - * - * Renders an if the dispatch service was able to - * resolve an image for the individual; otherwise renders a coloured - * circle with the person's initials. The placeholder is intentionally - * CSS-only — inline SVG and data: URIs are unreliable in Outlook / - * some webmail clients. + * Compact inline SVG glyph for each event type. Rendered in modern + * webmail (Gmail web, Apple Mail, iOS, Outlook 365); Outlook desktop + * strips SVG silently — the textual event label still carries the + * meaning, so the email reads correctly without it. */ -$avatar = static function (Individual|null $individual) use ($avatar_cids, $avatar_size): string { +$event_icon = static function (string $kind) use ($palette): string { + $svg_open = ' $svg_open . 'viewBox="0 0 24 24" width="18" height="18">' + . '' + . '', + + // Latin obelus/dagger — the typographic convention for "died" in + // obituaries since the 18th century. Restrained, narrow. + 'DEAT' => $svg_open . 'viewBox="0 0 24 24" width="14" height="18">' + . '' + . '' + . '', + + // Two interlocking rings — universal heraldic mark of marriage. + 'MARR' => $svg_open . 'viewBox="0 0 36 22" width="28" height="18">' + . '' + . '' + . '', + + default => '', + }; +}; + +/** + * A single circular avatar — either the embedded image, or a coloured + * disc with the person's initials. Wraps in an linking to the + * individual's webtrees page when available. + */ +$avatar = static function (Individual|null $individual) use ($avatar_cids, $avatar_size, $palette): string { if (!$individual instanceof Individual) { return ''; } $alt = e(strip_tags($individual->fullName())); - if (isset($avatar_cids[$individual->xref()])) { - $cid = $avatar_cids[$individual->xref()]; + $shadow = 'box-shadow:0 0 0 1px ' . $palette['rule'] . ',0 2px 6px rgba(58,40,32,0.18);'; - return '' . $alt . ''; + if (isset($avatar_cids[$individual->xref()])) { + $cid = $avatar_cids[$individual->xref()]; + $inner = '' . $alt . ''; + } else { + $hue = hexdec(substr(md5($individual->xref()), 0, 2)) * 360 / 255; + $first = strip_tags($individual->getAllNames()[0]['givn'] ?? $individual->xref()); + $last = strip_tags($individual->getAllNames()[0]['surn'] ?? ''); + $initials = e(mb_strtoupper(mb_substr($first, 0, 1) . mb_substr($last, 0, 1))); + + $inner = '' . $initials . ''; } - // CSS-only fallback: coloured circle with initials. Hash the xref - // into a stable hue so each person keeps the same colour across - // newsletters. - $hue = hexdec(substr(md5($individual->xref()), 0, 2)) * 360 / 255; - $first = strip_tags($individual->getAllNames()[0]['givn'] ?? $individual->xref()); - $last = strip_tags($individual->getAllNames()[0]['surn'] ?? ''); - $i1 = mb_substr($first, 0, 1); - $i2 = mb_substr($last, 0, 1); - $initials = e(mb_strtoupper($i1 . $i2)); - - return '' . $initials . ''; + return '' . $inner . ''; }; -/** - * HTML for the avatar(s) attached to a Fact's primary record: - * a single circle for Individual facts, side-by-side circles for - * Family facts (anniversaries). - */ $record_avatars = static function (Fact $fact) use ($avatar): string { $record = $fact->record(); @@ -87,55 +171,41 @@ $record_avatars = static function (Fact $fact) use ($avatar): string { } } - // Slight negative margin so the two circles overlap a touch — - // visually communicates "couple" without needing extra glue. - return '' - . implode('', $parts) - . ''; + // Two circles overlapping by a few px reads as "couple" without + // needing additional glue characters. + return '' + . '' + . '' + . (isset($parts[1]) + ? '' + : '') + . '
' . ($parts[0] ?? '') . '' . $parts[1] . '
'; } return ''; }; -$record_label = static function (Fact $fact): string { - $record = $fact->record(); - - if ($record instanceof Individual) { - return strip_tags($record->fullName()); +// Locale-aware ordinal: English uses st/nd/rd/th; German just appends ".". +$ordinal = static function (int $n): string { + if (str_starts_with(I18N::languageTag(), 'de')) { + return $n . '.'; } - if ($record instanceof Family) { - $husband = $record->husband(); - $wife = $record->wife(); - $names = array_filter([ - $husband !== null ? strip_tags($husband->fullName()) : '', - $wife !== null ? strip_tags($wife->fullName()) : '', - ]); + $abs = abs($n); + $mod100 = $abs % 100; - return implode(' & ', $names); + if ($mod100 >= 11 && $mod100 <= 13) { + return $n . 'th'; } - return $record->xref(); + return $n . match ($abs % 10) { + 1 => 'st', + 2 => 'nd', + 3 => 'rd', + default => 'th', + }; }; -$event_date = static function (Fact $fact): string { - $date = $fact->date(); - - if (!$date instanceof Date || !$date->isOK()) { - return ''; - } - - return strip_tags($date->display()); -}; - -/** - * Age the person/couple actually turns on the upcoming anniversary, not - * their current age. We use the fact's own year (which on an anniversary - * Fact is the year of the original event — birth or marriage) and the - * year of the upcoming Julian day stored on the Fact ($fact->jd) so the - * calculation handles people whose birthday falls before vs. after today - * uniformly. - */ $upcoming_age = static function (Fact $fact): int { static $gregorian = null; $gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar(); @@ -158,131 +228,227 @@ $upcoming_age = static function (Fact $fact): int { return max(0, $upcoming_year - $event_year); }; -/** - * Locale-aware ordinal. English uses st/nd/rd/th suffixes; German (and - * most other European languages we currently support) just appends a - * period to the digits. - */ -$ordinal = static function (int $n): string { - if (str_starts_with(I18N::languageTag(), 'de')) { - return $n . '.'; - } - - $abs = abs($n); - $mod100 = $abs % 100; - - if ($mod100 >= 11 && $mod100 <= 13) { - return $n . 'th'; - } - - return $n . match ($abs % 10) { - 1 => 'st', - 2 => 'nd', - 3 => 'rd', - default => 'th', - }; -}; - $birthday_label = static function (int $age) use ($ordinal): string { - if ($age <= 0) { - return I18N::translate('Birthday'); - } - - return I18N::translate('%s birthday', $ordinal($age)); + return $age > 0 + ? I18N::translate('%s birthday', $ordinal($age)) + : I18N::translate('Birthday'); }; $anniversary_label = static function (int $age) use ($ordinal): string { - if ($age <= 0) { - return I18N::translate('Wedding anniversary'); + return $age > 0 + ? I18N::translate('%s wedding anniversary', $ordinal($age)) + : I18N::translate('Wedding anniversary'); +}; + +// Styles reused across the event lists. +$row_padding = '20px 0'; +$divider_style = 'border-bottom:1px solid ' . $palette['rule'] . ';'; + +$section_title_style = 'margin:48px 0 8px;' + . 'font-family:' . $serif_display . ';' + . 'font-weight:500;font-size:28px;line-height:1.1;' + . 'color:' . $palette['ink'] . ';' + . 'letter-spacing:0.005em;'; + +$section_kicker_style = 'margin:0 0 22px;' + . 'font-family:' . $serif_display . ';' + . 'font-style:italic;font-size:15px;' + . 'color:' . $palette['mute'] . ';' + . 'letter-spacing:0.02em;'; + +$timeline_cell_style = 'width:170px;vertical-align:top;' + . 'padding:24px 0 24px 28px;' + . 'border-left:1px solid ' . $palette['rule'] . ';' + . 'font-family:' . $serif_body . ';' + . 'color:' . $palette['mute'] . ';' + . 'font-size:12px;letter-spacing:0.12em;text-transform:uppercase;' + . 'white-space:nowrap;'; + +$timeline_dot_style = 'display:inline-block;' + . 'width:11px;height:11px;background:' . $palette['gold'] . ';' + . 'border-radius:50%;' + . 'margin-left:-34px;margin-right:14px;vertical-align:middle;'; + +$content_cell_style = 'vertical-align:middle;' + . 'padding:24px 24px 24px 18px;' + . 'font-family:' . $serif_body . ';font-size:16px;line-height:1.4;' + . 'color:' . $palette['ink'] . ';'; + +$avatar_cell_style = 'width:64px;vertical-align:middle;padding:18px 0;'; + +// Renders one event row in the consistent 3-column layout shared by all +// sections (avatar | content | timeline + date). +/** + * Locale-aware long-form date for the masthead. German edition reads + * "15. Mai 2026" rather than the English "May 15, 2026". + */ +$masthead_date = static function (int $timestamp): string { + if (str_starts_with(I18N::languageTag(), 'de')) { + $months = [ + 1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', + ]; + + return (int) date('j', $timestamp) . '. ' + . $months[(int) date('n', $timestamp)] . ' ' + . date('Y', $timestamp); } - return I18N::translate('%s wedding anniversary', $ordinal($age)); + return date('F j, Y', $timestamp); }; +$event_row = static function (Fact $fact, string $body_html) + use ( + $record_avatars, + $event_date_display, + $avatar_cell_style, + $content_cell_style, + $timeline_cell_style, + $timeline_dot_style, + $divider_style, + ): string { + return '' + . '' . $record_avatars($fact) . '' + . '' . $body_html . '' + . '' + . '' + . e($event_date_display($fact)) + . '' + . ''; + }; + ?> + <?= e(I18N::translate('Family newsletter — %s', date('F j, Y', $generated_at))) ?> + - + -

- title()) ?> -

+ + + + +
-

- -

+ - + + + + -isEmpty()) : ?> -

-
    - - -
  • - - - - — - () - -
  • - -
- + + + + -isEmpty()) : ?> -

-
    - - -
  • - - - - — - () - -
  • - -
- + isEmpty()) : ?> + + -isEmpty()) : ?> -

-

- -

-
    - -
  • - - - - — label()) ?>: - -
  • - -
- + isEmpty()) : ?> + + -
-

- title())) ?> -
- ' . e(I18N::translate('My account')) . '', - ) ?> -

+ isEmpty()) : ?> + + + + + + +
+
+ +
+

+ title()) ?> +

+
+ +
+
+
+
+ +
+

+

+ +

+ + + ' + . '' . $record_label($fact) . '' + . ' — ' + . e($birthday_label($age)) . '' + . ''; + echo $event_row($fact, $body); + ?> + +
+
+

+

+ +

+ + + ' + . '' . $record_label($fact) . '' + . ' — ' + . e($anniversary_label($age)) . '' + . ''; + echo $event_row($fact, $body); + ?> + +
+
+

+

+ +

+ + + ' + . '' . $record_label($fact) . '' + . ' — ' + . e($fact->label()) . '' + . ''; + echo $event_row($fact, $body); + ?> + +
+
+
+

+ title())) ?> +
+ ' . e(I18N::translate('My account')) . '', + ) ?> +

+
+
diff --git a/src/Module.php b/src/Module.php index fd55f27..28930dd 100644 --- a/src/Module.php +++ b/src/Module.php @@ -118,6 +118,10 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf 'Cron token' => 'Cron-Token', 'Regenerate token' => 'Token neu generieren', 'Your subscription has been updated.' => 'Ihr Abonnement wurde aktualisiert.', + 'Family Chronicle' => 'Familienchronik', + 'Living kin who will celebrate this fortnight.' + => 'Lebende Verwandte, die in den nächsten zwei Wochen feiern.', + 'Marriages still intact.' => 'Noch bestehende Ehen.', '%s birthday' => '%s Geburtstag', '%s wedding anniversary' => '%s Hochzeitstag', 'Birthday' => 'Geburtstag',