letter_authority.php 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066
  1. <?php
  2. /**
  3. * Letter of Acceptance (LOA)
  4. * - Renders unsigned LOA from DB (with signature pad)
  5. * - Saves client signature, compiles dev+client signatures
  6. * - Generates + streams a PDF (no .html saved)
  7. * - Emails PDF to client + dev (as attachment)
  8. * - Supports GET ?download=signed to stream the latest signed PDF on demand
  9. */
  10. error_reporting(E_ALL);
  11. ini_set('display_errors', '1');
  12. date_default_timezone_set('Australia/Hobart');
  13. ini_set('default_charset', 'UTF-8');
  14. mb_internal_encoding('UTF-8');
  15. // ---------------------------------------------------------------------
  16. // Includes (adjust paths if your tree differs)
  17. // ---------------------------------------------------------------------
  18. require_once __DIR__ . '/../internal/connection.php'; // your DB connection helpers
  19. // require_once __DIR__ . '/database.php'; // your DB helpers (class or functions)
  20. require_once __DIR__ . '/dompdf/autoload.inc.php';
  21. use Dompdf\Dompdf;
  22. use Dompdf\Options;
  23. use PHPMailer\PHPMailer\PHPMailer;
  24. use PHPMailer\PHPMailer\SMTP;
  25. use PHPMailer\PHPMailer\Exception;
  26. require_once __DIR__ . '/../internal/phpmailer/src/Exception.php';
  27. require_once __DIR__ . '/../internal/phpmailer/src/PHPMailer.php';
  28. require_once __DIR__ . '/../internal/phpmailer/src/SMTP.php';
  29. // Optional: config.php for SMTP creds, branding, etc. (same keys you used for contract.php)
  30. $cfgFile = 'config.php';
  31. $cfg = @include $cfgFile;
  32. $cfg = is_array($cfg) ? $cfg : [];
  33. // Where signed PDFs live: ./contract relative to this PHP file
  34. define('CONTRACT_DIR', __DIR__ . '/contracts');
  35. if (!is_dir(CONTRACT_DIR)) {
  36. @mkdir(CONTRACT_DIR, 0775, true);
  37. }
  38. if (!is_writable(CONTRACT_DIR)) {
  39. // Optional: log instead of die in production
  40. die('Sorry PDF folder not writable: ' . CONTRACT_DIR);
  41. }
  42. // ---------------------------------------------------------------------
  43. // CSRF
  44. // ---------------------------------------------------------------------
  45. session_start();
  46. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  47. $ok = isset($_POST['csrf'], $_SESSION['csrf']) && hash_equals($_SESSION['csrf'], $_POST['csrf']);
  48. if (!$ok) {
  49. http_response_code(403);
  50. exit('Invalid CSRF token');
  51. }
  52. }
  53. if (empty($_SESSION['csrf'])) {
  54. $_SESSION['csrf'] = bin2hex(random_bytes(32));
  55. }
  56. $csrf = htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8');
  57. // ---------------------------------------------------------------------
  58. // Helpers
  59. // ---------------------------------------------------------------------
  60. function normalizeDrg(string $in): array {
  61. // Accept "3043", "3043.0", "3043.00", keep a few candidates to match DB reliably
  62. $s = trim($in);
  63. if ($s === '') return ['unknown', []];
  64. // integer part (before the first dot)
  65. $int = preg_replace('/\D+/', '', explode('.', $s, 2)[0] ?? '');
  66. if ($int === '') return [$s, [$s]];
  67. $variants = array_values(array_unique([
  68. $s, // as provided
  69. $int, // 3043
  70. $int . '.0', // 3043.0
  71. $int . '.00', // 3043.00
  72. ]));
  73. return [$int, $variants]; // return "3043" as canonical Job #, with variants to query
  74. }
  75. function getPdoSafe(array $cfg = []): PDO {
  76. $host = $cfg['db_host'] ?? getenv('DB_HOST') ?: 'localhost';
  77. $name = $cfg['db_name'] ?? getenv('DB_NAME') ?: 'client_jobs';
  78. $user = $cfg['db_username'] ?? getenv('DB_USER') ?: '';
  79. $pass = $cfg['db_password'] ?? getenv('DB_PASS') ?: '';
  80. $pdo = new PDO("mysql:host=$host;dbname=$name;charset=utf8mb4", $user, $pass, [
  81. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  82. PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  83. PDO::ATTR_EMULATE_PREPARES => false,
  84. ]);
  85. return $pdo;
  86. }
  87. function absUrlFor(array $params = []): string {
  88. $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
  89. $scheme = $https ? 'https' : 'https';
  90. $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
  91. $dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
  92. $base = $scheme . '://' . $host . ($dir ? $dir : '');
  93. $qs = http_build_query($params);
  94. return $base . '/' . basename(__FILE__) . ($qs ? ('?' . $qs) : '');
  95. }
  96. function clientExternalIp(array $trustedCidrs = []): string {
  97. $cf = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '';
  98. if ($cf && filter_var($cf, FILTER_VALIDATE_IP)) return $cf;
  99. $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
  100. $remote = $_SERVER['REMOTE_ADDR'] ?? '';
  101. if (!$xff) return $remote ?: 'UNKNOWN';
  102. // XFF may be a list "client, proxy1, proxy2"
  103. $parts = array_map('trim', explode(',', $xff));
  104. // Walk from leftmost; pick first that is not in a trusted range
  105. foreach ($parts as $ip) {
  106. if (!filter_var($ip, FILTER_VALIDATE_IP)) continue;
  107. if (!ipInCidrs($ip, $trustedCidrs)) {
  108. return $ip;
  109. }
  110. }
  111. // Fallback: last hop or REMOTE_ADDR
  112. return $parts[0] ?: ($remote ?: 'UNKNOWN');
  113. }
  114. function ipInCidrs(string $ip, array $cidrs): bool {
  115. foreach ($cidrs as $cidr) {
  116. [$subnet, $mask] = array_pad(explode('/', $cidr, 2), 2, null);
  117. if (!$subnet || $mask === null) continue;
  118. if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)
  119. && filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
  120. $ipLong = ip2long($ip);
  121. $subLong = ip2long($subnet);
  122. $maskLong = -1 << (32 - (int)$mask);
  123. if (($ipLong & $maskLong) === ($subLong & $maskLong)) return true;
  124. }
  125. }
  126. return false;
  127. }
  128. function salutationFromName(string $fullName): string {
  129. $name = trim(preg_replace('/\s+/', ' ', $fullName));
  130. if ($name === '') return 'there';
  131. $name = preg_replace(
  132. '/,?\s*(Jr\.?|Sr\.?|II|III|IV|MD|Ph\.?D|Esq\.?|J\.?D\.?|M\.?B\.?A\.?|RN|DDS|DMD)\s*$/i',
  133. '',
  134. $name
  135. );
  136. $tokens = preg_split('/\s+/', $name);
  137. if (!$tokens) return 'there';
  138. $titles = [
  139. 'mr', 'mrs', 'ms', 'miss', 'mx', 'dr', 'prof', 'sir', 'dame', 'lord', 'lady',
  140. 'hon', 'rev', 'fr', 'father', 'pastor', 'rabbi', 'imam', 'capt', 'cpt', 'gen',
  141. 'col', 'maj', 'sgt', 'officer', 'chief', 'coach', 'pres', 'sen', 'rep'
  142. ];
  143. $i = 0;
  144. while ($i < count($tokens) && in_array(strtolower(rtrim($tokens[$i], '.')), $titles, true)) $i++;
  145. while ($i < count($tokens) && preg_match('/^[A-Za-z]\.?$/', $tokens[$i])) $i++;
  146. return $i < count($tokens) ? $tokens[$i] : 'there';
  147. }
  148. // ---------------------------------------------------------------------
  149. // DB adapters (tweak SQL/columns to your schema)
  150. // ---------------------------------------------------------------------
  151. function fetchLoaFromDb(PDO $pdo, string $clientId, array $drgCandidates): array
  152. {
  153. // If no candidates (bad/missing drg), force empty result
  154. if (!$drgCandidates) return [];
  155. // Build a dynamic IN() that’s safe
  156. $ph = [];
  157. $params = [];
  158. foreach ($drgCandidates as $i => $c) {
  159. $ph[] = ':d' . $i;
  160. $params[':d' . $i] = $c;
  161. }
  162. $in = implode(',', $ph);
  163. // Pull what we need (adjust/extend columns if you add more later)
  164. // $sql = "SELECT * FROM details WHERE drg IN ($in) ORDER BY id DESC LIMIT 1";
  165. $sql = "
  166. SELECT d.*, a.*
  167. FROM details d
  168. LEFT JOIN addresses a ON d.drg = a.drg
  169. WHERE d.drg IN ($in)
  170. ORDER BY d.id DESC
  171. LIMIT 1
  172. ";
  173. $stmt = $pdo->prepare($sql);
  174. $stmt->execute($params);
  175. $job = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
  176. // Compose a clean display name (prefer joint_name, else First Last)
  177. $name = trim((string)($job['joint_name'] ?? ''));
  178. if ($name === '') {
  179. $first = trim((string)($job['firstname'] ?? ''));
  180. $last = trim((string)($job['lastname'] ?? ''));
  181. $name = trim($first . ' ' . $last);
  182. }
  183. // Minimal LOA body built from available fields
  184. $loaHtml = composeLoaHtmlFromJob($job);
  185. // Brand defaults from config
  186. $company = $GLOBALS['cfg']['dev_name'] ?? 'Modulos Design';
  187. $logoUrl = $GLOBALS['cfg']['dark_logo'] ?? '';
  188. // Prepared date: today
  189. $prepared = date('F j, Y');
  190. return [
  191. 'client_id' => $clientId, // canonical Job # like "3043"
  192. 'client_name' => $name,
  193. 'client_email' => (string)($job['client_email'] ?? ''),
  194. 'client_phone' => (string)($job['client_mobile'] ?? ''),
  195. 'client_address' => (string)($job['billing_address'] ?? ''),
  196. 'dev_name' => $GLOBALS['cfg']['dev_name'] ?? 'Modulos Design',
  197. 'dev_email' => $GLOBALS['cfg']['dev_email'] ?? 'drafting@modulosdesign.com.au',
  198. 'dev_phone' => $GLOBALS['cfg']['dev_phone'] ?? '',
  199. 'dev_address' => $GLOBALS['cfg']['dev_address'] ?? '',
  200. 'building_surveyor' => $GLOBALS['cfg']['building_surveyor'] ?? '',
  201. 'company' => $company,
  202. 'logo_url' => $logoUrl,
  203. 'prepared_date' => $prepared,
  204. 'loa_html' => $loaHtml,
  205. 'client_signature_png'=> $job['signature'] ?? null,
  206. 'client_signed_at' => $job['loa_signed'] ?? null,
  207. ];
  208. }
  209. /**
  210. * Compose the LOA body from job fields.
  211. */
  212. function composeLoaHtmlFromJob(array $j): string
  213. {
  214. $esc = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
  215. $firstname = $esc($j['firstname'] ?? '');
  216. $lastname = $esc($j['lastname'] ?? '');
  217. // Prefer joint_name; if empty, show "First Last"
  218. $jointRaw = trim((string)($j['joint_name'] ?? ''));
  219. if ($jointRaw === '') {
  220. $jointRaw = trim(($j['firstname'] ?? '') . ' ' . ($j['lastname'] ?? ''));
  221. }
  222. $joint_name = $esc($jointRaw);
  223. // Postal address: use postal_address if you have it; else billing_address
  224. $postal_address = $esc($j['postal_address'] ?? ($j['billing_address'] ?? ''));
  225. $client_mobile = $esc($j['client_mobile'] ?? '');
  226. $client_email = $esc($j['client_email'] ?? '');
  227. // Site address: use site_address if present; else fall back to locality
  228. $site_address = (string)($j['site_address'] ?? '');
  229. if ($site_address === '' && !empty($j['locality'])) {
  230. $site_address = (string)$j['locality'];
  231. }
  232. $site_address = $esc($site_address);
  233. $property_id = $esc($j['property_id'] ?? '');
  234. $title_id = $esc($j['title_id'] ?? '');
  235. $details = trim((string)($j['design_style'] ?? ''));
  236. $detailsHtml = $details !== '' ? nl2br($esc($details)) : '';
  237. $building_surveyor = $GLOBALS['cfg']['building_surveyor'] ?? '';
  238. $dev_name = $GLOBALS['cfg']['dev_name'] ?? '-';
  239. $dev_email = $GLOBALS['cfg']['dev_email'] ?? '-';
  240. $dev_phone = $GLOBALS['cfg']['dev_phone'] ?? '-';
  241. $dev_address = $GLOBALS['cfg']['dev_address'] ?? '-';
  242. return <<<HTML
  243. <div class="row mt-3">
  244. <div class="col">
  245. {$firstname} {$lastname}<br>
  246. {$joint_name}<br>
  247. {$postal_address}<br>
  248. </div>
  249. </div>
  250. <div class="row mt-3">
  251. <div class="col">
  252. <p>Attention, {$firstname}</p>
  253. </div>
  254. </div>
  255. <div class="row">
  256. <div class="col text-center">
  257. <h2 class="mb-0 fw-medium h2-title">LETTER OF APPOINTMENT</h2>
  258. <hr>
  259. </div>
  260. </div>
  261. <div class="row">
  262. <div class="col">
  263. <h4 class="h4-details">Project Details</h4>
  264. <table class="table table-bordered">
  265. <tbody>
  266. <tr>
  267. <th>Title Owner/s Name/s</th>
  268. <td contenteditable >{$joint_name}</td>
  269. </tr>
  270. <tr>
  271. <th>Title Owner/s Contact Details</th>
  272. <td>
  273. Postal Address: {$postal_address}<br>
  274. Ph: {$client_mobile}<br>
  275. Email: {$client_email}
  276. </td>
  277. </tr>
  278. <tr>
  279. <th>Project Address</th>
  280. <td>
  281. {$site_address}<br>
  282. PID: {$property_id}<br>
  283. Volume/Folio: {$title_id}
  284. </td>
  285. </tr>
  286. <tr>
  287. <th>Project Description</th>
  288. <td>{$detailsHtml}</td>
  289. </tr>
  290. <tr>
  291. <th>Contact Name and Phone Number</th>
  292. <td>
  293. {$dev_name}<br>
  294. {$dev_company}<br>
  295. {$dev_address}<br>
  296. {$dev_phone}<br>
  297. {$dev_email}<br>
  298. <br>
  299. </td>
  300. </tr>
  301. </tbody>
  302. </table>
  303. </div>
  304. </div>
  305. <div class="row">
  306. <div class="col">
  307. <!-- <p>We the undersigned being the lawful owner(s) of the above property, hereby appoint {$building_surveyor}. to carry out the duties of an accredited Building Surveyor in accordance with Section 29 of the Building Act 2016.</p> -->
  308. <p>We the undersigned, hereby authorise the above representative from Modulos Design to act as our lawful agent to sign and apply for all necessary certificates and permits on our behalf for the above project.</p>
  309. </div>
  310. </div>
  311. <hr>
  312. HTML;
  313. }
  314. /**
  315. * Save the client’s signature + metadata back to DB.
  316. */
  317. function saveLoaSignatureToDb(PDO $pdo, string $clientId, string $signatureDataUri, string $clientIp, string $clientTz, string $signedAtIso): void
  318. {
  319. // Only write the columns you actually have: signature + loa_signed
  320. $sql = "
  321. UPDATE details
  322. SET signature = :sig,
  323. loa_signed = :signed
  324. WHERE CAST(drg AS CHAR) = :drg1
  325. OR CAST(drg AS CHAR) = CONCAT(:drg2, '.0')
  326. OR CAST(drg AS CHAR) = CONCAT(:drg3, '.00')
  327. ";
  328. $pdo->prepare($sql)->execute([
  329. ':sig' => $signatureDataUri,
  330. ':signed'=> $signedAtIso,
  331. ':drg1' => $clientId,
  332. ':drg2' => $clientId,
  333. ':drg3' => $clientId,
  334. ]);
  335. }
  336. // ---------------------------------------------------------------------
  337. // Templating for on-screen unsigned view
  338. // ---------------------------------------------------------------------
  339. function headerWeb(string $title, string $clientId, string $prepared, string $logoUrl): string
  340. {
  341. $safeT = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
  342. $safeJ = htmlspecialchars($clientId, ENT_QUOTES, 'UTF-8');
  343. $safeD = htmlspecialchars($prepared, ENT_QUOTES, 'UTF-8');
  344. $safeL = $logoUrl
  345. ? '<img class="img-fluid pt-2" src="' . htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8') . '" style="max-height:48px" alt="Logo">'
  346. : '';
  347. $base = absUrlFor();
  348. return <<<HTML
  349. <!doctype html>
  350. <html lang="en">
  351. <head>
  352. <meta charset="utf-8">
  353. <title>{$safeJ} – {$safeT}</title>
  354. <meta name="viewport" content="width=device-width, initial-scale=1">
  355. <base href="{$base}">
  356. <meta name="robots" content="noindex">
  357. <link rel="shortcut icon" href="../internal/images/blueprint.ico" type="image/x-icon">
  358. <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet">
  359. <link href="../internal/css/blueprint.css" rel="stylesheet">
  360. <link href="../internal/css/print.css" rel="stylesheet" media="print">
  361. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
  362. <style>
  363. body { background:#f6f7fb }
  364. .container { max-width:860px }
  365. .signature-pad { border:1px solid #bbb; width:100%; height:120px; border-radius:4px }
  366. #canvas-container.just-signed { outline:3px solid #cfead9; outline-offset:2px }
  367. </style>
  368. </head>
  369. <body>
  370. <nav class="navbar bg-brown-dark brown-light border-bottom border-body d-print-none" data-bs-theme="dark">
  371. <div class="container-fluid">
  372. <a class="navbar-brand brown-light" href="#">
  373. <img src="../internal/images/blueprint-logo-light.png" alt="Modulos Design" width="30" height="24" class="d-inline-block align-text-top">
  374. Modulos Design
  375. </a>
  376. </div>
  377. </nav>
  378. <main class="container my-4">
  379. <div class="bg-white p-4 p-md-5 shadow-sm">
  380. <div class="row d-flex justify-content-between align-items-center mb-3">
  381. <div class="col-12 col-sm-6">{$safeL}</div>
  382. <div class="col-12 col-sm-6 text-center text-md-end">
  383. <div class="fw-bold">Job: {$safeJ}</div>
  384. <div class="text-secondary">{$safeD}</div>
  385. </div>
  386. </div>
  387. HTML;
  388. }
  389. function footerWeb(): string
  390. {
  391. return <<<HTML
  392. </div>
  393. </main>
  394. <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"></script>
  395. </body>
  396. </html>
  397. HTML;
  398. }
  399. // ---------------------------------------------------------------------
  400. // PDF compilation
  401. // ---------------------------------------------------------------------
  402. function buildSignedPdfHtml(
  403. string $clientId,
  404. string $preparedDate,
  405. string $company,
  406. string $loaHtml,
  407. string $devSigHtml,
  408. string $clientSigHtml
  409. ): string {
  410. $safeJob = htmlspecialchars($clientId, ENT_QUOTES, 'UTF-8');
  411. $safeDate = htmlspecialchars($preparedDate, ENT_QUOTES, 'UTF-8');
  412. $safeCo = htmlspecialchars($company, ENT_QUOTES, 'UTF-8');
  413. return <<<HTML
  414. <!doctype html>
  415. <html>
  416. <head>
  417. <meta charset="utf-8">
  418. <title>{$safeJob} – Signed LOA</title>
  419. <meta name="viewport" content="width=device-width, initial-scale=1">
  420. <meta name="robots" content="noindex">
  421. <link rel="shortcut icon" href="images/blueprint.ico" type="image/x-icon">
  422. <link href="css/blueprint.css" rel="stylesheet">
  423. <style>
  424. @page { margin: 5mm 10mm 10mm 10mm; }
  425. body { background:#fff; font-size:12px; }
  426. .container { max-width: 780px; margin: 0 auto; font-size:0.8rem; }
  427. .shadow-sm { box-shadow: none !important; }
  428. .rounded-3 { border-radius: 0 !important; }
  429. .bg-white { background: #fff !important; }
  430. .d-print-none, .noprint { display: none !important; }
  431. .img-logo { max-height: 40px; }
  432. .page-header { display: table; width: 100%; table-layout: fixed; }
  433. .page-header > .col-6 { display: table-cell; width: 45%; vertical-align: middle; padding: 0 8px; }
  434. .page-header .text-start { text-align: left; }
  435. .page-header .text-center { text-align: center; }
  436. .page-header .text-end { text-align: right; }
  437. .compiled-signatures { display: table; width: 50%; table-layout: fixed; margin-top: 1rem; }
  438. .compiled-signatures .compiled-signature { display: table-cell; width: 35%; vertical-align: bottom; padding: 0 8px; }
  439. .compiled-signatures img { max-width: 100%; height: auto; }
  440. .table { width: 100%; border-collapse: collapse; border: 1px solid #635A4A; }
  441. .table th, .table td { padding: 0.5rem; text-align: left; border: 1px solid #635A4A; }
  442. th { font-weight: 500; }
  443. h2 { color:#635A4A; text-align:center; font-size:1.75rem; font-weight:500; }
  444. h4 { font-size:1.2rem; margin-top:0; margin-bottom:0.5rem; font-weight:500; line-height:1.2; }
  445. hr { color:#635A4A; border:0; border-top:1.1px solid; opacity:100; }
  446. </style>
  447. </head>
  448. <body>
  449. <div class="container my-4">
  450. <div class="bg-white p-4 p-md-5 rounded-0 shadow-sm">
  451. <div class="row align-items-center page-header">
  452. <div class="col-6 text-start">
  453. <img class="img-fluid pt-2 img-logo" src="https://modulosdesign.com.au/internal/images/blueprint-full-logo-medium.png" height="100" alt="Modulos Design">
  454. </div>
  455. <div class="col-6 text-end pt-3">
  456. <h3 class="fw-bold mb-1" style="font-weight:bold;">Job: {$safeJob}</h3>
  457. <h4 class="mb-1"><span class="fw-bold text-secondary">{$safeDate}</span></h4>
  458. </div>
  459. </div>
  460. </div>
  461. <div class="content">
  462. {$loaHtml}
  463. </div>
  464. <div class="row compiled-signatures align-items-start">
  465. <div class="col-4 compiled-signature">{$clientSigHtml}</div>
  466. </div>
  467. </div>
  468. </body>
  469. </html>
  470. HTML;
  471. }
  472. // ---------------------------------------------------------------------
  473. // Email builder (14px everywhere, square button). Link points to ?download=signed
  474. // ---------------------------------------------------------------------
  475. function buildSignedLoaEmail(
  476. string $absoluteDownloadUrl,
  477. string $clientId,
  478. string $clientName,
  479. string $preparedDate,
  480. string $company,
  481. string $logoUrl
  482. ): array {
  483. $first = salutationFromName($clientName ?: '');
  484. $fSafe = htmlspecialchars($first, ENT_QUOTES, 'UTF-8');
  485. $safeUrl= htmlspecialchars($absoluteDownloadUrl, ENT_QUOTES, 'UTF-8');
  486. $safeCo = htmlspecialchars($company, ENT_QUOTES, 'UTF-8');
  487. $safeJob= htmlspecialchars($clientId, ENT_QUOTES, 'UTF-8');
  488. $prep = $preparedDate
  489. ? " (prepared " . htmlspecialchars($preparedDate, ENT_QUOTES, 'UTF-8') . ")"
  490. : '';
  491. $logo = $logoUrl
  492. ? '<img src="' . htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8') . '" alt="' . $safeCo . '" width="140" style="display:block;border:0;outline:none;text-decoration:none;height:auto;">'
  493. : '';
  494. $subject = "{$safeJob} – LOA signed";
  495. $html = <<<HTML
  496. <div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:0;line-height:0;">
  497. Thank you for signing your Letter of Acceptance — here’s your copy and access link.
  498. </div>
  499. <div style="background:#f6f7fb;padding:24px;font-size:14px;">
  500. <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" width="600"
  501. style="width:600px;max-width:100%;background:#ffffff;border-radius:8px;overflow:hidden;font-size:14px;font-family:Arial,Helvetica,sans-serif;">
  502. <tr>
  503. <td style="font-size:14px;padding:20px 24px;background:#D9CCC1;color:#ffffff;">
  504. <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
  505. <tr>
  506. <td>{$logo}</td>
  507. <td align="right" style="font-weight:700;font-size:14px;">Job #{$safeJob}</td>
  508. </tr>
  509. </table>
  510. </td>
  511. </tr>
  512. <tr>
  513. <td style="padding:28px 24px 8px;line-height:1.5;color:#635A4A;font-size:14px;">
  514. <div style="font-size:14px;margin-bottom:8px;">Hello {$fSafe},</div>
  515. <div style="font-size:14px;">
  516. Thank you for signing the Letter of Acceptance{$prep}. A PDF copy is attached, and you can view or download it anytime using the link below:
  517. </div>
  518. </td>
  519. </tr>
  520. <tr>
  521. <td align="center" style="padding:20px 24px 8px;font-size:14px;">
  522. <!--[if mso]>
  523. <v:rect xmlns:v="urn:schemas-microsoft-com:vml" href="{$safeUrl}" style="height:42px;v-text-anchor:middle;width:240px;" stroked="f" fillcolor="#635A4A">
  524. <w:anchorlock/>
  525. <center style="color:#ffffff;font-family:Arial,sans-serif;font-size:14px;">View LOA</center>
  526. </v:rect>
  527. <![endif]-->
  528. <!--[if !mso]><!-- -->
  529. <a href="{$safeUrl}"
  530. style="background:#635A4A;border-radius:0;display:inline-block;padding:12px 24px;color:#ffffff;text-decoration:none;font-weight:700;font-size:14px;mso-hide:all"
  531. target="_blank" rel="noopener">View LOA</a>
  532. <!--<![endif]-->
  533. </td>
  534. </tr>
  535. <tr>
  536. <td style="padding:8px 24px 24px;font-size:14px;line-height:1.6;color:#635A4A;">
  537. <div>
  538. If the button doesn’t work, copy and paste this link into your browser:<br>
  539. <span style="word-break:break-all;color:#635A4A;">{$safeUrl}</span>
  540. </div>
  541. <div style="font-size:14px;margin-top:18px;">Kind regards,<br>{$safeCo}</div>
  542. </td>
  543. </tr>
  544. <tr>
  545. <td style="padding:12px 24px;background:#28261E;color:#D9CCC1;font-size:12px;">
  546. This is an automated message. Please reply to this email if you have any questions.
  547. </td>
  548. </tr>
  549. </table>
  550. </div>
  551. HTML;
  552. $alt = "Hello {$first},\n\nThank you for signing the Letter of Acceptance{$prep}.\nView/download: {$absoluteDownloadUrl}\n\nKind regards,\n{$company}";
  553. return [$subject, $html, $alt];
  554. }
  555. // ---------------------------------------------------------------------
  556. // Dev and Client signature blocks
  557. // ---------------------------------------------------------------------
  558. function devSignatureHtml(string $sigUrlOrData, ?string $timestamp = null, ?string $ip = null, ?string $devName = null): string
  559. {
  560. $img = $sigUrlOrData
  561. ? '<img src="' . htmlspecialchars($sigUrlOrData, ENT_QUOTES, 'UTF-8') . '" style="max-height:117px;padding:10px;" alt="Signature">'
  562. : '';
  563. $name = $devName
  564. ? '<div class="label"><strong>' . htmlspecialchars($devName, ENT_QUOTES, 'UTF-8') . '</strong></div>'
  565. : '';
  566. $meta = '';
  567. if ($timestamp || $ip) {
  568. $meta .= '<div class="date-ip">';
  569. if ($timestamp) $meta .= '<div><strong>Signed on:</strong> ' . htmlspecialchars($timestamp, ENT_QUOTES, 'UTF-8') . '</div>';
  570. if ($ip) $meta .= '<div><strong>IP address:</strong> ' . htmlspecialchars($ip, ENT_QUOTES, 'UTF-8') . '</div>';
  571. $meta .= '</div>';
  572. }
  573. return $name . $img . $meta;
  574. }
  575. function clientSignatureHtml(string $sigDataUri, ?string $timestamp = null, ?string $ip = null, ?string $clientName = null): string
  576. {
  577. $img = '<img src="' . htmlspecialchars($sigDataUri, ENT_QUOTES, 'UTF-8') . '" alt="Client Signature">';
  578. $name = $clientName
  579. ? '<div class="label"><strong>' . htmlspecialchars($clientName, ENT_QUOTES, 'UTF-8') . '</strong></div>'
  580. : '';
  581. $meta = '';
  582. if ($timestamp || $ip) {
  583. $meta .= '<div class="date-ip">';
  584. if ($timestamp) $meta .= '<div><strong>Signed on:</strong> ' . htmlspecialchars($timestamp, ENT_QUOTES, 'UTF-8') . '</div>';
  585. if ($ip) $meta .= '<div><strong>Client IP:</strong> ' . htmlspecialchars($ip, ENT_QUOTES, 'UTF-8') . '</div>';
  586. $meta .= '</div>';
  587. }
  588. return $name . $img . $meta;
  589. }
  590. // ---------------------------------------------------------------------
  591. // Mail sender
  592. // ---------------------------------------------------------------------
  593. function sendSignedLoaEmails(array $cfg, string $fromAddress, array $row, string $pdfBinary): void
  594. {
  595. $clientEmail = trim((string)($row['client_email'] ?? ''));
  596. $devEmail = trim((string)($row['dev_email'] ?? ''));
  597. $clientEmail = filter_var($clientEmail, FILTER_VALIDATE_EMAIL) ?: '';
  598. $devEmail = filter_var($devEmail, FILTER_VALIDATE_EMAIL) ?: '';
  599. if (!$clientEmail && !$devEmail) return;
  600. $company = $row['company'] ?? ($cfg['dev_name'] ?? 'Modulos Design');
  601. $logoUrl = $row['logo_url'] ?? ($cfg['dark_logo'] ?? '');
  602. $clientId = (string)($row['client_id'] ?? '');
  603. $clientName= (string)($row['client_name'] ?? '');
  604. $prepared = (string)($row['prepared_date'] ?? date('F j, Y'));
  605. $dlUrl = absUrlFor(['drg' => $clientId, 'download' => 'signed']);
  606. [$subjC, $htmlC, $altC] = buildSignedLoaEmail($dlUrl, $clientId, $clientName, $prepared, $company, $logoUrl);
  607. [$subjD, $htmlD, $altD] = buildSignedLoaEmail($dlUrl, $clientId, $clientName, $prepared, $company, $logoUrl);
  608. $subjD = $clientId . ' – LOA signed!';
  609. $targets = [];
  610. if ($clientEmail) {
  611. $targets[] = [
  612. 'to' => $clientEmail,
  613. 'subject' => $subjC,
  614. 'html' => $htmlC,
  615. 'alt' => $altC,
  616. 'replyTo' => $devEmail ?: null,
  617. ];
  618. }
  619. if ($devEmail) {
  620. // add "Signed by" note
  621. $signedBy = htmlspecialchars($clientEmail ?: 'unknown', ENT_QUOTES, 'UTF-8');
  622. $inject = '<tr><td style="padding:4px 24px 0;font-size:14px;color:#444;">Signed by: ' . $signedBy . '</td></tr>';
  623. $htmlD = str_replace('</tr><tr>', '</tr>' . $inject . '<tr>', $htmlD);
  624. $targets[] = [
  625. 'to' => $devEmail,
  626. 'subject' => $subjD,
  627. 'html' => $htmlD,
  628. 'alt' => $altD . "\n\nSigned by: " . ($clientEmail ?: 'unknown'),
  629. 'replyTo' => $clientEmail ?: null,
  630. ];
  631. }
  632. foreach ($targets as $t) {
  633. $mail = new PHPMailer(true);
  634. $mail->SMTPDebug = SMTP::DEBUG_OFF;
  635. $mail->isSMTP();
  636. $mail->Host = $cfg['smtp_host'] ?? '';
  637. $mail->SMTPAuth = true;
  638. $mail->Username = $cfg['smtp_username'] ?? '';
  639. $mail->Password = $cfg['smtp_password'] ?? '';
  640. $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
  641. $mail->Port = $cfg['smtp_port'] ?? 465;
  642. $mail->CharSet = 'UTF-8';
  643. $mail->Encoding = 'base64';
  644. $mail->setFrom($cfg['from_address'] ?? $fromAddress ?? 'drafting@modulosdesign.com.au', $company);
  645. if (!empty($t['replyTo'])) $mail->addReplyTo($t['replyTo']);
  646. $mail->addAddress($t['to']);
  647. $mail->isHTML(true);
  648. $mail->Subject = $t['subject'];
  649. $mail->Body = $t['html'];
  650. if (!empty($t['alt'])) $mail->AltBody = $t['alt'];
  651. // Optional BCC + attachment
  652. $mail->addBCC('drafting@modulosdesign.com.au');
  653. // Attach the PDF from memory
  654. $mail->addStringAttachment($pdfBinary, $clientId . '_loa_signed.pdf', 'base64', 'application/pdf');
  655. try {
  656. $mail->send();
  657. } catch (Exception $e) {
  658. error_log("LOA email send error to {$t['to']}: {$mail->ErrorInfo}");
  659. }
  660. }
  661. }
  662. // ---------------------------------------------------------------------
  663. // MAIN FLOW
  664. // ---------------------------------------------------------------------
  665. $pdo = getPdoSafe($cfg);
  666. // $clientId = isset($_GET['clientid']) && preg_match('/^\d{1,10}$/', $_GET['clientid']) ? $_GET['clientid'] : 'unknown';
  667. $drgRaw = $_GET['drg'] ?? $_GET['clientid'] ?? '';
  668. [$clientId, $drgCandidates] = normalizeDrg($drgRaw); // $clientId becomes your Job # string (e.g. "3043")
  669. $trusted = isset($cfg['trusted_proxies']) && is_array($cfg['trusted_proxies']) ? $cfg['trusted_proxies'] : [];
  670. $row = fetchLoaFromDb($pdo, $clientId, $drgCandidates);
  671. // Developer signature image (URL or data:) from config
  672. $devSigUrl = '';
  673. if (!empty($cfg['dev_signature'])) {
  674. $devSigUrl = (string)$cfg['dev_signature']; // can be data: or absolute URL
  675. }
  676. // ---------------------------------------------------------------------
  677. // GET ?download=signed -> stream PDF of latest signed LOA from DB
  678. // ---------------------------------------------------------------------
  679. if (($_GET['download'] ?? '') === 'signed') {
  680. $filename = $clientId . '-LOA-Signed.pdf';
  681. $pdfPath = CONTRACT_DIR . '/' . $filename;
  682. // No file yet, build from DB
  683. if (empty($row['client_signature_png'])) {
  684. http_response_code(404);
  685. exit('No signed LOA on file.');
  686. }
  687. $clientSig = clientSignatureHtml(
  688. $row['client_signature_png'],
  689. !empty($row['client_signed_at']) ? date('F j, Y \a\t g:i:s A T', strtotime($row['client_signed_at'])) : null,
  690. $row['client_ip'] ?? null,
  691. $row['client_name'] ?? null
  692. );
  693. $devSig = devSignatureHtml($devSigUrl, null, null, $row['dev_name'] ?? null);
  694. $pdfHtml = buildSignedPdfHtml(
  695. $clientId,
  696. (string)$row['prepared_date'],
  697. (string)$row['company'],
  698. (string)$row['loa_html'],
  699. $devSig,
  700. $clientSig
  701. );
  702. $opts = new Options();
  703. $opts->set('defaultFont', 'Helvetica');
  704. $opts->set('isRemoteEnabled', true);
  705. $dompdf = new Dompdf($opts);
  706. $dompdf->loadHtml($pdfHtml, 'UTF-8');
  707. $dompdf->setPaper('A4', 'portrait');
  708. $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
  709. $dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\') . '/';
  710. $dompdf->setBasePath('https://' . $host . $dir);
  711. $dompdf->render();
  712. $pdf = $dompdf->output();
  713. // Save then stream
  714. @file_put_contents($pdfPath, $pdf, LOCK_EX);
  715. header('Content-Type: application/pdf');
  716. header('Content-Disposition: inline; filename="' . $filename . '"');
  717. header('Content-Length: ' . strlen($pdf));
  718. echo $pdf;
  719. exit;
  720. }
  721. // ---------------------------------------------------------------------
  722. // POST (client signing) -> save signature, email, stream PDF
  723. // ---------------------------------------------------------------------
  724. if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['client_signature'])) {
  725. $sig = (string)$_POST['client_signature'];
  726. if (!str_starts_with($sig, 'data:image/png;base64,')) {
  727. http_response_code(400);
  728. exit('Invalid signature format');
  729. }
  730. if (strlen($sig) > 2 * 1024 * 1024) { // 2MB limit for safety
  731. http_response_code(413);
  732. exit('Signature too large');
  733. }
  734. $clientTz = (string)($_POST['client_tz'] ?? '');
  735. $signedAtIso = (function ($clientTz) {
  736. try {
  737. if ($clientTz && in_array($clientTz, timezone_identifiers_list(), true)) {
  738. $tz = new DateTimeZone($clientTz);
  739. $dt = new DateTime('now', $tz);
  740. return $dt->format(DateTime::ATOM);
  741. }
  742. } catch (\Throwable $e) {}
  743. return gmdate('c');
  744. })($clientTz);
  745. $clientIp = clientExternalIp($trusted);
  746. // Persist signature to DB
  747. saveLoaSignatureToDb($pdo, $clientId, $sig, $clientIp, $clientTz, $signedAtIso);
  748. // Re-hydrate row with saved fields (optional)
  749. $row = fetchLoaFromDb($pdo, $clientId, $drgCandidates);
  750. // Build signature blocks
  751. $clientSig = clientSignatureHtml(
  752. $sig,
  753. date('F j, Y \a\t g:i:s A T', strtotime($signedAtIso)),
  754. $clientIp,
  755. $row['client_name'] ?? null
  756. );
  757. $devSignedAt = date('F j, Y \a\t g:i:s A T');
  758. $devIp = $_SERVER['SERVER_ADDR'] ?? 'UNKNOWN';
  759. $devSig = devSignatureHtml($devSigUrl, $devSignedAt, $devIp, $row['dev_name'] ?? null);
  760. // Compile PDF HTML
  761. $pdfHtml = buildSignedPdfHtml(
  762. $clientId,
  763. (string)$row['prepared_date'],
  764. (string)$row['company'],
  765. (string)$row['loa_html'],
  766. $devSig,
  767. $clientSig
  768. );
  769. // Render PDF
  770. $opts = new Options();
  771. $opts->set('defaultFont', 'Helvetica');
  772. $opts->set('isRemoteEnabled', true);
  773. $dompdf = new Dompdf($opts);
  774. $dompdf->loadHtml($pdfHtml, 'UTF-8');
  775. $dompdf->setPaper('A4', 'portrait');
  776. $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
  777. $dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\') . '/';
  778. $dompdf->setBasePath('https://' . $host . $dir);
  779. $dompdf->render();
  780. $pdf = $dompdf->output();
  781. // Email PDFs
  782. $from = $cfg['from_address'] ?? 'drafting@modulosdesign.com.au';
  783. sendSignedLoaEmails($cfg, $from, $row, $pdf);
  784. // Stream to browser
  785. header('Content-Type: application/pdf');
  786. header('Content-Disposition: inline; filename="' . $clientId . '-LOA-Signed.pdf"');
  787. header('Content-Length: ' . strlen($pdf));
  788. echo $pdf;
  789. exit;
  790. }
  791. // ---------------------------------------------------------------------
  792. // GET (no signature yet) -> show unsigned page with signature pad
  793. // ---------------------------------------------------------------------
  794. if (!headers_sent()) {
  795. header('Content-Type: text/html; charset=UTF-8');
  796. }
  797. echo headerWeb('Letter of Authority (Unsigned)', $clientId, (string)$row['prepared_date'], (string)$row['logo_url']);
  798. // LOA BODY (from DB)
  799. echo '<div id="content">';
  800. echo $row['loa_html']; // Assume this is trusted HTML from your system. If not, sanitize.
  801. echo '</div>';
  802. // Signature UI (no dev signature displayed here)
  803. $clientEmailSafe = htmlspecialchars((string)($row['client_email'] ?? ''), ENT_QUOTES, 'UTF-8');
  804. $csrfSafe = $csrf;
  805. ?>
  806. <form method="post" class="noprint" id="signature_form">
  807. <input type="hidden" name="csrf" value="<?=$csrfSafe?>">
  808. <input type="hidden" name="client_tz" value="">
  809. <input type="hidden" id="client_signature" name="client_signature">
  810. <div id="signature-container">
  811. <label class="form-label fw-semibold">Please sign below:</label>
  812. <div id="canvas-container">
  813. <canvas id="signature-pad" class="signature-pad"></canvas>
  814. </div>
  815. <div class="d-flex gap-2 justify-content-start mt-2">
  816. <button id="reset" type="button" class="btn btn-warning rounded-0">Clear</button>
  817. <button data-bs-toggle="modal" data-bs-target="#modal-qr" type="button" class="btn btn-secondary rounded-0">Sign on mobile</button>
  818. <button id="confirm" type="submit" class="btn btn-primary rounded-0" disabled>Sign & Download PDF</button>
  819. </div>
  820. <div class="form-text mt-2">
  821. After signing, a PDF will open in your browser and be emailed to you at
  822. <strong><?=$clientEmailSafe?></strong>.
  823. </div>
  824. </div>
  825. </form>
  826. <!-- Mobile QR modal -->
  827. <div class="modal fade" tabindex="-1" id="modal-qr" aria-labelledby="modal-qrLabel" aria-hidden="true">
  828. <div class="modal-dialog modal-dialog-centered">
  829. <div class="modal-content">
  830. <div class="modal-body">
  831. <button id="close-modal-qr" type="button" class="btn-close" data-bs-dismiss="modal-qr" aria-label="Close"></button>
  832. <canvas id="qr-code"></canvas>
  833. </div>
  834. </div>
  835. </div>
  836. </div>
  837. <?= footerWeb(); ?>
  838. <script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"></script>
  839. <script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
  840. <script>
  841. (function () {
  842. const canvas = document.getElementById('signature-pad');
  843. if (!canvas) return;
  844. const pad = new SignaturePad(canvas, {
  845. penColor: "hsl(200, 100%, 30%)",
  846. minDistance: 2
  847. });
  848. function resizeCanvas() {
  849. const ratio = Math.max(window.devicePixelRatio || 1, 1);
  850. canvas.width = canvas.offsetWidth * ratio;
  851. canvas.height = 120 * ratio;
  852. canvas.getContext('2d').scale(ratio, ratio);
  853. // restore if existing
  854. const data = localStorage.getItem('client_signature');
  855. if (data) {
  856. pad.fromDataURL(data);
  857. document.getElementById('client_signature').value = data;
  858. document.getElementById('confirm').disabled = false;
  859. }
  860. }
  861. window.addEventListener('resize', resizeCanvas);
  862. resizeCanvas();
  863. pad.addEventListener('afterUpdateStroke', () => {
  864. const data = pad.toDataURL('image/png');
  865. document.getElementById('client_signature').value = data;
  866. localStorage.setItem('client_signature', data);
  867. document.getElementById('confirm').disabled = false;
  868. });
  869. document.getElementById('reset').addEventListener('click', () => {
  870. pad.clear();
  871. localStorage.removeItem('client_signature');
  872. document.getElementById('client_signature').value = '';
  873. document.getElementById('confirm').disabled = true;
  874. });
  875. document.getElementById('signature_form').addEventListener('submit', (e) => {
  876. // allow natural submit
  877. document.getElementById('canvas-container').classList.add('just-signed');
  878. });
  879. // Set timezone hidden field
  880. try {
  881. document.querySelector('input[name="client_tz"]').value =
  882. Intl.DateTimeFormat().resolvedOptions().timeZone || '';
  883. } catch (e) {}
  884. // QR code for mobile signing (just points to this URL)
  885. const canvasQR = document.getElementById('qr-code');
  886. if (canvasQR && window.QRious) {
  887. new QRious({
  888. element: canvasQR,
  889. value: window.location.href,
  890. foreground: 'hsl(200, 30%, 20%)',
  891. padding: 0,
  892. size: 360
  893. });
  894. }
  895. // Basic modal fallback
  896. const modal = document.getElementById('modal-qr');
  897. const btnClose = document.getElementById('close-modal-qr');
  898. btnClose?.addEventListener('click', function () {
  899. try { modal.close(); } catch (e) { modal.removeAttribute('open'); }
  900. });
  901. modal?.addEventListener('click', function (e) {
  902. const r = modal.getBoundingClientRect();
  903. const inside = e.clientY >= r.top && e.clientY <= r.bottom && e.clientX >= r.left && e.clientX <= r.right;
  904. if (!inside) {
  905. try { modal.close(); } catch (err) { modal.removeAttribute('open'); }
  906. }
  907. });
  908. })();
  909. </script>