| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066 |
- <?php
- /**
- * Letter of Acceptance (LOA)
- * - Renders unsigned LOA from DB (with signature pad)
- * - Saves client signature, compiles dev+client signatures
- * - Generates + streams a PDF (no .html saved)
- * - Emails PDF to client + dev (as attachment)
- * - Supports GET ?download=signed to stream the latest signed PDF on demand
- */
- error_reporting(E_ALL);
- ini_set('display_errors', '1');
- date_default_timezone_set('Australia/Hobart');
- ini_set('default_charset', 'UTF-8');
- mb_internal_encoding('UTF-8');
- // ---------------------------------------------------------------------
- // Includes (adjust paths if your tree differs)
- // ---------------------------------------------------------------------
- require_once __DIR__ . '/../internal/connection.php'; // your DB connection helpers
- // require_once __DIR__ . '/database.php'; // your DB helpers (class or functions)
- require_once __DIR__ . '/dompdf/autoload.inc.php';
- use Dompdf\Dompdf;
- use Dompdf\Options;
- use PHPMailer\PHPMailer\PHPMailer;
- use PHPMailer\PHPMailer\SMTP;
- use PHPMailer\PHPMailer\Exception;
- require_once __DIR__ . '/../internal/phpmailer/src/Exception.php';
- require_once __DIR__ . '/../internal/phpmailer/src/PHPMailer.php';
- require_once __DIR__ . '/../internal/phpmailer/src/SMTP.php';
- // Optional: config.php for SMTP creds, branding, etc. (same keys you used for contract.php)
- $cfgFile = 'config.php';
- $cfg = @include $cfgFile;
- $cfg = is_array($cfg) ? $cfg : [];
- // Where signed PDFs live: ./contract relative to this PHP file
- define('CONTRACT_DIR', __DIR__ . '/contracts');
- if (!is_dir(CONTRACT_DIR)) {
- @mkdir(CONTRACT_DIR, 0775, true);
- }
- if (!is_writable(CONTRACT_DIR)) {
- // Optional: log instead of die in production
- die('Sorry PDF folder not writable: ' . CONTRACT_DIR);
- }
- // ---------------------------------------------------------------------
- // CSRF
- // ---------------------------------------------------------------------
- session_start();
- if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- $ok = isset($_POST['csrf'], $_SESSION['csrf']) && hash_equals($_SESSION['csrf'], $_POST['csrf']);
- if (!$ok) {
- http_response_code(403);
- exit('Invalid CSRF token');
- }
- }
- if (empty($_SESSION['csrf'])) {
- $_SESSION['csrf'] = bin2hex(random_bytes(32));
- }
- $csrf = htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8');
- // ---------------------------------------------------------------------
- // Helpers
- // ---------------------------------------------------------------------
- function normalizeDrg(string $in): array {
- // Accept "3043", "3043.0", "3043.00", keep a few candidates to match DB reliably
- $s = trim($in);
- if ($s === '') return ['unknown', []];
- // integer part (before the first dot)
- $int = preg_replace('/\D+/', '', explode('.', $s, 2)[0] ?? '');
- if ($int === '') return [$s, [$s]];
- $variants = array_values(array_unique([
- $s, // as provided
- $int, // 3043
- $int . '.0', // 3043.0
- $int . '.00', // 3043.00
- ]));
- return [$int, $variants]; // return "3043" as canonical Job #, with variants to query
- }
- function getPdoSafe(array $cfg = []): PDO {
- $host = $cfg['db_host'] ?? getenv('DB_HOST') ?: 'localhost';
- $name = $cfg['db_name'] ?? getenv('DB_NAME') ?: 'client_jobs';
- $user = $cfg['db_username'] ?? getenv('DB_USER') ?: '';
- $pass = $cfg['db_password'] ?? getenv('DB_PASS') ?: '';
- $pdo = new PDO("mysql:host=$host;dbname=$name;charset=utf8mb4", $user, $pass, [
- PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
- PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
- PDO::ATTR_EMULATE_PREPARES => false,
- ]);
- return $pdo;
- }
- function absUrlFor(array $params = []): string {
- $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
- $scheme = $https ? 'https' : 'https';
- $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
- $dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
- $base = $scheme . '://' . $host . ($dir ? $dir : '');
- $qs = http_build_query($params);
- return $base . '/' . basename(__FILE__) . ($qs ? ('?' . $qs) : '');
- }
- function clientExternalIp(array $trustedCidrs = []): string {
- $cf = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '';
- if ($cf && filter_var($cf, FILTER_VALIDATE_IP)) return $cf;
- $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
- $remote = $_SERVER['REMOTE_ADDR'] ?? '';
- if (!$xff) return $remote ?: 'UNKNOWN';
- // XFF may be a list "client, proxy1, proxy2"
- $parts = array_map('trim', explode(',', $xff));
- // Walk from leftmost; pick first that is not in a trusted range
- foreach ($parts as $ip) {
- if (!filter_var($ip, FILTER_VALIDATE_IP)) continue;
- if (!ipInCidrs($ip, $trustedCidrs)) {
- return $ip;
- }
- }
- // Fallback: last hop or REMOTE_ADDR
- return $parts[0] ?: ($remote ?: 'UNKNOWN');
- }
- function ipInCidrs(string $ip, array $cidrs): bool {
- foreach ($cidrs as $cidr) {
- [$subnet, $mask] = array_pad(explode('/', $cidr, 2), 2, null);
- if (!$subnet || $mask === null) continue;
- if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)
- && filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
- $ipLong = ip2long($ip);
- $subLong = ip2long($subnet);
- $maskLong = -1 << (32 - (int)$mask);
- if (($ipLong & $maskLong) === ($subLong & $maskLong)) return true;
- }
- }
- return false;
- }
- function salutationFromName(string $fullName): string {
- $name = trim(preg_replace('/\s+/', ' ', $fullName));
- if ($name === '') return 'there';
- $name = preg_replace(
- '/,?\s*(Jr\.?|Sr\.?|II|III|IV|MD|Ph\.?D|Esq\.?|J\.?D\.?|M\.?B\.?A\.?|RN|DDS|DMD)\s*$/i',
- '',
- $name
- );
- $tokens = preg_split('/\s+/', $name);
- if (!$tokens) return 'there';
- $titles = [
- 'mr', 'mrs', 'ms', 'miss', 'mx', 'dr', 'prof', 'sir', 'dame', 'lord', 'lady',
- 'hon', 'rev', 'fr', 'father', 'pastor', 'rabbi', 'imam', 'capt', 'cpt', 'gen',
- 'col', 'maj', 'sgt', 'officer', 'chief', 'coach', 'pres', 'sen', 'rep'
- ];
- $i = 0;
- while ($i < count($tokens) && in_array(strtolower(rtrim($tokens[$i], '.')), $titles, true)) $i++;
- while ($i < count($tokens) && preg_match('/^[A-Za-z]\.?$/', $tokens[$i])) $i++;
- return $i < count($tokens) ? $tokens[$i] : 'there';
- }
- // ---------------------------------------------------------------------
- // DB adapters (tweak SQL/columns to your schema)
- // ---------------------------------------------------------------------
- function fetchLoaFromDb(PDO $pdo, string $clientId, array $drgCandidates): array
- {
- // If no candidates (bad/missing drg), force empty result
- if (!$drgCandidates) return [];
- // Build a dynamic IN() that’s safe
- $ph = [];
- $params = [];
- foreach ($drgCandidates as $i => $c) {
- $ph[] = ':d' . $i;
- $params[':d' . $i] = $c;
- }
- $in = implode(',', $ph);
- // Pull what we need (adjust/extend columns if you add more later)
- // $sql = "SELECT * FROM details WHERE drg IN ($in) ORDER BY id DESC LIMIT 1";
- $sql = "
- SELECT d.*, a.*
- FROM details d
- LEFT JOIN addresses a ON d.drg = a.drg
- WHERE d.drg IN ($in)
- ORDER BY d.id DESC
- LIMIT 1
- ";
- $stmt = $pdo->prepare($sql);
- $stmt->execute($params);
- $job = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
- // Compose a clean display name (prefer joint_name, else First Last)
- $name = trim((string)($job['joint_name'] ?? ''));
- if ($name === '') {
- $first = trim((string)($job['firstname'] ?? ''));
- $last = trim((string)($job['lastname'] ?? ''));
- $name = trim($first . ' ' . $last);
- }
- // Minimal LOA body built from available fields
- $loaHtml = composeLoaHtmlFromJob($job);
- // Brand defaults from config
- $company = $GLOBALS['cfg']['dev_name'] ?? 'Modulos Design';
- $logoUrl = $GLOBALS['cfg']['dark_logo'] ?? '';
- // Prepared date: today
- $prepared = date('F j, Y');
- return [
- 'client_id' => $clientId, // canonical Job # like "3043"
- 'client_name' => $name,
- 'client_email' => (string)($job['client_email'] ?? ''),
- 'client_phone' => (string)($job['client_mobile'] ?? ''),
- 'client_address' => (string)($job['billing_address'] ?? ''),
- 'dev_name' => $GLOBALS['cfg']['dev_name'] ?? 'Modulos Design',
- 'dev_email' => $GLOBALS['cfg']['dev_email'] ?? 'drafting@modulosdesign.com.au',
- 'dev_phone' => $GLOBALS['cfg']['dev_phone'] ?? '',
- 'dev_address' => $GLOBALS['cfg']['dev_address'] ?? '',
- 'building_surveyor' => $GLOBALS['cfg']['building_surveyor'] ?? '',
- 'company' => $company,
- 'logo_url' => $logoUrl,
- 'prepared_date' => $prepared,
- 'loa_html' => $loaHtml,
- 'client_signature_png'=> $job['signature'] ?? null,
- 'client_signed_at' => $job['loa_signed'] ?? null,
- ];
- }
- /**
- * Compose the LOA body from job fields.
- */
- function composeLoaHtmlFromJob(array $j): string
- {
- $esc = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
- $firstname = $esc($j['firstname'] ?? '');
- $lastname = $esc($j['lastname'] ?? '');
- // Prefer joint_name; if empty, show "First Last"
- $jointRaw = trim((string)($j['joint_name'] ?? ''));
- if ($jointRaw === '') {
- $jointRaw = trim(($j['firstname'] ?? '') . ' ' . ($j['lastname'] ?? ''));
- }
- $joint_name = $esc($jointRaw);
- // Postal address: use postal_address if you have it; else billing_address
- $postal_address = $esc($j['postal_address'] ?? ($j['billing_address'] ?? ''));
- $client_mobile = $esc($j['client_mobile'] ?? '');
- $client_email = $esc($j['client_email'] ?? '');
- // Site address: use site_address if present; else fall back to locality
- $site_address = (string)($j['site_address'] ?? '');
- if ($site_address === '' && !empty($j['locality'])) {
- $site_address = (string)$j['locality'];
- }
- $site_address = $esc($site_address);
- $property_id = $esc($j['property_id'] ?? '');
- $title_id = $esc($j['title_id'] ?? '');
- $details = trim((string)($j['design_style'] ?? ''));
- $detailsHtml = $details !== '' ? nl2br($esc($details)) : '';
- $building_surveyor = $GLOBALS['cfg']['building_surveyor'] ?? '';
- $dev_name = $GLOBALS['cfg']['dev_name'] ?? '-';
- $dev_email = $GLOBALS['cfg']['dev_email'] ?? '-';
- $dev_phone = $GLOBALS['cfg']['dev_phone'] ?? '-';
- $dev_address = $GLOBALS['cfg']['dev_address'] ?? '-';
- return <<<HTML
- <div class="row mt-3">
- <div class="col">
- {$firstname} {$lastname}<br>
- {$joint_name}<br>
- {$postal_address}<br>
- </div>
- </div>
- <div class="row mt-3">
- <div class="col">
- <p>Attention, {$firstname}</p>
- </div>
- </div>
- <div class="row">
- <div class="col text-center">
- <h2 class="mb-0 fw-medium h2-title">LETTER OF APPOINTMENT</h2>
- <hr>
- </div>
- </div>
- <div class="row">
- <div class="col">
- <h4 class="h4-details">Project Details</h4>
- <table class="table table-bordered">
- <tbody>
- <tr>
- <th>Title Owner/s Name/s</th>
- <td contenteditable >{$joint_name}</td>
- </tr>
- <tr>
- <th>Title Owner/s Contact Details</th>
- <td>
- Postal Address: {$postal_address}<br>
- Ph: {$client_mobile}<br>
- Email: {$client_email}
- </td>
- </tr>
- <tr>
- <th>Project Address</th>
- <td>
- {$site_address}<br>
- PID: {$property_id}<br>
- Volume/Folio: {$title_id}
- </td>
- </tr>
- <tr>
- <th>Project Description</th>
- <td>{$detailsHtml}</td>
- </tr>
- <tr>
- <th>Contact Name and Phone Number</th>
- <td>
- {$dev_name}<br>
- {$dev_company}<br>
- {$dev_address}<br>
- {$dev_phone}<br>
- {$dev_email}<br>
- <br>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- </div>
- <div class="row">
- <div class="col">
- <!-- <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> -->
- <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>
- </div>
- </div>
- <hr>
- HTML;
- }
- /**
- * Save the client’s signature + metadata back to DB.
- */
- function saveLoaSignatureToDb(PDO $pdo, string $clientId, string $signatureDataUri, string $clientIp, string $clientTz, string $signedAtIso): void
- {
- // Only write the columns you actually have: signature + loa_signed
- $sql = "
- UPDATE details
- SET signature = :sig,
- loa_signed = :signed
- WHERE CAST(drg AS CHAR) = :drg1
- OR CAST(drg AS CHAR) = CONCAT(:drg2, '.0')
- OR CAST(drg AS CHAR) = CONCAT(:drg3, '.00')
- ";
- $pdo->prepare($sql)->execute([
- ':sig' => $signatureDataUri,
- ':signed'=> $signedAtIso,
- ':drg1' => $clientId,
- ':drg2' => $clientId,
- ':drg3' => $clientId,
- ]);
- }
- // ---------------------------------------------------------------------
- // Templating for on-screen unsigned view
- // ---------------------------------------------------------------------
- function headerWeb(string $title, string $clientId, string $prepared, string $logoUrl): string
- {
- $safeT = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
- $safeJ = htmlspecialchars($clientId, ENT_QUOTES, 'UTF-8');
- $safeD = htmlspecialchars($prepared, ENT_QUOTES, 'UTF-8');
- $safeL = $logoUrl
- ? '<img class="img-fluid pt-2" src="' . htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8') . '" style="max-height:48px" alt="Logo">'
- : '';
- $base = absUrlFor();
- return <<<HTML
- <!doctype html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <title>{$safeJ} – {$safeT}</title>
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <base href="{$base}">
- <meta name="robots" content="noindex">
- <link rel="shortcut icon" href="../internal/images/blueprint.ico" type="image/x-icon">
- <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet">
- <link href="../internal/css/blueprint.css" rel="stylesheet">
- <link href="../internal/css/print.css" rel="stylesheet" media="print">
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
- <style>
- body { background:#f6f7fb }
- .container { max-width:860px }
- .signature-pad { border:1px solid #bbb; width:100%; height:120px; border-radius:4px }
- #canvas-container.just-signed { outline:3px solid #cfead9; outline-offset:2px }
- </style>
- </head>
- <body>
- <nav class="navbar bg-brown-dark brown-light border-bottom border-body d-print-none" data-bs-theme="dark">
- <div class="container-fluid">
- <a class="navbar-brand brown-light" href="#">
- <img src="../internal/images/blueprint-logo-light.png" alt="Modulos Design" width="30" height="24" class="d-inline-block align-text-top">
- Modulos Design
- </a>
- </div>
- </nav>
- <main class="container my-4">
- <div class="bg-white p-4 p-md-5 shadow-sm">
- <div class="row d-flex justify-content-between align-items-center mb-3">
- <div class="col-12 col-sm-6">{$safeL}</div>
- <div class="col-12 col-sm-6 text-center text-md-end">
- <div class="fw-bold">Job: {$safeJ}</div>
- <div class="text-secondary">{$safeD}</div>
- </div>
- </div>
- HTML;
- }
- function footerWeb(): string
- {
- return <<<HTML
- </div>
- </main>
- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"></script>
- </body>
- </html>
- HTML;
- }
- // ---------------------------------------------------------------------
- // PDF compilation
- // ---------------------------------------------------------------------
- function buildSignedPdfHtml(
- string $clientId,
- string $preparedDate,
- string $company,
- string $loaHtml,
- string $devSigHtml,
- string $clientSigHtml
- ): string {
- $safeJob = htmlspecialchars($clientId, ENT_QUOTES, 'UTF-8');
- $safeDate = htmlspecialchars($preparedDate, ENT_QUOTES, 'UTF-8');
- $safeCo = htmlspecialchars($company, ENT_QUOTES, 'UTF-8');
- return <<<HTML
- <!doctype html>
- <html>
- <head>
- <meta charset="utf-8">
- <title>{$safeJob} – Signed LOA</title>
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <meta name="robots" content="noindex">
- <link rel="shortcut icon" href="images/blueprint.ico" type="image/x-icon">
- <link href="css/blueprint.css" rel="stylesheet">
- <style>
- @page { margin: 5mm 10mm 10mm 10mm; }
- body { background:#fff; font-size:12px; }
- .container { max-width: 780px; margin: 0 auto; font-size:0.8rem; }
- .shadow-sm { box-shadow: none !important; }
- .rounded-3 { border-radius: 0 !important; }
- .bg-white { background: #fff !important; }
- .d-print-none, .noprint { display: none !important; }
- .img-logo { max-height: 40px; }
- .page-header { display: table; width: 100%; table-layout: fixed; }
- .page-header > .col-6 { display: table-cell; width: 45%; vertical-align: middle; padding: 0 8px; }
- .page-header .text-start { text-align: left; }
- .page-header .text-center { text-align: center; }
- .page-header .text-end { text-align: right; }
- .compiled-signatures { display: table; width: 50%; table-layout: fixed; margin-top: 1rem; }
- .compiled-signatures .compiled-signature { display: table-cell; width: 35%; vertical-align: bottom; padding: 0 8px; }
- .compiled-signatures img { max-width: 100%; height: auto; }
- .table { width: 100%; border-collapse: collapse; border: 1px solid #635A4A; }
- .table th, .table td { padding: 0.5rem; text-align: left; border: 1px solid #635A4A; }
- th { font-weight: 500; }
- h2 { color:#635A4A; text-align:center; font-size:1.75rem; font-weight:500; }
- h4 { font-size:1.2rem; margin-top:0; margin-bottom:0.5rem; font-weight:500; line-height:1.2; }
- hr { color:#635A4A; border:0; border-top:1.1px solid; opacity:100; }
- </style>
- </head>
- <body>
- <div class="container my-4">
- <div class="bg-white p-4 p-md-5 rounded-0 shadow-sm">
- <div class="row align-items-center page-header">
- <div class="col-6 text-start">
- <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">
- </div>
- <div class="col-6 text-end pt-3">
- <h3 class="fw-bold mb-1" style="font-weight:bold;">Job: {$safeJob}</h3>
- <h4 class="mb-1"><span class="fw-bold text-secondary">{$safeDate}</span></h4>
- </div>
- </div>
- </div>
- <div class="content">
- {$loaHtml}
- </div>
- <div class="row compiled-signatures align-items-start">
- <div class="col-4 compiled-signature">{$clientSigHtml}</div>
- </div>
- </div>
- </body>
- </html>
- HTML;
- }
- // ---------------------------------------------------------------------
- // Email builder (14px everywhere, square button). Link points to ?download=signed
- // ---------------------------------------------------------------------
- function buildSignedLoaEmail(
- string $absoluteDownloadUrl,
- string $clientId,
- string $clientName,
- string $preparedDate,
- string $company,
- string $logoUrl
- ): array {
- $first = salutationFromName($clientName ?: '');
- $fSafe = htmlspecialchars($first, ENT_QUOTES, 'UTF-8');
- $safeUrl= htmlspecialchars($absoluteDownloadUrl, ENT_QUOTES, 'UTF-8');
- $safeCo = htmlspecialchars($company, ENT_QUOTES, 'UTF-8');
- $safeJob= htmlspecialchars($clientId, ENT_QUOTES, 'UTF-8');
- $prep = $preparedDate
- ? " (prepared " . htmlspecialchars($preparedDate, ENT_QUOTES, 'UTF-8') . ")"
- : '';
- $logo = $logoUrl
- ? '<img src="' . htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8') . '" alt="' . $safeCo . '" width="140" style="display:block;border:0;outline:none;text-decoration:none;height:auto;">'
- : '';
- $subject = "{$safeJob} – LOA signed";
- $html = <<<HTML
- <div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:0;line-height:0;">
- Thank you for signing your Letter of Acceptance — here’s your copy and access link.
- </div>
- <div style="background:#f6f7fb;padding:24px;font-size:14px;">
- <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" width="600"
- style="width:600px;max-width:100%;background:#ffffff;border-radius:8px;overflow:hidden;font-size:14px;font-family:Arial,Helvetica,sans-serif;">
- <tr>
- <td style="font-size:14px;padding:20px 24px;background:#D9CCC1;color:#ffffff;">
- <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
- <tr>
- <td>{$logo}</td>
- <td align="right" style="font-weight:700;font-size:14px;">Job #{$safeJob}</td>
- </tr>
- </table>
- </td>
- </tr>
- <tr>
- <td style="padding:28px 24px 8px;line-height:1.5;color:#635A4A;font-size:14px;">
- <div style="font-size:14px;margin-bottom:8px;">Hello {$fSafe},</div>
- <div style="font-size:14px;">
- 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:
- </div>
- </td>
- </tr>
- <tr>
- <td align="center" style="padding:20px 24px 8px;font-size:14px;">
- <!--[if mso]>
- <v:rect xmlns:v="urn:schemas-microsoft-com:vml" href="{$safeUrl}" style="height:42px;v-text-anchor:middle;width:240px;" stroked="f" fillcolor="#635A4A">
- <w:anchorlock/>
- <center style="color:#ffffff;font-family:Arial,sans-serif;font-size:14px;">View LOA</center>
- </v:rect>
- <![endif]-->
- <!--[if !mso]><!-- -->
- <a href="{$safeUrl}"
- 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"
- target="_blank" rel="noopener">View LOA</a>
- <!--<![endif]-->
- </td>
- </tr>
- <tr>
- <td style="padding:8px 24px 24px;font-size:14px;line-height:1.6;color:#635A4A;">
- <div>
- If the button doesn’t work, copy and paste this link into your browser:<br>
- <span style="word-break:break-all;color:#635A4A;">{$safeUrl}</span>
- </div>
- <div style="font-size:14px;margin-top:18px;">Kind regards,<br>{$safeCo}</div>
- </td>
- </tr>
- <tr>
- <td style="padding:12px 24px;background:#28261E;color:#D9CCC1;font-size:12px;">
- This is an automated message. Please reply to this email if you have any questions.
- </td>
- </tr>
- </table>
- </div>
- HTML;
- $alt = "Hello {$first},\n\nThank you for signing the Letter of Acceptance{$prep}.\nView/download: {$absoluteDownloadUrl}\n\nKind regards,\n{$company}";
- return [$subject, $html, $alt];
- }
- // ---------------------------------------------------------------------
- // Dev and Client signature blocks
- // ---------------------------------------------------------------------
- function devSignatureHtml(string $sigUrlOrData, ?string $timestamp = null, ?string $ip = null, ?string $devName = null): string
- {
- $img = $sigUrlOrData
- ? '<img src="' . htmlspecialchars($sigUrlOrData, ENT_QUOTES, 'UTF-8') . '" style="max-height:117px;padding:10px;" alt="Signature">'
- : '';
- $name = $devName
- ? '<div class="label"><strong>' . htmlspecialchars($devName, ENT_QUOTES, 'UTF-8') . '</strong></div>'
- : '';
- $meta = '';
- if ($timestamp || $ip) {
- $meta .= '<div class="date-ip">';
- if ($timestamp) $meta .= '<div><strong>Signed on:</strong> ' . htmlspecialchars($timestamp, ENT_QUOTES, 'UTF-8') . '</div>';
- if ($ip) $meta .= '<div><strong>IP address:</strong> ' . htmlspecialchars($ip, ENT_QUOTES, 'UTF-8') . '</div>';
- $meta .= '</div>';
- }
- return $name . $img . $meta;
- }
- function clientSignatureHtml(string $sigDataUri, ?string $timestamp = null, ?string $ip = null, ?string $clientName = null): string
- {
- $img = '<img src="' . htmlspecialchars($sigDataUri, ENT_QUOTES, 'UTF-8') . '" alt="Client Signature">';
- $name = $clientName
- ? '<div class="label"><strong>' . htmlspecialchars($clientName, ENT_QUOTES, 'UTF-8') . '</strong></div>'
- : '';
- $meta = '';
- if ($timestamp || $ip) {
- $meta .= '<div class="date-ip">';
- if ($timestamp) $meta .= '<div><strong>Signed on:</strong> ' . htmlspecialchars($timestamp, ENT_QUOTES, 'UTF-8') . '</div>';
- if ($ip) $meta .= '<div><strong>Client IP:</strong> ' . htmlspecialchars($ip, ENT_QUOTES, 'UTF-8') . '</div>';
- $meta .= '</div>';
- }
- return $name . $img . $meta;
- }
- // ---------------------------------------------------------------------
- // Mail sender
- // ---------------------------------------------------------------------
- function sendSignedLoaEmails(array $cfg, string $fromAddress, array $row, string $pdfBinary): void
- {
- $clientEmail = trim((string)($row['client_email'] ?? ''));
- $devEmail = trim((string)($row['dev_email'] ?? ''));
- $clientEmail = filter_var($clientEmail, FILTER_VALIDATE_EMAIL) ?: '';
- $devEmail = filter_var($devEmail, FILTER_VALIDATE_EMAIL) ?: '';
- if (!$clientEmail && !$devEmail) return;
- $company = $row['company'] ?? ($cfg['dev_name'] ?? 'Modulos Design');
- $logoUrl = $row['logo_url'] ?? ($cfg['dark_logo'] ?? '');
- $clientId = (string)($row['client_id'] ?? '');
- $clientName= (string)($row['client_name'] ?? '');
- $prepared = (string)($row['prepared_date'] ?? date('F j, Y'));
- $dlUrl = absUrlFor(['drg' => $clientId, 'download' => 'signed']);
- [$subjC, $htmlC, $altC] = buildSignedLoaEmail($dlUrl, $clientId, $clientName, $prepared, $company, $logoUrl);
- [$subjD, $htmlD, $altD] = buildSignedLoaEmail($dlUrl, $clientId, $clientName, $prepared, $company, $logoUrl);
- $subjD = $clientId . ' – LOA signed!';
- $targets = [];
- if ($clientEmail) {
- $targets[] = [
- 'to' => $clientEmail,
- 'subject' => $subjC,
- 'html' => $htmlC,
- 'alt' => $altC,
- 'replyTo' => $devEmail ?: null,
- ];
- }
- if ($devEmail) {
- // add "Signed by" note
- $signedBy = htmlspecialchars($clientEmail ?: 'unknown', ENT_QUOTES, 'UTF-8');
- $inject = '<tr><td style="padding:4px 24px 0;font-size:14px;color:#444;">Signed by: ' . $signedBy . '</td></tr>';
- $htmlD = str_replace('</tr><tr>', '</tr>' . $inject . '<tr>', $htmlD);
- $targets[] = [
- 'to' => $devEmail,
- 'subject' => $subjD,
- 'html' => $htmlD,
- 'alt' => $altD . "\n\nSigned by: " . ($clientEmail ?: 'unknown'),
- 'replyTo' => $clientEmail ?: null,
- ];
- }
- foreach ($targets as $t) {
- $mail = new PHPMailer(true);
- $mail->SMTPDebug = SMTP::DEBUG_OFF;
- $mail->isSMTP();
- $mail->Host = $cfg['smtp_host'] ?? '';
- $mail->SMTPAuth = true;
- $mail->Username = $cfg['smtp_username'] ?? '';
- $mail->Password = $cfg['smtp_password'] ?? '';
- $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
- $mail->Port = $cfg['smtp_port'] ?? 465;
- $mail->CharSet = 'UTF-8';
- $mail->Encoding = 'base64';
- $mail->setFrom($cfg['from_address'] ?? $fromAddress ?? 'drafting@modulosdesign.com.au', $company);
- if (!empty($t['replyTo'])) $mail->addReplyTo($t['replyTo']);
- $mail->addAddress($t['to']);
- $mail->isHTML(true);
- $mail->Subject = $t['subject'];
- $mail->Body = $t['html'];
- if (!empty($t['alt'])) $mail->AltBody = $t['alt'];
- // Optional BCC + attachment
- $mail->addBCC('drafting@modulosdesign.com.au');
- // Attach the PDF from memory
- $mail->addStringAttachment($pdfBinary, $clientId . '_loa_signed.pdf', 'base64', 'application/pdf');
- try {
- $mail->send();
- } catch (Exception $e) {
- error_log("LOA email send error to {$t['to']}: {$mail->ErrorInfo}");
- }
- }
- }
- // ---------------------------------------------------------------------
- // MAIN FLOW
- // ---------------------------------------------------------------------
- $pdo = getPdoSafe($cfg);
- // $clientId = isset($_GET['clientid']) && preg_match('/^\d{1,10}$/', $_GET['clientid']) ? $_GET['clientid'] : 'unknown';
- $drgRaw = $_GET['drg'] ?? $_GET['clientid'] ?? '';
- [$clientId, $drgCandidates] = normalizeDrg($drgRaw); // $clientId becomes your Job # string (e.g. "3043")
- $trusted = isset($cfg['trusted_proxies']) && is_array($cfg['trusted_proxies']) ? $cfg['trusted_proxies'] : [];
- $row = fetchLoaFromDb($pdo, $clientId, $drgCandidates);
- // Developer signature image (URL or data:) from config
- $devSigUrl = '';
- if (!empty($cfg['dev_signature'])) {
- $devSigUrl = (string)$cfg['dev_signature']; // can be data: or absolute URL
- }
- // ---------------------------------------------------------------------
- // GET ?download=signed -> stream PDF of latest signed LOA from DB
- // ---------------------------------------------------------------------
- if (($_GET['download'] ?? '') === 'signed') {
- $filename = $clientId . '-LOA-Signed.pdf';
- $pdfPath = CONTRACT_DIR . '/' . $filename;
- // No file yet, build from DB
- if (empty($row['client_signature_png'])) {
- http_response_code(404);
- exit('No signed LOA on file.');
- }
- $clientSig = clientSignatureHtml(
- $row['client_signature_png'],
- !empty($row['client_signed_at']) ? date('F j, Y \a\t g:i:s A T', strtotime($row['client_signed_at'])) : null,
- $row['client_ip'] ?? null,
- $row['client_name'] ?? null
- );
- $devSig = devSignatureHtml($devSigUrl, null, null, $row['dev_name'] ?? null);
- $pdfHtml = buildSignedPdfHtml(
- $clientId,
- (string)$row['prepared_date'],
- (string)$row['company'],
- (string)$row['loa_html'],
- $devSig,
- $clientSig
- );
- $opts = new Options();
- $opts->set('defaultFont', 'Helvetica');
- $opts->set('isRemoteEnabled', true);
- $dompdf = new Dompdf($opts);
- $dompdf->loadHtml($pdfHtml, 'UTF-8');
- $dompdf->setPaper('A4', 'portrait');
- $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
- $dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\') . '/';
- $dompdf->setBasePath('https://' . $host . $dir);
- $dompdf->render();
- $pdf = $dompdf->output();
- // Save then stream
- @file_put_contents($pdfPath, $pdf, LOCK_EX);
- header('Content-Type: application/pdf');
- header('Content-Disposition: inline; filename="' . $filename . '"');
- header('Content-Length: ' . strlen($pdf));
- echo $pdf;
- exit;
- }
- // ---------------------------------------------------------------------
- // POST (client signing) -> save signature, email, stream PDF
- // ---------------------------------------------------------------------
- if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['client_signature'])) {
- $sig = (string)$_POST['client_signature'];
- if (!str_starts_with($sig, 'data:image/png;base64,')) {
- http_response_code(400);
- exit('Invalid signature format');
- }
- if (strlen($sig) > 2 * 1024 * 1024) { // 2MB limit for safety
- http_response_code(413);
- exit('Signature too large');
- }
- $clientTz = (string)($_POST['client_tz'] ?? '');
- $signedAtIso = (function ($clientTz) {
- try {
- if ($clientTz && in_array($clientTz, timezone_identifiers_list(), true)) {
- $tz = new DateTimeZone($clientTz);
- $dt = new DateTime('now', $tz);
- return $dt->format(DateTime::ATOM);
- }
- } catch (\Throwable $e) {}
- return gmdate('c');
- })($clientTz);
- $clientIp = clientExternalIp($trusted);
- // Persist signature to DB
- saveLoaSignatureToDb($pdo, $clientId, $sig, $clientIp, $clientTz, $signedAtIso);
- // Re-hydrate row with saved fields (optional)
- $row = fetchLoaFromDb($pdo, $clientId, $drgCandidates);
- // Build signature blocks
- $clientSig = clientSignatureHtml(
- $sig,
- date('F j, Y \a\t g:i:s A T', strtotime($signedAtIso)),
- $clientIp,
- $row['client_name'] ?? null
- );
- $devSignedAt = date('F j, Y \a\t g:i:s A T');
- $devIp = $_SERVER['SERVER_ADDR'] ?? 'UNKNOWN';
- $devSig = devSignatureHtml($devSigUrl, $devSignedAt, $devIp, $row['dev_name'] ?? null);
- // Compile PDF HTML
- $pdfHtml = buildSignedPdfHtml(
- $clientId,
- (string)$row['prepared_date'],
- (string)$row['company'],
- (string)$row['loa_html'],
- $devSig,
- $clientSig
- );
- // Render PDF
- $opts = new Options();
- $opts->set('defaultFont', 'Helvetica');
- $opts->set('isRemoteEnabled', true);
- $dompdf = new Dompdf($opts);
- $dompdf->loadHtml($pdfHtml, 'UTF-8');
- $dompdf->setPaper('A4', 'portrait');
- $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
- $dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\') . '/';
- $dompdf->setBasePath('https://' . $host . $dir);
- $dompdf->render();
- $pdf = $dompdf->output();
- // Email PDFs
- $from = $cfg['from_address'] ?? 'drafting@modulosdesign.com.au';
- sendSignedLoaEmails($cfg, $from, $row, $pdf);
- // Stream to browser
- header('Content-Type: application/pdf');
- header('Content-Disposition: inline; filename="' . $clientId . '-LOA-Signed.pdf"');
- header('Content-Length: ' . strlen($pdf));
- echo $pdf;
- exit;
- }
- // ---------------------------------------------------------------------
- // GET (no signature yet) -> show unsigned page with signature pad
- // ---------------------------------------------------------------------
- if (!headers_sent()) {
- header('Content-Type: text/html; charset=UTF-8');
- }
- echo headerWeb('Letter of Authority (Unsigned)', $clientId, (string)$row['prepared_date'], (string)$row['logo_url']);
- // LOA BODY (from DB)
- echo '<div id="content">';
- echo $row['loa_html']; // Assume this is trusted HTML from your system. If not, sanitize.
- echo '</div>';
- // Signature UI (no dev signature displayed here)
- $clientEmailSafe = htmlspecialchars((string)($row['client_email'] ?? ''), ENT_QUOTES, 'UTF-8');
- $csrfSafe = $csrf;
- ?>
- <form method="post" class="noprint" id="signature_form">
- <input type="hidden" name="csrf" value="<?=$csrfSafe?>">
- <input type="hidden" name="client_tz" value="">
- <input type="hidden" id="client_signature" name="client_signature">
- <div id="signature-container">
- <label class="form-label fw-semibold">Please sign below:</label>
- <div id="canvas-container">
- <canvas id="signature-pad" class="signature-pad"></canvas>
- </div>
- <div class="d-flex gap-2 justify-content-start mt-2">
- <button id="reset" type="button" class="btn btn-warning rounded-0">Clear</button>
- <button data-bs-toggle="modal" data-bs-target="#modal-qr" type="button" class="btn btn-secondary rounded-0">Sign on mobile</button>
- <button id="confirm" type="submit" class="btn btn-primary rounded-0" disabled>Sign & Download PDF</button>
- </div>
- <div class="form-text mt-2">
- After signing, a PDF will open in your browser and be emailed to you at
- <strong><?=$clientEmailSafe?></strong>.
- </div>
- </div>
- </form>
- <!-- Mobile QR modal -->
- <div class="modal fade" tabindex="-1" id="modal-qr" aria-labelledby="modal-qrLabel" aria-hidden="true">
- <div class="modal-dialog modal-dialog-centered">
- <div class="modal-content">
- <div class="modal-body">
- <button id="close-modal-qr" type="button" class="btn-close" data-bs-dismiss="modal-qr" aria-label="Close"></button>
- <canvas id="qr-code"></canvas>
- </div>
- </div>
- </div>
- </div>
- <?= footerWeb(); ?>
- <script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"></script>
- <script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
- <script>
- (function () {
- const canvas = document.getElementById('signature-pad');
- if (!canvas) return;
- const pad = new SignaturePad(canvas, {
- penColor: "hsl(200, 100%, 30%)",
- minDistance: 2
- });
- function resizeCanvas() {
- const ratio = Math.max(window.devicePixelRatio || 1, 1);
- canvas.width = canvas.offsetWidth * ratio;
- canvas.height = 120 * ratio;
- canvas.getContext('2d').scale(ratio, ratio);
- // restore if existing
- const data = localStorage.getItem('client_signature');
- if (data) {
- pad.fromDataURL(data);
- document.getElementById('client_signature').value = data;
- document.getElementById('confirm').disabled = false;
- }
- }
- window.addEventListener('resize', resizeCanvas);
- resizeCanvas();
- pad.addEventListener('afterUpdateStroke', () => {
- const data = pad.toDataURL('image/png');
- document.getElementById('client_signature').value = data;
- localStorage.setItem('client_signature', data);
- document.getElementById('confirm').disabled = false;
- });
- document.getElementById('reset').addEventListener('click', () => {
- pad.clear();
- localStorage.removeItem('client_signature');
- document.getElementById('client_signature').value = '';
- document.getElementById('confirm').disabled = true;
- });
- document.getElementById('signature_form').addEventListener('submit', (e) => {
- // allow natural submit
- document.getElementById('canvas-container').classList.add('just-signed');
- });
- // Set timezone hidden field
- try {
- document.querySelector('input[name="client_tz"]').value =
- Intl.DateTimeFormat().resolvedOptions().timeZone || '';
- } catch (e) {}
- // QR code for mobile signing (just points to this URL)
- const canvasQR = document.getElementById('qr-code');
- if (canvasQR && window.QRious) {
- new QRious({
- element: canvasQR,
- value: window.location.href,
- foreground: 'hsl(200, 30%, 20%)',
- padding: 0,
- size: 360
- });
- }
- // Basic modal fallback
- const modal = document.getElementById('modal-qr');
- const btnClose = document.getElementById('close-modal-qr');
- btnClose?.addEventListener('click', function () {
- try { modal.close(); } catch (e) { modal.removeAttribute('open'); }
- });
- modal?.addEventListener('click', function (e) {
- const r = modal.getBoundingClientRect();
- const inside = e.clientY >= r.top && e.clientY <= r.bottom && e.clientX >= r.left && e.clientX <= r.right;
- if (!inside) {
- try { modal.close(); } catch (err) { modal.removeAttribute('open'); }
- }
- });
- })();
- </script>
|