Benjamin Harris преди 2 месеца
родител
ревизия
6169f927ca
променени са 8 файла, в които са добавени 338 реда и са изтрити 179 реда
  1. 19 1
      client-assets/css/home.css
  2. 5 0
      composer.json
  3. 16 0
      config/mail.php
  4. 105 0
      controllers/contactSubmit.php
  5. 0 175
      dashboard/crop-analysis/soil-test-data/soil-analysis.php
  6. 19 2
      index.php
  7. 1 1
      layouts/header.php
  8. 173 0
      lib/soil_calculations.php

+ 19 - 1
client-assets/css/home.css

@@ -1021,4 +1021,22 @@ footer {
     .about-metrics,
     .steps { grid-template-columns: 1fr; }
     .form-row { grid-template-columns: 1fr; }
-}
+}
+/* ── Contact form alerts ─────────────────────────────────────────────────── */
+.contact-alert {
+    padding: 0.85rem 1.2rem;
+    border-radius: 6px;
+    margin-bottom: 1.5rem;
+    font-size: 0.95rem;
+    font-family: 'DM Sans', sans-serif;
+}
+.contact-alert-success {
+    background: rgba(74, 122, 66, 0.15);
+    border: 1px solid rgba(74, 122, 66, 0.35);
+    color: #4a7a42;
+}
+.contact-alert-error {
+    background: rgba(180, 60, 60, 0.1);
+    border: 1px solid rgba(180, 60, 60, 0.3);
+    color: #b43c3c;
+}

+ 5 - 0
composer.json

@@ -0,0 +1,5 @@
+{
+    "require": {
+        "phpmailer/phpmailer": "^6.9"
+    }
+}

+ 16 - 0
config/mail.php

@@ -0,0 +1,16 @@
+<?php
+/**
+ * config/mail.php
+ *
+ * SMTP configuration for PHPMailer.
+ * Fill in your mail server credentials here (or move to .env).
+ */
+
+define('MAIL_HOST',       'smtp.example.com');      // SMTP server
+define('MAIL_PORT',       587);                     // 587 = STARTTLS, 465 = SSL
+define('MAIL_ENCRYPTION', 'tls');                   // 'tls' or 'ssl'
+define('MAIL_USERNAME',   'hello@cropmonitor.com.au');
+define('MAIL_PASSWORD',   '');                      // Fill in SMTP password
+define('MAIL_FROM',       'hello@cropmonitor.com.au');
+define('MAIL_FROM_NAME',  'Crop Monitor');
+define('MAIL_TO',         'hello@cropmonitor.com.au'); // Inbox that receives contact submissions

+ 105 - 0
controllers/contactSubmit.php

