letter_authority.php 39 KB

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