@@ -0,0 +1,105 @@
+<?php
+/**
+ * controllers/contactSubmit.php
+ *
+ * Handles the homepage contact form submission.
+ * Validates input, sends an email via PHPMailer, redirects back with status.
+ */
+
+require_once __DIR__ . '/../vendor/autoload.php';
+require_once __DIR__ . '/../config/mail.php';
+require_once __DIR__ . '/../lib/csrf.php';
+
+use PHPMailer\PHPMailer\PHPMailer;
+use PHPMailer\PHPMailer\SMTP;
+use PHPMailer\PHPMailer\Exception;
+
+if (session_status() === PHP_SESSION_NONE) {
+    session_start();
+}
+
+// Only accept POST
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    header('Location: /#contact');
+    exit;
+}
+
+// CSRF check
+if (!verifyCsrfToken($_POST['csrf_token'] ?? '')) {
+    $_SESSION['contact_error'] = 'Invalid form submission. Please try again.';
+    header('Location: /#contact');
+    exit;
+}
+
+// Collect + sanitise fields
+$firstName = trim(htmlspecialchars($_POST['first_name'] ?? '', ENT_QUOTES, 'UTF-8'));
+$lastName  = trim(htmlspecialchars($_POST['last_name']  ?? '', ENT_QUOTES, 'UTF-8'));
+$email     = trim(filter_var($_POST['email'] ?? '', FILTER_SANITIZE_EMAIL));
+$farmType  = trim(htmlspecialchars($_POST['farm_type'] ?? '', ENT_QUOTES, 'UTF-8'));
+$message   = trim(htmlspecialchars($_POST['message']   ?? '', ENT_QUOTES, 'UTF-8'));
+
+// Basic validation
+if (!$firstName || !$lastName) {
+    $_SESSION['contact_error'] = 'Please enter your full name.';
+    header('Location: /#contact');
+    exit;
+}
+
+if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+    $_SESSION['contact_error'] = 'Please enter a valid email address.';
+    header('Location: /#contact');
+    exit;
+}
+
+if (!$message) {
+    $_SESSION['contact_error'] = 'Please enter a message.';
+    header('Location: /#contact');
+    exit;
+}
+
+// Build email body
+$fullName   = $firstName . ' ' . $lastName;
+$farmLabel  = $farmType ?: 'Not specified';
+$bodyHtml   = "
+<h2>New Contact Form Submission</h2>
+<table cellpadding='6' style='font-family:sans-serif;font-size:14px;'>
+  <tr><td><strong>Name</strong></td><td>" . $fullName . "</td></tr>
+  <tr><td><strong>Email</strong></td><td>" . $email . "</td></tr>
+  <tr><td><strong>Farm Type</strong></td><td>" . $farmLabel . "</td></tr>
+  <tr><td><strong>Message</strong></td><td>" . nl2br($message) . "</td></tr>
+</table>
+";
+$bodyText = "Name: {$fullName}\nEmail: {$email}\nFarm Type: {$farmLabel}\n\nMessage:\n{$message}";
+
+// Send via PHPMailer
+$mail = new PHPMailer(true);
+
+try {
+    $mail->isSMTP();
+    $mail->Host       = MAIL_HOST;
+    $mail->SMTPAuth   = true;
+    $mail->Username   = MAIL_USERNAME;
+    $mail->Password   = MAIL_PASSWORD;
+    $mail->SMTPSecure = MAIL_ENCRYPTION === 'ssl' ? PHPMailer::ENCRYPTION_SMTPS : PHPMailer::ENCRYPTION_STARTTLS;
+    $mail->Port       = MAIL_PORT;
+
+    $mail->setFrom(MAIL_FROM, MAIL_FROM_NAME);
+    $mail->addAddress(MAIL_TO);
+    $mail->addReplyTo($email, $fullName);
+
+    $mail->isHTML(true);
+    $mail->Subject = 'Contact Form: ' . $fullName . ' (' . $farmLabel . ')';
+    $mail->Body    = $bodyHtml;
+    $mail->AltBody = $bodyText;
+
+    $mail->send();
+
+    $_SESSION['contact_success'] = 'Thank you, ' . $firstName . '. We\'ll be in touch soon.';
+
+} catch (Exception $e) {
+    error_log('Contact form mailer error: ' . $mail->ErrorInfo);
+    $_SESSION['contact_error'] = 'Sorry, we couldn\'t send your message. Please try again later.';
+}
+
+header('Location: /#contact');
+exit;

+ 0 - 175
dashboard/crop-analysis/soil-test-data/soil-analysis.php

@@ -49,181 +49,6 @@ try {
     die('Database error occurred');
 }
 
-// ── Helper: render one analysis row ───────────────────────────────────────
-/**
- * Renders a <tr> for a single nutrient with three progress-bar columns.
- *
- * Parameters ($p keys):
- *   element   string  Column name in soil_records
- *   sbl       string  Chemical symbol (may contain HTML, e.g. "NO<sub>3</sub>-N")
- *   nutrient  string  Display name (may contain HTML)
- *   min       string  Column in soil_records, numeric literal, or '' for no bar
- *   max       string  Column in soil_records, 'soil_type' (special), numeric literal, or ''
- *   type      string  Unit label (ppm, %, mS/cm …)
- *   text      string  Value cell alignment: c|r|l
- *   rec_text  string  Recommended cell alignment: c|r|l
- *   recV      string  Recommended display: 'n'=none, 'ph'='6.4', 'max'=max only, else=min–max
- *   decimal   int     Decimal places for value
- *   graph     string  CSS class for progress-bar colour
- */
-function soilRow(array $row, array $spec, array $p): void
-{
-    $element  = $p['element']   ?? '';
-    $sbl      = $p['sbl']       ?? '';
-    $nutrient = $p['nutrient']  ?? '';
-    $minParam = $p['min']       ?? '';
-    $maxParam = $p['max']       ?? '';
-    $type     = $p['type']      ?? '';
-    $text     = $p['text']      ?? 'c';
-    $recText  = $p['rec_text']  ?? 'c';
-    $recV     = $p['recV']      ?? '';
-    $decimal  = (int)($p['decimal'] ?? 2);
-    $graph    = $p['graph']     ?? '';
-
-    $label  = ($sbl !== '') ? $sbl . ' - ' . $nutrient : $nutrient;
-    $rawVal = $row[$element] ?? null;
-    $value  = ($rawVal !== null && $rawVal !== '') ? (float)$rawVal : 0.0;
-    $valueFmt = number_format($value, $decimal, '.', '') . ($type !== '' ? ' ' . $type : '');
-
-    // Resolve min
-    $min = 0.0;
-    if ($minParam !== '') {
-        if (is_numeric($minParam)) {
-            $min = (float)$minParam;
-        } elseif (isset($row[$minParam]) && $row[$minParam] !== '') {
-            $min = (float)$row[$minParam];
-        } elseif (isset($spec[$minParam]) && $spec[$minParam] !== '') {
-            $min = (float)$spec[$minParam];
-        }
-    } elseif (isset($spec[$element]) && $spec[$element] !== '') {
-        $min = (float)$spec[$element] / 2;  // fallback: half the spec average
-    }
-
-    // Resolve max + recommended display label
-    $max = 0.0;
-    $maxLabel = '';
-    if ($maxParam === 'soil_type') {
-        $st = strtolower($row['soil_type'] ?? '');
-        $maxLabel = match($st) {
-            'light'  => 'Light Soil',
-            'medium' => 'Medium Soil',
-            'heavy'  => 'Heavy Soil',
-            default  => htmlspecialchars($row['soil_type'] ?? '', ENT_QUOTES, 'UTF-8'),
-        };
-    } elseif ($maxParam !== '') {
-        if (is_numeric($maxParam)) {
-            $max = (float)$maxParam;
-        } elseif (isset($row[$maxParam]) && $row[$maxParam] !== '') {
-            $max = (float)$row[$maxParam];
-        } elseif (isset($spec[$maxParam]) && $spec[$maxParam] !== '') {
-            $max = (float)$spec[$maxParam];
-        }
-    } elseif (isset($spec[$element]) && $spec[$element] !== '') {
-        $max = (float)$spec[$element] * 2;  // fallback: double the spec average
-    }
-
-    // Recommended cell text
-    $measurement = ($type !== '') ? ' ' . $type : '';
-    if ($maxParam === 'soil_type') {
-        $recommended = $maxLabel;
-    } elseif ($recV === 'n') {
-        $recommended = '';
-    } elseif ($recV === 'ph') {
-        $recommended = '6.4';
-    } elseif ($recV === 'max') {
-        $recommended = number_format($max, $decimal, '.', '') . $measurement;
-    } else {
-        $recommended = number_format($min, $decimal, '.', '') . ' - ' . number_format($max, $decimal, '.', '') . $measurement;
-    }
-
-    $alignVal = match($text)    { 'r' => 'text-right', 'l' => 'text-left', default => 'text-center' };
-    $alignRec = match($recText) { 'r' => 'text-right', 'l' => 'text-left', default => 'text-center' };
-
-    // Bar calculations (replicates original int-cast logic)
-    $c_min = $min - ($max - $min);
-    $c_max = $max + ($max - $min);
-    $hasValue = ($rawVal !== null && $rawVal !== '' && $rawVal !== '0');
-
-    // First bar (deficit zone: c_min → min)
-    if (!$hasValue || (int)($c_min - $min) == 0) {
-        $fb = 0;
-    } else {
-        $fb = (int)($c_min - $value) / (int)($c_min - $min) * 100;
-    }
-    $fb = !$hasValue ? 0 : ($fb > 100 ? 100 : ($fb < 0 ? 2 : $fb));
-
-    // Second bar (ideal zone: min → max)
-    if (!$hasValue || (int)($min - $max) == 0) {
-        $sb = 0;
-    } else {
-        $sb = (int)($min - $value) / (int)($min - $max) * 100;
-    }
-    $sbp = ($fb < 100) ? 0 : ($sb < 0 ? 0 : ($sb > 101 ? 100 : $sb));
-
-    // Third bar (excess zone: max → c_max)
-    if (!$hasValue || (int)($max - $c_max) == 0) {
-        $tb = 0;
-    } else {
-        $tb = (int)($max - $value) / (int)($max - $c_max) * 100;
-    }
-    $tbp = ($sb < 100) ? 0 : ($tb < 0 ? 0 : ($tb > 101 ? 100 : $tb));
-
-    echo "<tr class='sub-chart'>\n";
-    echo "  <td class='text-left border-left text-capitalize pl-2'>{$label}</td>\n";
-    echo "  <td class='{$alignRec} border-left px-3'>{$recommended}</td>\n";
-    echo "  <td class='{$alignVal} border-left nutrient-balance px-3'>{$valueFmt}</td>\n";
-    echo "  <td class='text-center border-left graph-border'><div class='progress'><div class='progress-bar {$graph}' style='width:{$fb}%'></div></div></td>\n";
-    echo "  <td class='text-center border-left graph-border'><div class='progress'><div class='progress-bar {$graph}' style='width:{$sbp}%'></div></div></td>\n";
-    echo "  <td class='text-center border-left border-right graph-border'><div class='progress'><div class='progress-bar {$graph}' style='width:{$tbp}%'></div></div></td>\n";
-    echo "</tr>\n";
-}
-
-// ── Helper: render a ratio row ─────────────────────────────────────────────
-/**
- * Renders a <tr> for a calculated ratio (element ÷ elementTwo).
- *
- * Parameters ($p keys):
- *   element    string  Numerator column in soil_records
- *   elementTwo string  Denominator column in soil_records
- *   sbl        string  Chemical symbol
- *   nutrient   string  Display name
- *   rec        string  Column in soil_specifications for the recommended ratio
- *   type       string  Unit suffix (e.g. ':1')
- *   rec_text   string  Recommended alignment: c|r|l
- *   decimal    int     Decimal places
- *   graph      string  CSS class for progress-bar colour
- */
-function soilRatio(array $row, array $spec, array $p): void
-{
-    $element  = $p['element']    ?? '';
-    $element2 = $p['elementTwo'] ?? '';
-    $sbl      = $p['sbl']        ?? '';
-    $nutrient = $p['nutrient']   ?? '';
-    $rec      = $p['rec']        ?? '';
-    $type     = $p['type']       ?? '';
-    $recText  = $p['rec_text']   ?? 'c';
-    $decimal  = (int)($p['decimal'] ?? 1);
-    $graph    = $p['graph']      ?? '';
-
-    $label = ($sbl !== '') ? $sbl . ' - ' . $nutrient : $nutrient;
-    $val1  = isset($row[$element])  && $row[$element]  !== '' ? (float)$row[$element]  : 0.0;
-    $val2  = isset($row[$element2]) && $row[$element2] !== '' ? (float)$row[$element2] : 0.0;
-    $ratio = ($val2 != 0) ? $val1 / $val2 : 0.0;
-
-    $valueFmt    = number_format($ratio, $decimal, '.', '') . ($type !== '' ? ' ' . $type : '');
-    $recommended = (isset($spec[$rec]) && $spec[$rec] !== '') ? htmlspecialchars($spec[$rec], ENT_QUOTES, 'UTF-8') : '';
-    $alignRec    = match($recText) { 'r' => 'text-right', 'l' => 'text-left', default => 'text-center' };
-
-    echo "<tr class='sub-chart'>\n";
-    echo "  <td class='text-left border-left text-capitalize pl-2'>{$label}</td>\n";
-    echo "  <td class='{$alignRec} border-left px-3'>{$recommended}</td>\n";
-    echo "  <td class='text-center border-left nutrient-balance px-3'>{$valueFmt}</td>\n";
-    echo "  <td class='text-center border-left graph-border'><div class='progress'><div class='progress-bar {$graph}' style='width:0%'></div></div></td>\n";
-    echo "  <td class='text-center border-left graph-border'><div class='progress'><div class='progress-bar {$graph}' style='width:0%'></div></div></td>\n";
-    echo "  <td class='text-center border-left border-right graph-border'><div class='progress'><div class='progress-bar {$graph}' style='width:0%'></div></div></td>\n";
-    echo "</tr>\n";
-}
-
 // ── Page setup ─────────────────────────────────────────────────────────────
 $client     = htmlspecialchars($row['client_name']   ?? '', ENT_QUOTES, 'UTF-8');
 $address    = htmlspecialchars($row['site_address']   ?? '', ENT_QUOTES, 'UTF-8');

+ 19 - 2
index.php

@@ -1,4 +1,14 @@
 <?php
+require_once __DIR__ . '/lib/csrf.php';
+
+if (session_status() === PHP_SESSION_NONE) {
+    session_start();
+}
+
+$contactSuccess = $_SESSION['contact_success'] ?? null;
+$contactError   = $_SESSION['contact_error']   ?? null;
+unset($_SESSION['contact_success'], $_SESSION['contact_error']);
+
 $pageTitle = 'Precision Soil Science for Australian Farms';
 $siteName  = 'Crop Monitor';
 $siteUrl   = '/';
@@ -410,7 +420,14 @@ $siteUrl   = '/';
       <div class="section-eyebrow">Contact Us</div>
       <h2 class="section-title">Let's Talk About<br>Your Soil</h2>
       <p class="section-sub" style="margin-bottom:2rem;">Whether you're curious about the Albrecht Method, looking to start monitoring, or want a consult on existing soil test results — we're here.</p>
-      <form class="contact-form" action="#" method="post">
+      <?php if ($contactSuccess): ?>
+        <div class="contact-alert contact-alert-success"><?= htmlspecialchars($contactSuccess, ENT_QUOTES, 'UTF-8') ?></div>
+      <?php elseif ($contactError): ?>
+        <div class="contact-alert contact-alert-error"><?= htmlspecialchars($contactError, ENT_QUOTES, 'UTF-8') ?></div>
+      <?php endif; ?>
+
+      <form class="contact-form" action="/controllers/contactSubmit.php" method="post">
+        <input type="hidden" name="csrf_token" value="<?= generateCsrfToken() ?>">
         <div class="form-row">
           <div class="form-field">
             <label>First Name</label>
@@ -469,7 +486,7 @@ $siteUrl   = '/';
         <div class="contact-info-icon">🕐</div>
         <div>
           <div class="contact-info-label">Hours</div>
-          <div class="contact-info-val">Mon–Fri 8:00am – 5:30pm AEST</div>
+          <div class="contact-info-val">Mon–Fri 8:00am – 4:30pm AEST</div>
         </div>
       </div>
     </div>

+ 1 - 1
layouts/header.php

@@ -10,7 +10,7 @@
     <meta name="keywords" content="Crop Management, Agriculture, Soil Analysis, Weather Monitoring, Crop Health, Farm Management, Agricultural Technology">
     <meta name="author" content="Crop Management Platform Team">
     
-    <link rel="icon" href="client-assets/images/favicon.ico?v=2" type="image/x-icon">
+    <link rel="icon" href="favicon.ico?v=2" type="image/x-icon">
 
     <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
     <link href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css" rel="stylesheet" type="text/css" />

+ 173 - 0
lib/soil_calculations.php

@@ -183,4 +183,177 @@ function soilAnalysisReportCalcs($symbol, $element, $min, $max, $nutrient, $type
         return "<div class='{$class}'>{$nutrient}: Error</div>";
     }
 }
+
+/**
+ * Renders a <tr> for a single nutrient with three progress-bar columns.
+ *
+ * Parameters ($p keys):
+ *   element   string  Column name in soil_records
+ *   sbl       string  Chemical symbol (may contain HTML, e.g. "NO<sub>3</sub>-N")
+ *   nutrient  string  Display name (may contain HTML)
+ *   min       string  Column in soil_records, numeric literal, or '' for no bar
+ *   max       string  Column in soil_records, 'soil_type' (special), numeric literal, or ''
+ *   type      string  Unit label (ppm, %, mS/cm …)
+ *   text      string  Value cell alignment: c|r|l
+ *   rec_text  string  Recommended cell alignment: c|r|l
+ *   recV      string  Recommended display: 'n'=none, 'ph'='6.4', 'max'=max only, else=min–max
+ *   decimal   int     Decimal places for value
+ *   graph     string  CSS class for progress-bar colour
+ */
+function soilRow(array $row, array $spec, array $p): void
+{
+    $element  = $p['element']   ?? '';
+    $sbl      = $p['sbl']       ?? '';
+    $nutrient = $p['nutrient']  ?? '';
+    $minParam = $p['min']       ?? '';
+    $maxParam = $p['max']       ?? '';
+    $type     = $p['type']      ?? '';
+    $text     = $p['text']      ?? 'c';
+    $recText  = $p['rec_text']  ?? 'c';
+    $recV     = $p['recV']      ?? '';
+    $decimal  = (int)($p['decimal'] ?? 2);
+    $graph    = $p['graph']     ?? '';
+
+    $label  = ($sbl !== '') ? $sbl . ' - ' . $nutrient : $nutrient;
+    $rawVal = $row[$element] ?? null;
+    $value  = ($rawVal !== null && $rawVal !== '') ? (float)$rawVal : 0.0;
+    $valueFmt = number_format($value, $decimal, '.', '') . ($type !== '' ? ' ' . $type : '');
+
+    // Resolve min
+    $min = 0.0;
+    if ($minParam !== '') {
+        if (is_numeric($minParam)) {
+            $min = (float)$minParam;
+        } elseif (isset($row[$minParam]) && $row[$minParam] !== '') {
+            $min = (float)$row[$minParam];
+        } elseif (isset($spec[$minParam]) && $spec[$minParam] !== '') {
+            $min = (float)$spec[$minParam];
+        }
+    } elseif (isset($spec[$element]) && $spec[$element] !== '') {
+        $min = (float)$spec[$element] / 2;  // fallback: half the spec average
+    }
+
+    // Resolve max + recommended display label
+    $max = 0.0;
+    $maxLabel = '';
+    if ($maxParam === 'soil_type') {
+        $st = strtolower($row['soil_type'] ?? '');
+        $maxLabel = match($st) {
+            'light'  => 'Light Soil',
+            'medium' => 'Medium Soil',
+            'heavy'  => 'Heavy Soil',
+            default  => htmlspecialchars($row['soil_type'] ?? '', ENT_QUOTES, 'UTF-8'),
+        };
+    } elseif ($maxParam !== '') {
+        if (is_numeric($maxParam)) {
+            $max = (float)$maxParam;
+        } elseif (isset($row[$maxParam]) && $row[$maxParam] !== '') {
+            $max = (float)$row[$maxParam];
+        } elseif (isset($spec[$maxParam]) && $spec[$maxParam] !== '') {
+            $max = (float)$spec[$maxParam];
+        }
+    } elseif (isset($spec[$element]) && $spec[$element] !== '') {
+        $max = (float)$spec[$element] * 2;  // fallback: double the spec average
+    }
+
+    // Recommended cell text
+    $measurement = ($type !== '') ? ' ' . $type : '';
+    if ($maxParam === 'soil_type') {
+        $recommended = $maxLabel;
+    } elseif ($recV === 'n') {
+        $recommended = '';
+    } elseif ($recV === 'ph') {
+        $recommended = '6.4';
+    } elseif ($recV === 'max') {
+        $recommended = number_format($max, $decimal, '.', '') . $measurement;
+    } else {
+        $recommended = number_format($min, $decimal, '.', '') . ' - ' . number_format($max, $decimal, '.', '') . $measurement;
+    }
+
+    $alignVal = match($text)    { 'r' => 'text-right', 'l' => 'text-left', default => 'text-center' };
+    $alignRec = match($recText) { 'r' => 'text-right', 'l' => 'text-left', default => 'text-center' };
+
+    // Bar calculations (replicates original int-cast logic)
+    $c_min = $min - ($max - $min);
+    $c_max = $max + ($max - $min);
+    $hasValue = ($rawVal !== null && $rawVal !== '' && $rawVal !== '0');
+
+    // First bar (deficit zone: c_min → min)
+    if (!$hasValue || (int)($c_min - $min) == 0) {
+        $fb = 0;
+    } else {
+        $fb = (int)($c_min - $value) / (int)($c_min - $min) * 100;
+    }
+    $fb = !$hasValue ? 0 : ($fb > 100 ? 100 : ($fb < 0 ? 2 : $fb));
+
+    // Second bar (ideal zone: min → max)
+    if (!$hasValue || (int)($min - $max) == 0) {
+        $sb = 0;
+    } else {
+        $sb = (int)($min - $value) / (int)($min - $max) * 100;
+    }
+    $sbp = ($fb < 100) ? 0 : ($sb < 0 ? 0 : ($sb > 101 ? 100 : $sb));
+
+    // Third bar (excess zone: max → c_max)
+    if (!$hasValue || (int)($max - $c_max) == 0) {
+        $tb = 0;
+    } else {
+        $tb = (int)($max - $value) / (int)($max - $c_max) * 100;
+    }
+    $tbp = ($sb < 100) ? 0 : ($tb < 0 ? 0 : ($tb > 101 ? 100 : $tb));
+
+    echo "<tr class='sub-chart'>\n";
+    echo "  <td class='text-left border-left text-capitalize pl-2'>{$label}</td>\n";
+    echo "  <td class='{$alignRec} border-left px-3'>{$recommended}</td>\n";
+    echo "  <td class='{$alignVal} border-left nutrient-balance px-3'>{$valueFmt}</td>\n";
+    echo "  <td class='text-center border-left graph-border'><div class='progress'><div class='progress-bar {$graph}' style='width:{$fb}%'></div></div></td>\n";
+    echo "  <td class='text-center border-left graph-border'><div class='progress'><div class='progress-bar {$graph}' style='width:{$sbp}%'></div></div></td>\n";
+    echo "  <td class='text-center border-left border-right graph-border'><div class='progress'><div class='progress-bar {$graph}' style='width:{$tbp}%'></div></div></td>\n";
+    echo "</tr>\n";
+}
+
+/**
+ * Renders a <tr> for a calculated ratio (element ÷ elementTwo).
+ *
+ * Parameters ($p keys):
+ *   element    string  Numerator column in soil_records
+ *   elementTwo string  Denominator column in soil_records
+ *   sbl        string  Chemical symbol
+ *   nutrient   string  Display name
+ *   rec        string  Column in soil_specifications for the recommended ratio
+ *   type       string  Unit suffix (e.g. ':1')
+ *   rec_text   string  Recommended alignment: c|r|l
+ *   decimal    int     Decimal places
+ *   graph      string  CSS class for progress-bar colour
+ */
+function soilRatio(array $row, array $spec, array $p): void
+{
+    $element  = $p['element']    ?? '';
+    $element2 = $p['elementTwo'] ?? '';
+    $sbl      = $p['sbl']        ?? '';
+    $nutrient = $p['nutrient']   ?? '';
+    $rec      = $p['rec']        ?? '';
+    $type     = $p['type']       ?? '';
+    $recText  = $p['rec_text']   ?? 'c';
+    $decimal  = (int)($p['decimal'] ?? 1);
+    $graph    = $p['graph']      ?? '';
+
+    $label = ($sbl !== '') ? $sbl . ' - ' . $nutrient : $nutrient;
+    $val1  = isset($row[$element])  && $row[$element]  !== '' ? (float)$row[$element]  : 0.0;
+    $val2  = isset($row[$element2]) && $row[$element2] !== '' ? (float)$row[$element2] : 0.0;
+    $ratio = ($val2 != 0) ? $val1 / $val2 : 0.0;
+
+    $valueFmt    = number_format($ratio, $decimal, '.', '') . ($type !== '' ? ' ' . $type : '');
+    $recommended = (isset($spec[$rec]) && $spec[$rec] !== '') ? htmlspecialchars($spec[$rec], ENT_QUOTES, 'UTF-8') : '';
+    $alignRec    = match($recText) { 'r' => 'text-right', 'l' => 'text-left', default => 'text-center' };
+
+    echo "<tr class='sub-chart'>\n";
+    echo "  <td class='text-left border-left text-capitalize pl-2'>{$label}</td>\n";
+    echo "  <td class='{$alignRec} border-left px-3'>{$recommended}</td>\n";
+    echo "  <td class='text-center border-left nutrient-balance px-3'>{$valueFmt}</td>\n";
+    echo "  <td class='text-center border-left graph-border'><div class='progress'><div class='progress-bar {$graph}' style='width:0%'></div></div></td>\n";
+    echo "  <td class='text-center border-left graph-border'><div class='progress'><div class='progress-bar {$graph}' style='width:0%'></div></div></td>\n";
+    echo "  <td class='text-center border-left border-right graph-border'><div class='progress'><div class='progress-bar {$graph}' style='width:0%'></div></div></td>\n";
+    echo "</tr>\n";
+}
 ?>