Procházet zdrojové kódy

Restructure repo: move files into internal/ and contracts/ subfolders

Both systems now live as siblings inside the repository root,
making the full codebase versionable in a single repo.

Changes:
- All internal PHP files moved to internal/ subfolder
- contracts/ system added alongside internal/ (was untracked)
- .env moved to internal/ where connection.php expects it
- contracts/config.php: fixed .env path (../internal/.env → ../.env
  is now correct since both are siblings)
- contracts/edit_application.php, admin_dashboard.php: fixed HTML
  asset paths from ../../internal/ to ../internal/
- internal/progress.php: fixed absolute CSS URLs to relative paths
- internal/dashboard.php: contracts link changed from absolute URL
  to relative ../contracts/edit_application.php
- contracts/.gitignore: added to exclude client data (signed LOA,
  contract PDFs, client signatures, SQLite DB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris před 2 týdny
rodič
revize
1aeef14fb0
100 změnil soubory, kde provedl 11637 přidání a 0 odebrání
  1. 20 0
      contracts/.gitignore
  2. 19 0
      contracts/.htaccess
  3. 0 0
      contracts/Parsedown.php
  4. 686 0
      contracts/ParsedownExtra.php
  5. 80 0
      contracts/add_stage.php
  6. 100 0
      contracts/admin_dashboard.php
  7. binární
      contracts/applicant_signature.png
  8. 582 0
      contracts/breadcrumb.php
  9. 41 0
      contracts/config.php
  10. 1235 0
      contracts/contract.php
  11. 25 0
      contracts/contracts-admin/.htaccess
  12. 111 0
      contracts/contracts-admin/README.md
  13. 1636 0
      contracts/contracts-admin/contracts-admin.php
  14. 12 0
      contracts/contracts-admin/schema.mysql.sql
  15. 5 0
      contracts/contracts/.htaccess
  16. 0 0
      contracts/dompdf/AUTHORS.md
  17. 0 0
      contracts/dompdf/LICENSE.LGPL
  18. 0 0
      contracts/dompdf/README.md
  19. 0 0
      contracts/dompdf/VERSION
  20. 0 0
      contracts/dompdf/autoload.inc.php
  21. 74 0
      contracts/dorset_fill.php
  22. 1618 0
      contracts/edit_application.php
  23. 3 0
      contracts/generator/.gitignore
  24. 8 0
      contracts/generator/.vscode/settings.json
  25. 171 0
      contracts/generator/README.md
  26. 43 0
      contracts/generator/data/README.md
  27. 111 0
      contracts/generator/data/contract-content.html
  28. 16 0
      contracts/generator/data/contract-data.js
  29. 62 0
      contracts/generator/data/more-data/README.md
  30. 7 0
      contracts/generator/data/more-data/contract-settings.js
  31. 59 0
      contracts/generator/data/more-data/css/accessibility.css
  32. 318 0
      contracts/generator/data/more-data/css/animated-entrances.css
  33. 212 0
      contracts/generator/data/more-data/css/buttons.css
  34. 52 0
      contracts/generator/data/more-data/css/colors.css
  35. 83 0
      contracts/generator/data/more-data/css/contract-typography.css
  36. 0 0
      contracts/generator/data/more-data/css/custom.css
  37. 4 0
      contracts/generator/data/more-data/css/fonts.css
  38. 77 0
      contracts/generator/data/more-data/css/forms.css
  39. 39 0
      contracts/generator/data/more-data/css/from-quill-editor-overrides.css
  40. 272 0
      contracts/generator/data/more-data/css/from-quill-editor.css
  41. 27 0
      contracts/generator/data/more-data/css/main.css
  42. 83 0
      contracts/generator/data/more-data/css/modal.css
  43. 159 0
      contracts/generator/data/more-data/css/panels.css
  44. 41 0
      contracts/generator/data/more-data/css/reset.css
  45. 182 0
      contracts/generator/data/more-data/css/signatures.css
  46. 69 0
      contracts/generator/data/more-data/css/utility.css
  47. 13 0
      contracts/generator/data/more-data/html-partials/ui-signed.html.xml
  48. 62 0
      contracts/generator/data/more-data/html-partials/ui-unsigned.html.xml
  49. 141 0
      contracts/generator/data/more-data/php-partials/contract_footer.phpsrc
  50. 103 0
      contracts/generator/data/more-data/php-partials/contract_header.phpsrc
  51. 3 0
      contracts/generator/data/more-data/scripts/contract_script_signed.js
  52. 91 0
      contracts/generator/data/more-data/scripts/contract_script_unsigned.js
  53. 47 0
      contracts/generator/data/more-data/scripts/qr-code.js
  54. binární
      contracts/generator/data/more-data/signature-empty.png
  55. binární
      contracts/generator/data/more-data/signature-example.png
  56. binární
      contracts/generator/data/signature.png
  57. 0 0
      contracts/generator/data/style.min.css
  58. 2 0
      contracts/generator/docker/.dockerignore
  59. 11 0
      contracts/generator/docker/Dockerfile
  60. 25 0
      contracts/generator/docker/docker-compose-with-nginx.yaml
  61. 6 0
      contracts/generator/docker/docker-compose.yaml
  62. 434 0
      contracts/generator/edit.html
  63. binární
      contracts/generator/favicon/android-chrome-192x192.png
  64. binární
      contracts/generator/favicon/android-chrome-512x512.png
  65. binární
      contracts/generator/favicon/apple-touch-icon.png
  66. binární
      contracts/generator/favicon/favicon-16x16.png
  67. binární
      contracts/generator/favicon/favicon-32x32.png
  68. binární
      contracts/generator/favicon/favicon.ico
  69. 19 0
      contracts/generator/favicon/site.webmanifest
  70. 11 0
      contracts/generator/index.html
  71. 28 0
      contracts/generator/package.json
  72. 9 0
      contracts/generator/postcss.config.js
  73. 35 0
      contracts/generator/scripts/download-preview/activate.js
  74. 40 0
      contracts/generator/scripts/download-preview/download-preview.js
  75. 27 0
      contracts/generator/scripts/download/download.js
  76. 141 0
      contracts/generator/scripts/download/generate.js
  77. 33 0
      contracts/generator/scripts/editor/editor-settings.js
  78. 154 0
      contracts/generator/scripts/editor/editor.js
  79. 55 0
      contracts/generator/scripts/editor/ios-keyboard-bug.js
  80. 5 0
      contracts/generator/scripts/highlight/highlight.min.js
  81. 1 0
      contracts/generator/scripts/highlight/xml.min.js
  82. 7 0
      contracts/generator/scripts/init/clear-data.js
  83. 37 0
      contracts/generator/scripts/init/init-fields.js
  84. 70 0
      contracts/generator/scripts/init/init-filename-field.js
  85. 204 0
      contracts/generator/scripts/init/init-repeater.js
  86. 12 0
      contracts/generator/scripts/init/set-data.js
  87. 55 0
      contracts/generator/scripts/init/set-footer.js
  88. 24 0
      contracts/generator/scripts/init/set-header.js
  89. 35 0
      contracts/generator/scripts/main.js
  90. 137 0
      contracts/generator/scripts/preview/generate-preview.js
  91. 47 0
      contracts/generator/scripts/preview/preview-only.js
  92. 79 0
      contracts/generator/scripts/preview/preview.js
  93. 112 0
      contracts/generator/scripts/signature/signature.js
  94. 69 0
      contracts/generator/scripts/utils.js
  95. 1 0
      contracts/generator/styles/abstracts.css
  96. 67 0
      contracts/generator/styles/button-animations.css
  97. 143 0
      contracts/generator/styles/editor-ui-overrides.css
  98. 562 0
      contracts/generator/styles/editor-ui.css
  99. 136 0
      contracts/generator/styles/generator.css
  100. 2 0
      contracts/generator/styles/highlight.min.css

+ 20 - 0
contracts/.gitignore

@@ -0,0 +1,20 @@
+error_log
+*.log
+
+uploads/
+
+# Client data — never commit
+contracts/*.md
+contracts/**/*.md
+contracts/*.pdf
+contracts/**/*.pdf
+loa/*.md
+loa/**/*.md
+loa/*.pdf
+loa/**/*.pdf
+loa/**/*_signature.png
+loa/**/*_signed_*.pdf
+*_signed_contract.html
+*_signed_contract.pdf
+
+contracts-admin/contracts.sqlite

+ 19 - 0
contracts/.htaccess

@@ -0,0 +1,19 @@
+# disable the server signature
+ServerSignature Off
+
+# remove php and html extensions
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteRule ^([^\.]+)$ $1.php [NC,L]
+RewriteRule ^([^\.]+)$ $1.html [NC,L]
+
+# Disable directory browsing
+Options -Indexes
+
+# Additional security
+<FilesMatch "config\.php$">
+    Require all denied
+</FilesMatch>
+
+<FilesMatch "\.md$">
+    Require all denied
+</FilesMatch>

+ 0 - 0
Parsedown.php → contracts/Parsedown.php


+ 686 - 0
contracts/ParsedownExtra.php

@@ -0,0 +1,686 @@
+<?php
+
+#
+#
+# Parsedown Extra
+# https://github.com/erusev/parsedown-extra
+#
+# (c) Emanuil Rusev
+# http://erusev.com
+#
+# For the full license information, view the LICENSE file that was distributed
+# with this source code.
+#
+#
+
+class ParsedownExtra extends Parsedown
+{
+    # ~
+
+    const version = '0.8.0';
+
+    # ~
+
+    function __construct()
+    {
+        if (version_compare(parent::version, '1.7.1') < 0)
+        {
+            throw new Exception('ParsedownExtra requires a later version of Parsedown');
+        }
+
+        $this->BlockTypes[':'] []= 'DefinitionList';
+        $this->BlockTypes['*'] []= 'Abbreviation';
+
+        # identify footnote definitions before reference definitions
+        array_unshift($this->BlockTypes['['], 'Footnote');
+
+        # identify footnote markers before before links
+        array_unshift($this->InlineTypes['['], 'FootnoteMarker');
+    }
+
+    #
+    # ~
+
+    function text($text)
+    {
+        $Elements = $this->textElements($text);
+
+        # convert to markup
+        $markup = $this->elements($Elements);
+
+        # trim line breaks
+        $markup = trim($markup, "\n");
+
+        # merge consecutive dl elements
+
+        $markup = preg_replace('/<\/dl>\s+<dl>\s+/', '', $markup);
+
+        # add footnotes
+
+        if (isset($this->DefinitionData['Footnote']))
+        {
+            $Element = $this->buildFootnoteElement();
+
+            $markup .= "\n" . $this->element($Element);
+        }
+
+        return $markup;
+    }
+
+    #
+    # Blocks
+    #
+
+    #
+    # Abbreviation
+
+    protected function blockAbbreviation($Line)
+    {
+        if (preg_match('/^\*\[(.+?)\]:[ ]*(.+?)[ ]*$/', $Line['text'], $matches))
+        {
+            $this->DefinitionData['Abbreviation'][$matches[1]] = $matches[2];
+
+            $Block = array(
+                'hidden' => true,
+            );
+
+            return $Block;
+        }
+    }
+
+    #
+    # Footnote
+
+    protected function blockFootnote($Line)
+    {
+        if (preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $Line['text'], $matches))
+        {
+            $Block = array(
+                'label' => $matches[1],
+                'text' => $matches[2],
+                'hidden' => true,
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockFootnoteContinue($Line, $Block)
+    {
+        if ($Line['text'][0] === '[' and preg_match('/^\[\^(.+?)\]:/', $Line['text']))
+        {
+            return;
+        }
+
+        if (isset($Block['interrupted']))
+        {
+            if ($Line['indent'] >= 4)
+            {
+                $Block['text'] .= "\n\n" . $Line['text'];
+
+                return $Block;
+            }
+        }
+        else
+        {
+            $Block['text'] .= "\n" . $Line['text'];
+
+            return $Block;
+        }
+    }
+
+    protected function blockFootnoteComplete($Block)
+    {
+        $this->DefinitionData['Footnote'][$Block['label']] = array(
+            'text' => $Block['text'],
+            'count' => null,
+            'number' => null,
+        );
+
+        return $Block;
+    }
+
+    #
+    # Definition List
+
+    protected function blockDefinitionList($Line, $Block)
+    {
+        if ( ! isset($Block) or $Block['type'] !== 'Paragraph')
+        {
+            return;
+        }
+
+        $Element = array(
+            'name' => 'dl',
+            'elements' => array(),
+        );
+
+        $terms = explode("\n", $Block['element']['handler']['argument']);
+
+        foreach ($terms as $term)
+        {
+            $Element['elements'] []= array(
+                'name' => 'dt',
+                'handler' => array(
+                    'function' => 'lineElements',
+                    'argument' => $term,
+                    'destination' => 'elements'
+                ),
+            );
+        }
+
+        $Block['element'] = $Element;
+
+        $Block = $this->addDdElement($Line, $Block);
+
+        return $Block;
+    }
+
+    protected function blockDefinitionListContinue($Line, array $Block)
+    {
+        if ($Line['text'][0] === ':')
+        {
+            $Block = $this->addDdElement($Line, $Block);
+
+            return $Block;
+        }
+        else
+        {
+            if (isset($Block['interrupted']) and $Line['indent'] === 0)
+            {
+                return;
+            }
+
+            if (isset($Block['interrupted']))
+            {
+                $Block['dd']['handler']['function'] = 'textElements';
+                $Block['dd']['handler']['argument'] .= "\n\n";
+
+                $Block['dd']['handler']['destination'] = 'elements';
+
+                unset($Block['interrupted']);
+            }
+
+            $text = substr($Line['body'], min($Line['indent'], 4));
+
+            $Block['dd']['handler']['argument'] .= "\n" . $text;
+
+            return $Block;
+        }
+    }
+
+    #
+    # Header
+
+    protected function blockHeader($Line)
+    {
+        $Block = parent::blockHeader($Line);
+
+        if ($Block !== null && preg_match('/[ #]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE))
+        {
+            $attributeString = $matches[1][0];
+
+            $Block['element']['attributes'] = $this->parseAttributeData($attributeString);
+
+            $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]);
+        }
+
+        return $Block;
+    }
+
+    #
+    # Markup
+
+    protected function blockMarkup($Line)
+    {
+        if ($this->markupEscaped or $this->safeMode)
+        {
+            return;
+        }
+
+        if (preg_match('/^<(\w[\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches))
+        {
+            $element = strtolower($matches[1]);
+
+            if (in_array($element, $this->textLevelElements))
+            {
+                return;
+            }
+
+            $Block = array(
+                'name' => $matches[1],
+                'depth' => 0,
+                'element' => array(
+                    'rawHtml' => $Line['text'],
+                    'autobreak' => true,
+                ),
+            );
+
+            $length = strlen($matches[0]);
+            $remainder = substr($Line['text'], $length);
+
+            if (trim($remainder) === '')
+            {
+                if (isset($matches[2]) or in_array($matches[1], $this->voidElements))
+                {
+                    $Block['closed'] = true;
+                    $Block['void'] = true;
+                }
+            }
+            else
+            {
+                if (isset($matches[2]) or in_array($matches[1], $this->voidElements))
+                {
+                    return;
+                }
+                if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder))
+                {
+                    $Block['closed'] = true;
+                }
+            }
+
+            return $Block;
+        }
+    }
+
+    protected function blockMarkupContinue($Line, array $Block)
+    {
+        if (isset($Block['closed']))
+        {
+            return;
+        }
+
+        if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open
+        {
+            $Block['depth'] ++;
+        }
+
+        if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close
+        {
+            if ($Block['depth'] > 0)
+            {
+                $Block['depth'] --;
+            }
+            else
+            {
+                $Block['closed'] = true;
+            }
+        }
+
+        if (isset($Block['interrupted']))
+        {
+            $Block['element']['rawHtml'] .= "\n";
+            unset($Block['interrupted']);
+        }
+
+        $Block['element']['rawHtml'] .= "\n".$Line['body'];
+
+        return $Block;
+    }
+
+    protected function blockMarkupComplete($Block)
+    {
+        if ( ! isset($Block['void']))
+        {
+            $Block['element']['rawHtml'] = $this->processTag($Block['element']['rawHtml']);
+        }
+
+        return $Block;
+    }
+
+    #
+    # Setext
+
+    protected function blockSetextHeader($Line, ?array $Block = null)
+    {
+        $Block = parent::blockSetextHeader($Line, $Block);
+
+        if ($Block !== null && preg_match('/[ ]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE))
+        {
+            $attributeString = $matches[1][0];
+
+            $Block['element']['attributes'] = $this->parseAttributeData($attributeString);
+
+            $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]);
+        }
+
+        return $Block;
+    }
+
+    #
+    # Inline Elements
+    #
+
+    #
+    # Footnote Marker
+
+    protected function inlineFootnoteMarker($Excerpt)
+    {
+        if (preg_match('/^\[\^(.+?)\]/', $Excerpt['text'], $matches))
+        {
+            $name = $matches[1];
+
+            if ( ! isset($this->DefinitionData['Footnote'][$name]))
+            {
+                return;
+            }
+
+            $this->DefinitionData['Footnote'][$name]['count'] ++;
+
+            if ( ! isset($this->DefinitionData['Footnote'][$name]['number']))
+            {
+                $this->DefinitionData['Footnote'][$name]['number'] = ++ $this->footnoteCount; # » &
+            }
+
+            $Element = array(
+                'name' => 'sup',
+                'attributes' => array('id' => 'fnref'.$this->DefinitionData['Footnote'][$name]['count'].':'.$name),
+                'element' => array(
+                    'name' => 'a',
+                    'attributes' => array('href' => '#fn:'.$name, 'class' => 'footnote-ref'),
+                    'text' => $this->DefinitionData['Footnote'][$name]['number'],
+                ),
+            );
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => $Element,
+            );
+        }
+    }
+
+    private $footnoteCount = 0;
+
+    #
+    # Link
+
+    protected function inlineLink($Excerpt)
+    {
+        $Link = parent::inlineLink($Excerpt);
+
+        $remainder = $Link !== null ? substr($Excerpt['text'], $Link['extent']) : '';
+
+        if (preg_match('/^[ ]*{('.$this->regexAttribute.'+)}/', $remainder, $matches))
+        {
+            $Link['element']['attributes'] += $this->parseAttributeData($matches[1]);
+
+            $Link['extent'] += strlen($matches[0]);
+        }
+
+        return $Link;
+    }
+
+    #
+    # ~
+    #
+
+    private $currentAbreviation;
+    private $currentMeaning;
+
+    protected function insertAbreviation(array $Element)
+    {
+        if (isset($Element['text']))
+        {
+            $Element['elements'] = self::pregReplaceElements(
+                '/\b'.preg_quote($this->currentAbreviation, '/').'\b/',
+                array(
+                    array(
+                        'name' => 'abbr',
+                        'attributes' => array(
+                            'title' => $this->currentMeaning,
+                        ),
+                        'text' => $this->currentAbreviation,
+                    )
+                ),
+                $Element['text']
+            );
+
+            unset($Element['text']);
+        }
+
+        return $Element;
+    }
+
+    protected function inlineText($text)
+    {
+        $Inline = parent::inlineText($text);
+
+        if (isset($this->DefinitionData['Abbreviation']))
+        {
+            foreach ($this->DefinitionData['Abbreviation'] as $abbreviation => $meaning)
+            {
+                $this->currentAbreviation = $abbreviation;
+                $this->currentMeaning = $meaning;
+
+                $Inline['element'] = $this->elementApplyRecursiveDepthFirst(
+                    array($this, 'insertAbreviation'),
+                    $Inline['element']
+                );
+            }
+        }
+
+        return $Inline;
+    }
+
+    #
+    # Util Methods
+    #
+
+    protected function addDdElement(array $Line, array $Block)
+    {
+        $text = substr($Line['text'], 1);
+        $text = trim($text);
+
+        unset($Block['dd']);
+
+        $Block['dd'] = array(
+            'name' => 'dd',
+            'handler' => array(
+                'function' => 'lineElements',
+                'argument' => $text,
+                'destination' => 'elements'
+            ),
+        );
+
+        if (isset($Block['interrupted']))
+        {
+            $Block['dd']['handler']['function'] = 'textElements';
+
+            unset($Block['interrupted']);
+        }
+
+        $Block['element']['elements'] []= & $Block['dd'];
+
+        return $Block;
+    }
+
+    protected function buildFootnoteElement()
+    {
+        $Element = array(
+            'name' => 'div',
+            'attributes' => array('class' => 'footnotes'),
+            'elements' => array(
+                array('name' => 'hr'),
+                array(
+                    'name' => 'ol',
+                    'elements' => array(),
+                ),
+            ),
+        );
+
+        uasort($this->DefinitionData['Footnote'], 'self::sortFootnotes');
+
+        foreach ($this->DefinitionData['Footnote'] as $definitionId => $DefinitionData)
+        {
+            if ( ! isset($DefinitionData['number']))
+            {
+                continue;
+            }
+
+            $text = $DefinitionData['text'];
+
+            $textElements = parent::textElements($text);
+
+            $numbers = range(1, $DefinitionData['count']);
+
+            $backLinkElements = array();
+
+            foreach ($numbers as $number)
+            {
+                $backLinkElements[] = array('text' => ' ');
+                $backLinkElements[] = array(
+                    'name' => 'a',
+                    'attributes' => array(
+                        'href' => "#fnref$number:$definitionId",
+                        'rev' => 'footnote',
+                        'class' => 'footnote-backref',
+                    ),
+                    'rawHtml' => '&#8617;',
+                    'allowRawHtmlInSafeMode' => true,
+                    'autobreak' => false,
+                );
+            }
+
+            unset($backLinkElements[0]);
+
+            $n = count($textElements) -1;
+
+            if ($textElements[$n]['name'] === 'p')
+            {
+                $backLinkElements = array_merge(
+                    array(
+                        array(
+                            'rawHtml' => '&#160;',
+                            'allowRawHtmlInSafeMode' => true,
+                        ),
+                    ),
+                    $backLinkElements
+                );
+
+                unset($textElements[$n]['name']);
+
+                $textElements[$n] = array(
+                    'name' => 'p',
+                    'elements' => array_merge(
+                        array($textElements[$n]),
+                        $backLinkElements
+                    ),
+                );
+            }
+            else
+            {
+                $textElements[] = array(
+                    'name' => 'p',
+                    'elements' => $backLinkElements
+                );
+            }
+
+            $Element['elements'][1]['elements'] []= array(
+                'name' => 'li',
+                'attributes' => array('id' => 'fn:'.$definitionId),
+                'elements' => array_merge(
+                    $textElements
+                ),
+            );
+        }
+
+        return $Element;
+    }
+
+    # ~
+
+    protected function parseAttributeData($attributeString)
+    {
+        $Data = array();
+
+        $attributes = preg_split('/[ ]+/', $attributeString, - 1, PREG_SPLIT_NO_EMPTY);
+
+        foreach ($attributes as $attribute)
+        {
+            if ($attribute[0] === '#')
+            {
+                $Data['id'] = substr($attribute, 1);
+            }
+            else # "."
+            {
+                $classes []= substr($attribute, 1);
+            }
+        }
+
+        if (isset($classes))
+        {
+            $Data['class'] = implode(' ', $classes);
+        }
+
+        return $Data;
+    }
+
+    # ~
+
+    protected function processTag($elementMarkup) # recursive
+    {
+        # http://stackoverflow.com/q/1148928/200145
+        libxml_use_internal_errors(true);
+
+        $DOMDocument = new DOMDocument;
+
+        # http://stackoverflow.com/q/11309194/200145
+        $elementMarkup = mb_convert_encoding($elementMarkup, 'HTML-ENTITIES', 'UTF-8');
+
+        # http://stackoverflow.com/q/4879946/200145
+        $DOMDocument->loadHTML($elementMarkup);
+        $DOMDocument->removeChild($DOMDocument->doctype);
+        $DOMDocument->replaceChild($DOMDocument->firstChild->firstChild->firstChild, $DOMDocument->firstChild);
+
+        $elementText = '';
+
+        if ($DOMDocument->documentElement->getAttribute('markdown') === '1')
+        {
+            foreach ($DOMDocument->documentElement->childNodes as $Node)
+            {
+                $elementText .= $DOMDocument->saveHTML($Node);
+            }
+
+            $DOMDocument->documentElement->removeAttribute('markdown');
+
+            $elementText = "\n".$this->text($elementText)."\n";
+        }
+        else
+        {
+            foreach ($DOMDocument->documentElement->childNodes as $Node)
+            {
+                $nodeMarkup = $DOMDocument->saveHTML($Node);
+
+                if ($Node instanceof DOMElement and ! in_array($Node->nodeName, $this->textLevelElements))
+                {
+                    $elementText .= $this->processTag($nodeMarkup);
+                }
+                else
+                {
+                    $elementText .= $nodeMarkup;
+                }
+            }
+        }
+
+        # because we don't want for markup to get encoded
+        $DOMDocument->documentElement->nodeValue = 'placeholder\x1A';
+
+        $markup = $DOMDocument->saveHTML($DOMDocument->documentElement);
+        $markup = str_replace('placeholder\x1A', $elementText, $markup);
+
+        return $markup;
+    }
+
+    # ~
+
+    protected function sortFootnotes($A, $B) # callback
+    {
+        return $A['number'] - $B['number'];
+    }
+
+    #
+    # Fields
+    #
+
+    protected $regexAttribute = '(?:[#.][-\w]+[ ]*)';
+}

+ 80 - 0
contracts/add_stage.php

@@ -0,0 +1,80 @@
+<?php
+//error_reporting(E_ERROR | E_PARSE);
+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");
+
+require_once 'config.php';
+
+$cfg = require __DIR__ . '/config.php';
+
+use PHPMailer\PHPMailer\PHPMailer;
+use PHPMailer\PHPMailer\Exception;
+
+require_once __DIR__ . '/vendor/autoload.php';
+$cfg = require __DIR__ . '/config.php';
+
+$dsn = 'mysql:host=' . $cfg['db_host'] . ';dbname=' . $cfg['db_name'] . ';charset=utf8mb4';
+$options = [
+    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
+    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+];
+
+try {
+    $pdo = new PDO($dsn, $cfg['db_username'], $cfg['db_password'], $options);
+} catch (PDOException $e) {
+    exit('Database connection failed: ' . $e->getMessage());
+}
+
+$app_id = $_POST['application_id'];
+$title = $_POST['title'];
+$desc = $_POST['description'];
+
+// Save stage
+$stmt = $pdo->prepare("INSERT INTO application_stages (application_id, title, description) VALUES (?, ?, ?)");
+$stmt->execute([$app_id, $title, $desc]);
+
+// Fetch client email
+$stmt = $pdo->prepare("SELECT client_email FROM applications WHERE id = ?");
+$stmt->execute([$app_id]);
+$email = $stmt->fetchColumn();
+
+
+
+function sendStageEmail($to, $title, $desc, $viewUrl) {
+    global $cfg;
+
+    $mail = new PHPMailer(true);
+    $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'];
+
+    $mail->setFrom($cfg['from_address'], $cfg['dev_company']);
+    $mail->addAddress($to);
+    $mail->isHTML(true);
+
+    $subject = "Council Application Progress Update";
+    $html = <<<HTML
+<p>Hello,</p>
+<p>Your application has reached a new stage: <strong>{$title}</strong></p>
+<p>{$desc}</p>
+<p><a href="{$viewUrl}" class="btn btn-primary">View Application Progress</a></p>
+<p>Kind regards,<br>{$cfg['dev_name']}<br>{$cfg['dev_company']}</p>
+HTML;
+
+    $mail->Subject = $subject;
+    $mail->Body    = $html;
+    $mail->AltBody = "New update: $title\n\n$desc\n\nView: $viewUrl";
+
+    $mail->send();
+}
+
+// Redirect back to admin dashboard
+header("Location: admin_dashboard.php");

+ 100 - 0
contracts/admin_dashboard.php

@@ -0,0 +1,100 @@
+<?php
+error_reporting(E_ALL);
+ini_set("display_errors", 1);
+
+date_default_timezone_set("Australia/Hobart");
+
+$cfg = require __DIR__ . '/config.php';
+
+// HTTP Basic Auth — must be configured in .env
+$_au = $cfg['admin_user'] ?? '';
+$_ap = $cfg['admin_pass'] ?? '';
+if ($_au === '' || $_ap === '' ||
+    !isset($_SERVER['PHP_AUTH_USER']) ||
+    $_SERVER['PHP_AUTH_USER'] !== $_au ||
+    ($_SERVER['PHP_AUTH_PW'] ?? '') !== $_ap) {
+    header('WWW-Authenticate: Basic realm="Modulos Contracts Admin"');
+    header('HTTP/1.0 401 Unauthorized');
+    echo 'Authentication required.';
+    exit;
+}
+unset($_au, $_ap);
+
+$dsn = 'mysql:host=' . $cfg['db_host'] . ';dbname=' . $cfg['db_name'] . ';charset=utf8mb4';
+$options = [
+    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
+    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+];
+
+try {
+    $pdo = new PDO($dsn, $cfg['db_username'], $cfg['db_password'], $options);
+} catch (PDOException $e) {
+    exit('Database connection failed: ' . $e->getMessage());
+}
+
+$app_id_raw = $_GET['id'] ?? '';
+$token      = $_GET['token'] ?? '';
+
+$app_id = preg_match('/^\d+$/', $app_id_raw) ? $app_id_raw : '0';
+
+// Fetch applications
+$stmt = $pdo->query("SELECT id, reference, client_email FROM applications ORDER BY id DESC");
+$applications = $stmt->fetchAll();
+?>
+
+<!doctype html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <title>Admin Dashboard – Application Stages</title>
+        <link rel="shortcut icon" href="../internal/images/blueprint.ico" type="image/x-icon">
+
+        <meta name="robots" content="noindex">
+        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
+        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js" integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q" crossorigin="anonymous"></script>
+        <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">
+
+    </head>
+    <body class="bg-light">
+        <nav class="navbar bg-brown-dark brown-light border-bottom border-body d-print-none">
+            <div class="container-fluid">
+                <span class="navbar-brand brown-light">
+                    <img src="../internal/images/blueprint-logo-light.png" alt="Logo" width="30" height="24" class="d-inline-block align-text-top">
+                    Modulos Design
+                </span>
+                <div class="ms-auto d-flex gap-2">
+                    <a href="../internal/dashboard.php" class="btn btn-sm btn-outline-light"><i class="bi bi-grid-fill"></i> Dashboard</a>
+                </div>
+            </div>
+        </nav>
+        <div class="container my-5">
+            <h2 class="mb-4">Applications</h2>
+            <table class="table table-bordered">
+                <thead class="table-light">
+                    <tr>
+                        <th>ID</th>
+                        <th>Reference</th>
+                        <th>Client Email</th>
+                        <th>Actions</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <?php foreach ($applications as $app): ?>
+                    <tr>
+                        <td><?= $app['id'] ?></td>
+                        <td><?= htmlspecialchars($app['reference']) ?></td>
+                        <td><?= htmlspecialchars($app['client_email']) ?></td>
+                        <td>
+                            <a href="edit_application.php?id=<?= $app['id'] ?>" class="btn btn-sm btn-primary">Edit Timeline</a>
+                            <a href="progress.php?id=<?= $app['id'] ?>" class="btn btn-sm btn-outline-secondary">View as Client</a>
+                        </td>
+                    </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+        </div>
+    </body>
+</html>

binární
contracts/applicant_signature.png


+ 582 - 0
contracts/breadcrumb.php

@@ -0,0 +1,582 @@
+<?php
+error_reporting(E_ALL);
+ini_set("display_errors", 1);
+
+date_default_timezone_set("Australia/Hobart");
+
+// Adjust path if your contracts live elsewhere.
+if (!defined('CONTRACTS_DIR')) {
+    define('CONTRACTS_DIR', realpath(__DIR__ . '/contracts'));
+}
+
+function contract_path_for_client(string $clientid): string {
+    $id = preg_replace('/[^A-Za-z0-9_-]/', '', $clientid);
+    return rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . $id . '.md';
+}
+
+/** Very small front-matter puller; same idea as contracts admin */
+function extract_front_matter_fields_progress(string $file): array {
+    $out = [];
+    $txt = @file_get_contents($file);
+    if (!$txt) return $out;
+    if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) return $out;
+    $fm = $m[1];
+    // admin.secret or admin_secret
+    if (preg_match('/^\s*admin\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block)) {
+        $adminBlock = $block[1];
+        if (preg_match('/^\s*secret\s*:\s*["\']?([^"\']+)["\']?/mi', $adminBlock, $mm)) {
+            $out['admin_secret'] = trim($mm[1]);
+        }
+    }
+    if (empty($out['admin_secret']) && preg_match('/^\s*admin_secret\s*:\s*["\']?([^"\']+)["\']?/mi', $fm, $mm)) {
+        $out['admin_secret'] = trim($mm[1]);
+    }
+    return $out;
+}
+
+/** Build the exact token we expect for the public progress page */
+function progress_expected_token(string $clientid, $appId): ?string {
+    $path = contract_path_for_client($clientid);
+    $fm   = extract_front_matter_fields_progress($path);
+    $secret = $fm['admin_secret'] ?? '';
+    if ($secret === '') return null;
+    return hash_hmac('sha256', 'progress|' . (string)$appId, $secret);
+}
+
+
+$cfg = require __DIR__ . '/config.php';
+
+$dsn = 'mysql:host=' . $cfg['db_host'] . ';dbname=' . $cfg['db_name'] . ';charset=utf8mb4';
+$options = [
+    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
+    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+];
+
+try {
+    $pdo = new PDO($dsn, $cfg['db_username'], $cfg['db_password'], $options);
+} catch (PDOException $e) {
+    exit('Database connection failed: ' . $e->getMessage());
+}
+
+$app_id_raw = $_GET['id'] ?? '';
+$token      = $_GET['token'] ?? '';
+
+$app_id = preg_match('/^\d+$/', $app_id_raw) ? $app_id_raw : '0';
+
+// Verify token (optional: match your token logic)
+$stmt = $pdo->prepare("SELECT client_email, reference, created_at, submission_date, required_by FROM applications WHERE id = ?");
+$stmt->execute([$app_id]);
+$app = $stmt->fetch(PDO::FETCH_ASSOC);
+
+if (!$app) {
+    http_response_code(404);
+    exit("Application not found.");
+}
+
+// Fetch stages
+$stmt = $pdo->prepare("SELECT * FROM application_stages WHERE application_id = ? ORDER BY position ASC");
+$stmt->execute([$app_id]);
+$stages = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+$totalStages = 7;
+$currentStage = count(array_filter($stages, function ($s) {
+    return strtolower(trim($s['status'] ?? '')) === 'complete';
+}));
+$progress = round(($currentStage / $totalStages) * 100);
+
+$decisionDate = null;
+
+// 1) Look for an explicit 'Council Decision Due' stage date
+$decisionStage = null;
+foreach ($stages as $s) {
+    if (stripos($s['title'] ?? '', 'decision') !== false && !empty($s['stage_date'])) {
+        $decisionStage = $s;
+        break;
+    }
+}
+
+if ($decisionStage) {
+    $decisionDate = new DateTime($decisionStage['stage_date'], new DateTimeZone('Australia/Hobart'));
+} elseif (!empty($app['required_by'])) {
+    $decisionDate = new DateTime($app['required_by'], new DateTimeZone('Australia/Hobart'));
+} elseif (!empty($app['submission_date'])) {
+    $decisionDate = (new DateTime($app['submission_date'], new DateTimeZone('Australia/Hobart')))->modify('+42 days');
+}
+
+// set a friendly “end of business day” time so the countdown isn’t midnight-awkward
+if ($decisionDate) { $decisionDate->setTime(17, 0, 0); }
+$decisionIso = $decisionDate ? $decisionDate->format('c') : '';
+
+// --- Create correspondence entry ---
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'add_correspondence') {
+    $tz = new DateTimeZone('Australia/Hobart');
+
+    $typeAllow      = ['incoming','outgoing','note'];
+    $channelAllow   = ['email','phone','portal','letter','meeting','other'];
+    $visibilityAllow= ['client','internal'];
+
+    $type       = in_array($_POST['type'] ?? 'note', $typeAllow, true) ? $_POST['type'] : 'note';
+    $channel    = in_array($_POST['channel'] ?? 'other', $channelAllow, true) ? $_POST['channel'] : 'other';
+    $visibility = in_array($_POST['visibility'] ?? 'client', $visibilityAllow, true) ? $_POST['visibility'] : 'client';
+    $subject    = trim($_POST['subject'] ?? '') ?: null;
+    $author     = trim($_POST['author'] ?? '') ?: null;
+    $pin        = isset($_POST['pin']) ? 1 : 0;
+
+    $bodyRaw    = trim($_POST['body'] ?? '');
+    if ($bodyRaw === '') { $bodyRaw = '(no content)'; }
+
+    // event_at: prefer user input, else "now"
+    $eventAtRaw = trim($_POST['event_at'] ?? '');
+    try {
+        $eventAt = $eventAtRaw ? new DateTime($eventAtRaw, $tz) : new DateTime('now', $tz);
+    } catch (Exception $e) {
+        $eventAt = new DateTime('now', $tz);
+    }
+
+    $stmt = $pdo->prepare("
+        INSERT INTO application_correspondence
+        (application_id, event_at, type, channel, subject, body, author, visibility, pin)
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+    ");
+    $stmt->execute([
+        $app_id,
+        $eventAt->format('Y-m-d H:i:s'),
+        $type,
+        $channel,
+        $subject,
+        $bodyRaw,
+        $author,
+        $visibility,
+        $pin
+    ]);
+
+    // Redirect to avoid resubmission and jump to the timeline section
+    header("Location: ".$_SERVER['REQUEST_URI']."#correspondence");
+    exit;
+}
+
+// Fetch timeline (newest first; pinned first)
+$stmt = $pdo->prepare("
+  SELECT id, event_at, type, channel, subject, body, author, visibility, pin, created_at
+  FROM application_correspondence
+  WHERE application_id = ?
+  ORDER BY pin DESC, event_at DESC, id DESC
+  LIMIT 200
+");
+$stmt->execute([$app_id]);
+$correspondence = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+/* NEW: attachment counts (and optional details) */
+$fileCounts = [];
+$filesByCorr = []; // if you also want to list the files
+
+if (!empty($correspondence)) {
+    $ids = array_column($correspondence, 'id');
+    $ph  = implode(',', array_fill(0, count($ids), '?'));
+
+    // Count files per correspondence
+    $qc = $pdo->prepare("
+        SELECT correspondence_id, COUNT(*) AS n
+        FROM application_correspondence_files
+        WHERE correspondence_id IN ($ph)
+        GROUP BY correspondence_id
+    ");
+    $qc->execute($ids);
+    foreach ($qc->fetchAll(PDO::FETCH_ASSOC) as $r) {
+        $fileCounts[(int)$r['correspondence_id']] = (int)$r['n'];
+    }
+
+    // OPTIONAL: load file details if you want links
+    $qd = $pdo->prepare("
+        SELECT correspondence_id, original_name, file_url
+        FROM application_correspondence_files
+        WHERE correspondence_id IN ($ph)
+        ORDER BY id ASC
+    ");
+    $qd->execute($ids);
+    foreach ($qd->fetchAll(PDO::FETCH_ASSOC) as $f) {
+        $cid = (int)$f['correspondence_id'];
+        if (!isset($filesByCorr[$cid])) $filesByCorr[$cid] = [];
+        $filesByCorr[$cid][] = $f;
+    }
+}
+
+
+// ------------------ HELPERS ------------------
+function render_body_html(string $text): string {
+    // escape first
+    $s = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
+    // linkify http(s)
+    $s = preg_replace('~(https?://[^\s<]+)~i', '<a href="$1" target="_blank" rel="noopener">$1</a>', $s);
+    // newlines to <br>
+    return nl2br($s);
+}
+
+// --- Require signed token from Contracts Admin link ---
+$clientid = $_GET['clientid'] ?? '';
+$token    = $_GET['token']    ?? '';
+
+if (!preg_match('/^[A-Za-z0-9_-]+$/', $clientid)) {
+    http_response_code(400);
+    exit('Bad link (clientid).');
+}
+if ($token === '') {
+    http_response_code(403);
+    exit('Missing token.');
+}
+
+// Build expected token from the .md front matter secret
+$expected = progress_expected_token($clientid, $app_id);
+if (!$expected || !hash_equals($expected, $token)) {
+    http_response_code(403);
+    exit('Invalid or expired link.');
+}
+
+?>
+
+<!doctype html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <title><?= htmlspecialchars($app['reference']) ?> – Application Progress</title>
+        <link rel="shortcut icon" href="../../internal/images/blueprint.ico" type="image/x-icon">
+
+        <meta name="robots" content="noindex">
+        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
+        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js" integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q" crossorigin="anonymous"></script>
+        <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.13.1/font/bootstrap-icons.min.css">
+        <link href="progress.css" rel="stylesheet">
+		<style>
+		.popover-attachments { max-width: 360px; }
+		.popover-attachments .popover-body { padding: .5rem .75rem; }
+		</style>
+    </head>
+    <body>
+        <!--
+<nav class="navbar bg-dark border-bottom border-body d-print-none" data-bs-theme="dark">
+<div class="container">
+<a class="navbar-brand text-white" href="#">
+<img src="../internal/images/blueprint-logo-light.png" width="30" height="24" class="d-inline-block align-text-top" alt="Modulos">
+Modulos Design
+</a>
+</div>
+</nav>
+-->
+
+        <main 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-12 col-md-6 text-start">
+                        <!-- CUSTOMER DETAILS HERE -->
+                    </div>
+                    <div class="col-12 col-md-6 text-end pt-2">
+        				<h3 class="fw-bold mb-1 text-dark">Development Application</h3>
+                        <h3 class="fw-bold mb-1 text-dark">Application No: <?= htmlspecialchars($app['reference']) ?></h3>
+                        <h5 class="mb-0 text-muted">Started: <?= date("d M Y", strtotime($app['created_at'])) ?></h5>
+                    </div>
+                </div>
+
+                <hr class="my-4">
+
+                <div class="row my-4">
+                    <div class="col-12 align-self-center">
+                        <div class="steps">
+                            <?php
+    $labels = ['Submit','Acknowledge','Paid','Confirmed','Advertise','Complete','Decision'];
+            $N = count($labels);
+
+            // Build status + date arrays by position
+            $statusByIndex = array_fill(0, $N, 'pending');
+            $dateByIndex   = array_fill(0, $N, null);
+
+            foreach ($stages as $row) {
+                $idx = (int)($row['position'] ?? -1);
+                if ($idx < 0 || $idx >= $N) continue;
+
+                $st = strtolower(trim($row['status'] ?? 'pending'));
+                if (!in_array($st, ['complete','current','pending'], true)) $st = 'pending';
+                $statusByIndex[$idx] = $st;
+
+                $dateByIndex[$idx] = $row['stage_date'] ?: ($row['updated_at'] ?: ($row['created_at'] ?? null));
+            }
+
+            // If no explicit "current", highlight the *last complete* stage
+            $hasCurrent = false;
+            foreach ($statusByIndex as $st) { if (strpos($st, 'current') !== false) { $hasCurrent = true; break; } }
+            if (!$hasCurrent) {
+                $lastComplete = -1;
+                for ($i = 0; $i < $N; $i++) if ($statusByIndex[$i] === 'complete') $lastComplete = $i;
+                if ($lastComplete >= 0) {
+                    $statusByIndex[$lastComplete] = 'current'; // keep green, add highlight
+                } else {
+                    $statusByIndex[0] = trim($statusByIndex[0] . ' current'); // nothing complete yet
+                }
+            }
+
+            $fmt = function (?string $s): string {
+                if (!$s) return '';
+                $t = strtotime($s);
+                return $t ? date('d M Y', $t) : '';
+            };
+                            ?>
+
+                            <!-- Top row: arrows + inline (mobile-only) dates -->
+                            <ul class="step-menu">
+                                <?php for ($i = 0; $i < $N; $i++):
+                                $class    = htmlspecialchars($statusByIndex[$i]);
+                                $dateText = $fmt($dateByIndex[$i]);
+                                $isComplete = (strpos($class, 'complete') !== false);
+                                $prefix   = (!$isComplete && $dateText) ? 'Due ' : '';
+                                ?>
+                                <li class="<?= $class ?>">
+                                    <?= htmlspecialchars($labels[$i]) ?>
+                                    <?php if ($dateText): ?>
+                                    <div class="date-inline small mt-1 d-xxl-none"><?= htmlspecialchars($prefix . $dateText) ?></div>
+                                    <?php endif; ?>
+                                </li>
+                                <?php endfor; ?>
+                            </ul>
+
+                            <!-- Bottom row: desktop-only date line, aligned with arrows -->
+                            <ul class="step-dates d-none d-xxl-flex">
+                                <?php for ($i = 0; $i < $N; $i++):
+                                $rawClass   = trim((string)($statusByIndex[$i] ?? ''));
+                                $tokens     = preg_split('/\s+/', $rawClass, -1, PREG_SPLIT_NO_EMPTY);
+                                $isComplete = in_array('complete', $tokens, true);
+                                $isCurrent  = in_array('current',  $tokens, true);
+
+                                $dateText = $fmt($dateByIndex[$i]);
+                                $prefix   = (!$isComplete && !$isCurrent && $dateText) ? 'Due ' : '';
+                                ?>
+                                <li class="<?= htmlspecialchars($rawClass, ENT_QUOTES, 'UTF-8') ?>">
+                                    <?= $dateText ? htmlspecialchars($prefix . $dateText, ENT_QUOTES, 'UTF-8') : '&nbsp;' ?>
+                                </li>
+                                <?php endfor; ?>
+                            </ul>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="row py-3">
+                    <div class="col-12">
+                        <?php if (!empty($decisionIso)): ?>
+                        <div class="countdown" data-target-date="<?php echo $decisionIso; ?>"></div>
+                        <?php endif; ?>
+                    </div>
+                </div>
+
+                <div class="row">
+                    <?php if (empty($stages)): ?>
+                    <div class="col-12">
+                        <div class="alert alert-warning">This application has not started yet.</div>
+                    </div>
+                    <?php endif; ?>
+                </div>
+
+                <hr class="my-4">
+                <div class="row">
+                    <div class="col-12 text-center">
+                        <h4>Timeline of Correspondence</h4>
+                    </div>
+                </div>
+                <div class="row py-3">
+                    <div class="col">
+                        <div class="timeline">
+                            <?php
+                            $badgeMap = [
+                                'email_incoming' => 'bi-envelope-arrow-up',
+                                'email_outgoing' => 'bi-send-check',
+                                'phone_incoming' => 'bi-telephone-inbound',
+                                'phone_outgoing' => 'bi-telephone-outbound',
+                                'note'           => 'bi-journal-text'
+                            ];
+                            $fallbackByChannel = [
+                                'email'   => 'bi-envelope',
+                                'phone'   => 'bi-telephone',
+                                'meeting' => 'bi-people',
+                                'other'   => 'bi-chat-dots'
+                            ];
+
+                            $typeLabel = ['incoming'=>'Incoming','outgoing'=>'Outgoing','note'=>'Note'];
+                            $chLabel   = ['email'=>'Email','phone'=>'Phone','meeting'=>'Meeting','other'=>'Other'];
+
+                            foreach ($correspondence as $row):
+                            $id = (int)$row['id'];
+                            $typeVal    = strtolower(trim($row['type'] ?? 'note'));
+                            $channelVal = strtolower(trim($row['channel'] ?? 'other'));
+                            $key        = ($typeVal === 'note') ? 'note' : "{$channelVal}_{$typeVal}";
+                            $icon       = $badgeMap[$key] ?? ($fallbackByChannel[$channelVal] ?? 'bi-journal-text');
+
+                            $when      = (new DateTime($row['event_at'], new DateTimeZone('Australia/Hobart')))->format('d M Y, h:ia');
+                            $visBadge  = $row['visibility']==='internal' ? '<span class="badge rounded-pill text-bg-secondary ms-2">Internal</span>' : '';
+                            $typeClass = 'type-'.preg_replace('/[^a-z]/','', $typeVal);
+
+                            $numFiles = $fileCounts[$id] ?? 0;                    // <- NEW
+                            $hasFiles = $numFiles > 0;                            // <- NEW
+                            ?>
+                            <div class="timeline-item <?= $row['pin'] ? 'pinned' : '' ?>">
+                                <div class="timeline-badge"><i class="bi <?= $icon ?>"></i></div>
+                                <div class="timeline-panel <?= $typeClass ?>">
+                                    <div class="timeline-heading d-flex justify-content-between align-items-start">
+                                        <div>
+                                            <h6 class="mb-1">
+                                                <?= $when ?> • <?= $typeLabel[$typeVal] ?? ucfirst($typeVal) ?>
+                                                via <?= $chLabel[$channelVal] ?? ucfirst($channelVal) ?>
+                                                <?= $visBadge ?>
+                                                <?php if ($row['pin']): ?>
+                                                <i class="bi bi-pin-angle-fill text-warning ms-1" title="Pinned"></i>
+                                                <?php endif; ?>
+
+                                                
+                                                <?php if (!empty($filesByCorr[$id])): ?>
+                                                <span class="ms-2 att-pop"
+                                                      role="button"
+                                                      tabindex="0"
+                                                      data-bs-toggle="popover"
+                                                      data-bs-trigger="hover focus"
+                                                      data-bs-placement="top"
+                                                      data-bs-custom-class="popover-attachments"
+                                                      data-content-id="att-popover-<?= $id ?>"
+                                                      title="Attachments (<?= (int)$numFiles ?>)">
+                                                    <i class="bi bi-paperclip"></i>
+                                                </span>
+                                                
+                                                
+                                                <?php endif; ?>
+                                                
+
+                                            </h6>
+                                            <small class="text-muted">
+                                                <?= htmlspecialchars($row['subject'] ?: ucfirst($typeVal)) ?>
+                                                <?= $row['author'] ? ' • by: '.htmlspecialchars($row['author']) : '' ?>
+                                            </small>
+                                        </div>
+                                    </div>
+
+                                    <div class="timeline-body mt-2 small">
+                                        <?= render_body_html($row['body']) ?>
+                                    </div>
+                                </div>
+                            </div>
+                            <?php endforeach; ?>
+
+
+                            <?php if (empty($correspondence)): ?>
+                            <div class="text-muted">No correspondence recorded yet.</div>
+                            <?php endif; ?>
+
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+        </main>
+        <script>
+            const countdownEls = document.querySelectorAll(".countdown")
+            countdownEls.forEach(countdownEl => createCountdown(countdownEl))
+
+            function createCountdown(countdownEl){
+                const target = new Date(new Date(countdownEl.dataset.targetDate).toLocaleString('en', ))
+                const parts = {
+                    days: {text: ["days","day"], dots: 30},
+                    hours: {text: ["hours","hour"], dots: 24},
+                    minutes: {text: ["minutes","minute"], dots: 60},
+                    seconds: {text: ["seconds","second"], dots: 60},
+                }
+
+                Object.entries(parts).forEach(([key, value])=>{
+                    const partEl = document.createElement("div");
+                    partEl.classList.add("part", key);
+                    partEl.style.setProperty("--dots", value.dots);
+                    value.element = partEl;
+
+                    const remainingEl = document.createElement("div");
+                    remainingEl.classList.add("remaining");
+                    remainingEl.innerHTML = `<span class="number"></span><span class="text"></span>`
+                    partEl.append(remainingEl);
+                    for(let i = 0; i < value.dots; i++){
+                        const dotContainerEl = document.createElement("div");
+                        dotContainerEl.style.setProperty("--dot-idx", i);
+                        dotContainerEl.classList.add("dot-container")
+                        const dotEl = document.createElement("div");
+                        dotEl.classList.add("dot")
+                        dotContainerEl.append(dotEl);
+                        partEl.append(dotContainerEl);
+                    }
+                    countdownEl.append(partEl);
+                })
+                getRemainingTime(target, parts)
+            }
+
+            function getRemainingTime(target, parts, first=true){
+                const now = new Date()
+                if(first) console.log({target, now})
+                const remaining = {}
+                let seconds = Math.floor((target - (now))/1000);
+                let minutes = Math.floor(seconds/60);
+                let hours = Math.floor(minutes/60);
+                let days = Math.floor(hours/24);
+                hours = hours-(days*24);
+                minutes = minutes-(days*24*60)-(hours*60);
+                seconds = seconds-(days*24*60*60)-(hours*60*60)-(minutes*60);
+                Object.entries({days, hours, minutes, seconds}).forEach(([key, value])=>{
+                    const remaining = parts[key].element.querySelector(".number");
+                    const text = parts[key].element.querySelector(".text");
+                    remaining.innerText = value;
+                    text.innerText = parts[key].text[Number(value==1)]
+                    const dots = parts[key].element.querySelectorAll(".dot")
+                    dots.forEach((dot, idx)=>{
+                        dot.dataset.active = idx <= value;
+                        dot.dataset.lastactive = idx == value;
+                    })
+                })
+                if(now <= target){
+                    window.requestAnimationFrame(()=>{
+                        getRemainingTime(target, parts, false)
+                    });
+                }
+            }
+
+            document.getElementById('tryParse')?.addEventListener('click', function(e){
+            e.preventDefault();
+            const body = document.getElementById('corrBody').value || '';
+            const subj = /(?:^|\n)Subject:\s*(.+)/i.exec(body);
+            const from = /(?:^|\n)From:\s*(.+)/i.exec(body);
+            const date = /(?:^|\n)Date:\s*(.+)/i.exec(body);
+
+            if (subj) document.getElementById('corrSubject').value = subj[1].trim();
+            if (from) document.getElementById('corrAuthor').value  = from[1].trim();
+
+            if (date) {
+            const guess = new Date(date[1]);
+            if (!isNaN(guess.getTime())) {
+            // to local datetime-local string
+            const pad = n => String(n).padStart(2,'0');
+            const v = guess.getFullYear() + '-' + pad(guess.getMonth()+1) + '-' + pad(guess.getDate())
+                + 'T' + pad(guess.getHours()) + ':' + pad(guess.getMinutes());
+            const el = document.querySelector('input[name="event_at"]');
+            if (el) el.value = v;
+            }
+            }
+            });
+
+            document.addEventListener('DOMContentLoaded', () => {
+            document.querySelectorAll('[data-bs-toggle="popover"][data-content-id]').forEach(el => {
+            const id = el.getAttribute('data-content-id');
+            const tpl = document.getElementById(id);
+            const content = tpl ? tpl.innerHTML : '';
+            new bootstrap.Popover(el, {
+                html: true,
+                content,
+                container: 'body',
+                sanitize: true,         // keep Bootstrap’s sanitizer on
+                trigger: 'hover focus'  // hover on desktop, tap/focus on mobile
+            });
+            });
+            });
+        </script>
+    </body>
+</html>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 41 - 0
contracts/config.php


+ 1235 - 0
contracts/contract.php

@@ -0,0 +1,1235 @@
+<?php
+/* ##########################
+[client_email]
+[dev_email]
+[dev_signature]
+[dev_ip]
+[dev_timestamp]
+[dev_timestamp_offset]
+[dev_name]
+[client_name]
+################################### */
+
+//error_reporting(E_ERROR | E_PARSE);
+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");
+
+require_once __DIR__ . "/Parsedown.php";
+require_once __DIR__ . "/dompdf/autoload.inc.php";
+
+use PHPMailer\PHPMailer\PHPMailer;
+use PHPMailer\PHPMailer\SMTP;
+use PHPMailer\PHPMailer\Exception;
+require_once "../internal/phpmailer/src/Exception.php";
+require_once "../internal/phpmailer/src/PHPMailer.php";
+require_once "../internal/phpmailer/src/SMTP.php";
+
+// at the top, before any output
+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 (same) ---------------------------------- */
+$cfg 				= @include __DIR__ . "/config.php";
+$cfg 				= is_array($cfg) ? $cfg : [];
+$csrf 				= htmlspecialchars($_SESSION["csrf"] ?? "", ENT_QUOTES, "UTF-8");
+
+// Behavior flags
+$redirectToSigned 	= true; // keep this true
+$allowSelfDelete  	= (bool)($cfg['self_delete'] ?? false);  // default off
+
+$fromAddress 		= $cfg["from_address"] ?? "drafting@modulosdesign.com.au";
+
+$clientIdRaw 		= $_GET['clientid'] ?? '';
+$clientId    		= preg_match('/^[A-Za-z0-9_-]{1,64}$/', $clientIdRaw) ? $clientIdRaw : 'unknown';
+
+$devName     		= $cfg['dev_name'] ?? 'Modulos Design';
+$devEmail 			= $cfg["dev_email"] ?? 'drafting@modulosdesign.com.au';
+$devEmail 			= preg_replace('/[\r\n]+/', "", $devEmail);
+$devSig 			= $cfg["dev_signature"] ?? "";
+$devPhone      		= $cfg['dev_phone']      ?? '';
+$devAddress    		= $cfg['dev_address']    ?? '';
+
+$clientName  		= $_GET['client_name'] ?? '';
+$clientEmail 		= $_POST['client_email'] ?? $_GET['client_email'] ?? '';
+$clientEmail 		= trim(preg_replace('/[\r\n]+/', '', (string)$clientEmail)); // strip CRLF + trim
+
+$clientPhone   		= $_GET['client_phone']   ?? '';
+$clientAddress 		= $_GET['client_address'] ?? '';
+
+// 🔧 Fallback to front matter if still empty
+if ($clientEmail === '') {
+    $meta = parseFrontMatterForId($clientId);
+    $clientEmailFromMd = '';
+    if (is_array($meta)) {
+        // prefer nested client.email, but accept client_email if present
+        $clientEmailFromMd = (string)(
+            getByPath($meta, 'client.email', '') ?:
+            ($meta['client']['email'] ?? '') ?:
+            ($meta['client_email'] ?? '')
+        );
+    }
+    if ($clientEmailFromMd !== '') {
+        $clientEmail = trim(preg_replace('/[\r\n]+/', '', $clientEmailFromMd));
+    }
+}
+
+
+
+if (is_string($devSig) && $devSig !== '') {
+    // Can be a data: URL or a normal URL; both are fine
+    $DEV_SIGNATURE = '<img id="dev_signature" src="' . htmlspecialchars($devSig, ENT_QUOTES, 'UTF-8') . '" style="max-height: 117px;padding: 10px;" alt="Client Relations Signature">';
+} elseif (!empty($devSigPath) && is_file($devSigPath)) {
+    // Fallback: original file-path logic
+    $abs = realpath($devSigPath) ?: $devSigPath;
+    $prefix = rtrim($_SERVER["DOCUMENT_ROOT"] ?? "", "/");
+    $devSigUrl = str_replace($prefix, "", $abs);
+    if ($devSigUrl === $abs) {
+        // fallback if the file is outside docroot
+        $devSigUrl = $abs;
+    }
+    $DEV_SIGNATURE = '<img id="dev_signature" src="' . htmlspecialchars($devSigUrl, ENT_QUOTES, "UTF-8") . '" style="max-height: 117px;padding: 10px;" alt="Client Relations Signature">';
+}
+
+function loadContractHtml(?string $clientId, array $overrides = []): string {
+    $safeId = preg_match('/^[A-Za-z0-9_-]{1,64}$/', (string)$clientId) ? (string)$clientId : 'default';
+	$path   = __DIR__ . '/contracts/' . $safeId . '.md';
+    if (!is_file($path)) $path = __DIR__ . '/contracts/default.md';
+
+    $md = file_get_contents($path);
+
+    // 1) Split front matter and body
+    $vars = [];
+    $body = $md;
+    if (preg_match('/^\s*---\s*\n(.*?)\n---\s*\n(.*)$/s', $md, $m)) {
+        $front = $m[1];
+        $body  = $m[2];
+        $vars  = parseFrontMatter($front);
+    }
+
+    // 2) Defaults available to every document
+    $base = [
+        'dev' => [
+            'name'    => $GLOBALS['devName']    ?? 'Modulos Design',
+            'email'   => $GLOBALS['devEmail']   ?? 'drafting@modulosdesign.com.au',
+            'phone'   => $GLOBALS['devPhone']   ?? '',
+            'address' => $GLOBALS['devAddress'] ?? '',
+        ],
+        'client' => [
+            'name'    => $GLOBALS['clientName']    ?? '',
+            'email'   => $GLOBALS['clientEmail']   ?? '',
+            'phone'   => $GLOBALS['clientPhone']   ?? '',
+            'address' => $GLOBALS['clientAddress'] ?? '',
+        ],
+        'today' => date('F j, Y'),
+    ];
+
+    // 3) Merge: URL or POST overrides > front matter > base
+    $merged = array_replace_recursive($base, $vars, $overrides);
+
+    // 4) Also allow flat GET overrides like client_name=...
+    foreach (['client_name' => 'client.name', 'client_email' => 'client.email', 'client_phone' => 'client.phone'] as $q => $pathKey) {
+        if (isset($_GET[$q]) && $_GET[$q] !== '') {
+            setByPath($merged, $pathKey, (string)$_GET[$q]);
+        }
+    }
+
+    // 5) Replace [path.to.value] placeholders in the Markdown body
+    $body = preg_replace_callback('/\[([a-zA-Z0-9_.-]+)\]/', function ($m) use ($merged) {
+        $val = getByPath($merged, $m[1]);
+        return is_scalar($val) ? (string)$val : '';
+    }, $body);
+
+    // 6) Convert to HTML
+    $Parsedown = new Parsedown();
+    $Parsedown->setSafeMode(true);
+    return $Parsedown->text($body);
+}
+
+function parseFrontMatter(string $text): array {
+    // If the PECL yaml extension is available, use it
+    if (function_exists('yaml_parse')) {
+        $arr = @yaml_parse($text);
+        return is_array($arr) ? $arr : [];
+    }
+
+    // Minimal indentation-aware parser for nested maps and simple lists
+    $lines = preg_split('/\R/', rtrim($text));
+    $root  = [];
+    $stack = [ ['indent' => -1, 'ref' => &$root] ];
+
+    foreach ($lines as $raw) {
+        if ($raw === '') continue;
+        $trimmed = ltrim($raw, ' ');
+        if ($trimmed === '' || $trimmed[0] === '#') continue;
+
+        $indent = strlen($raw) - strlen($trimmed);
+        // climb up to the correct parent by indent
+        while (count($stack) > 1 && $indent <= $stack[array_key_last($stack)]['indent']) {
+            array_pop($stack);
+        }
+        $parent =& $stack[array_key_last($stack)]['ref'];
+
+        // List item
+        if (preg_match('/^-\s*(.*)$/', $trimmed, $m)) {
+            $val = $m[1];
+            if (!is_array($parent)) $parent = [];
+            if ($val === '') {
+                $parent[] = [];
+                $stack[] = ['indent' => $indent, 'ref' => &$parent[array_key_last($parent)]];
+            } else {
+                $parent[] = _fm_trim_quotes($val);
+            }
+            continue;
+        }
+
+        // Key: value or Key:
+        if (preg_match('/^([A-Za-z0-9_.-]+):\s*(.*)$/', $trimmed, $m)) {
+            $key = $m[1];
+            $val = $m[2];
+            if ($val === '') {
+                if (!isset($parent[$key]) || !is_array($parent[$key])) {
+                    $parent[$key] = [];
+                }
+                $stack[] = ['indent' => $indent, 'ref' => &$parent[$key]];
+            } else {
+                $parent[$key] = _fm_trim_quotes($val);
+            }
+        }
+    }
+    return $root;
+}
+
+function parseFrontMatterForId(?string $clientId): array {
+    $safeId = preg_match('/^[A-Za-z0-9_-]{1,64}$/', (string)$clientId) ? (string)$clientId : 'default';
+    $path   = __DIR__ . '/contracts/' . $safeId . '.md';
+    if (!is_file($path)) $path = __DIR__ . '/contracts/default.md';
+    $md = @file_get_contents($path);
+    if ($md && preg_match('/^\s*---\s*\n(.*?)\n---\s*\n/s', $md, $m)) {
+        return parseFrontMatter($m[1]);
+    }
+    return [];
+}
+
+function getPreparedDateFromMd(string $clientId): string {
+    $safe = preg_match('/^[A-Za-z0-9_-]{1,64}$/', $clientId) ? $clientId : 'default';
+	$path = __DIR__ . '/contracts/' . $safe . '.md';
+    if (!is_file($path)) $path = __DIR__ . '/contracts/default.md';
+
+    $md = file_get_contents($path);
+    if (preg_match('/^\s*---\s*\n(.*?)\n---/s', $md, $m)) {
+        $meta = parseFrontMatter($m[1]);
+        $prepared = getByPath($meta, 'dates.prepared', '');
+        return is_string($prepared) ? $prepared : '';
+    }
+    return '';
+}
+
+function _fm_trim_quotes(string $v): string {
+    $v = trim($v);
+    if ($v !== '' && $v[0] === "'" && substr($v, -1) === "'") return stripslashes(substr($v, 1, -1));
+    if ($v !== '' && $v[0] === '"' && substr($v, -1) === '"') return stripslashes(substr($v, 1, -1));
+    return $v;
+}
+
+
+function getByPath(array $arr, string $path, $default = '') {
+    $keys = explode('.', $path);
+    foreach ($keys as $k) {
+        if ($k === '') continue;
+        if (is_array($arr) && array_key_exists($k, $arr)) {
+            $arr = $arr[$k];
+        } else {
+            return $default;
+        }
+    }
+    return $arr;
+}
+
+function setByPath(array &$arr, string $path, $value): void {
+    $keys = explode('.', $path);
+    $ref =& $arr;
+    foreach ($keys as $k) {
+        if ($k === '') continue;
+        if (!isset($ref[$k]) || !is_array($ref[$k])) $ref[$k] = [];
+        $ref =& $ref[$k];
+    }
+    $ref = $value;
+}
+
+// Gets the current file URL and replaces the .php extension with .html
+function getHtmlUrl(string $htmlName): string {
+    $https  = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
+    $scheme = $https ? 'https' : 'https';
+    $host   = $_SERVER['HTTP_HOST'] ?? 'localhost';
+    $dir    = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
+    return $scheme . '://' . $host . ($dir ? $dir : '') . '/' . $htmlName;
+}
+
+function getClientIp(): string {
+    // 2) X-Forwarded-For: "client, proxy1, proxy2"
+    if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
+        $parts = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
+        foreach ($parts as $ip) {
+            if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
+                return $ip; // first public address
+            }
+        }
+        // if nothing public, take the first valid one
+        foreach ($parts as $ip) {
+            if (filter_var($ip, FILTER_VALIDATE_IP)) {
+                return $ip;
+            }
+        }
+    }
+
+    // 3) X-Real-IP
+    if (!empty($_SERVER['HTTP_X_REAL_IP']) &&
+        filter_var($_SERVER['HTTP_X_REAL_IP'], FILTER_VALIDATE_IP)) {
+        return $_SERVER['HTTP_X_REAL_IP'];
+    }
+
+    // 4) Fallback
+    if (!empty($_SERVER['REMOTE_ADDR']) &&
+        filter_var($_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP)) {
+        return $_SERVER['REMOTE_ADDR'];
+    }
+
+    return 'UNKNOWN';
+}
+
+// Build overrides only when values are non-empty
+$ov = ['client' => [], 'dev' => []];
+
+if ($clientName   !== '') $ov['client']['name']    = $clientName;
+if ($clientEmail  !== '') $ov['client']['email']   = $clientEmail;
+if ($clientPhone  !== '') $ov['client']['phone']   = $clientPhone;
+if ($clientAddress!== '') $ov['client']['address'] = $clientAddress;
+
+if ($devName      !== '') $ov['dev']['name']       = $devName;
+if ($devEmail     !== '') $ov['dev']['email']      = $devEmail;
+if ($devPhone     !== '') $ov['dev']['phone']      = $devPhone;
+if ($devAddress   !== '') $ov['dev']['address']    = $devAddress;
+
+$CONTRACT_HTML = loadContractHtml($_GET['clientid'] ?? null, $ov);
+
+$CLIENT_SIGNATURE = $_POST["client_signature"] ?? null;
+
+if ( is_string($CLIENT_SIGNATURE) && str_starts_with($CLIENT_SIGNATURE, "data:image/png;base64,") ) {
+    // Size guard, rejects very large data URIs
+    if (strlen($CLIENT_SIGNATURE) > 2 * 1024 * 1024) { 
+        http_response_code(413);
+        exit("Signature too large");
+    }
+    $CLIENT_SIGNATURE = '<img id="hk" src="' . htmlspecialchars($CLIENT_SIGNATURE, ENT_QUOTES, "UTF-8") . '">';
+} else {
+    $CLIENT_SIGNATURE = null;
+}
+
+/* -------------------------------------------------------------------------- */
+/*                              SECURITY AND ACCESS                           */
+/* -------------------------------------------------------------------------- */
+
+// Optional config
+$cfg = @include __DIR__ . '/config.php';
+$cfg = is_array($cfg) ? $cfg : [];
+
+// Optional admin creds for Basic Auth or old token scheme
+$ADMIN_USER = $cfg['admin_user']   ?? ($ADMIN_USER   ?? '');
+$ADMIN_PASS = $cfg['admin_pass']   ?? ($ADMIN_PASS   ?? '');
+$SECRET_KEY = $cfg['admin_secret'] ?? ($SECRET_KEY   ?? ''); // old global secret, if you still use it
+
+// Front matter helpers reused from your client file
+if (!function_exists('_fm_trim_quotes')) {
+    function _fm_trim_quotes(string $v) {
+        $v = trim($v);
+        if ($v === '') return '';
+        $q = $v[0];
+        if (($q === '"' || $q === "'") && substr($v, -1) === $q) return substr($v, 1, -1);
+        return $v;
+    }
+}
+if (!function_exists('parseFrontMatter')) {
+    function parseFrontMatter(string $text): array {
+        if (function_exists('yaml_parse')) {
+            $arr = @yaml_parse($text);
+            return is_array($arr) ? $arr : [];
+        }
+        $lines = preg_split('/\R/', rtrim($text));
+        $root  = [];
+        $stack = [ ['indent' => -1, 'ref' => &$root] ];
+        foreach ($lines as $raw) {
+            if ($raw === '') continue;
+            $trim = ltrim($raw, ' ');
+            if ($trim === '' || $trim[0] === '#') continue;
+            $indent = strlen($raw) - strlen($trim);
+            while (count($stack) > 1 && $indent <= $stack[array_key_last($stack)]['indent']) array_pop($stack);
+            $parent =& $stack[array_key_last($stack)]['ref'];
+            if (preg_match('/^-\s*(.*)$/', $trim, $m)) {
+                $val = $m[1];
+                if (!is_array($parent)) $parent = [];
+                if ($val === '') {
+                    $parent[] = [];
+                    $stack[] = ['indent' => $indent, 'ref' => &$parent[array_key_last($parent)]];
+                } else {
+                    $parent[] = _fm_trim_quotes($val);
+                }
+                continue;
+            }
+            if (preg_match('/^([A-Za-z0-9_.-]+):\s*(.*)$/', $trim, $m)) {
+                $key = $m[1];
+                $val = $m[2];
+                if ($val === '') {
+                    if (!isset($parent[$key]) || !is_array($parent[$key])) $parent[$key] = [];
+                    $stack[] = ['indent' => $indent, 'ref' => &$parent[$key]];
+                } else {
+                    $parent[$key] = _fm_trim_quotes($val);
+                }
+            }
+        }
+        return $root;
+    }
+}
+if (!function_exists('parseFrontMatterForId')) {
+    function parseFrontMatterForId(?string $clientId): array {
+        $safeId = preg_match('/^[A-Za-z0-9_-]{1,64}$/', (string)$clientId) ? $clientId : 'default';
+        $path   = __DIR__ . '/contracts/' . $safeId . '.md';
+        if (!is_file($path)) $path = __DIR__ . '/contracts/default.md';
+        $md = @file_get_contents($path);
+        if ($md && preg_match('/^\s*---\s*\n(.*?)\n---\s*\n/s', $md, $m)) {
+            return parseFrontMatter($m[1]);
+        }
+        return [];
+    }
+}
+function fm_admin_secret_for(string $clientId): string {
+    $fm = parseFrontMatterForId($clientId);
+    // support either nested admin.secret or a flat admin_secret
+    if (!empty($fm['admin']) && is_array($fm['admin']) && !empty($fm['admin']['secret'])) {
+        return (string)$fm['admin']['secret'];
+    }
+    if (!empty($fm['admin_secret'])) return (string)$fm['admin_secret'];
+    return '';
+}
+
+/**
+ * Accept both link formats:
+ * 1) New style:  contract.php?clientid=3043&token=HMAC_SHA256(clientid, fm_admin_secret)
+ * 2) Old style:  contract.php?token=HMAC_SHA256(ADMIN_USER|ADMIN_PASS|expires, SECRET_KEY)&expires=UNIX
+ */
+function access_allowed_by_token(): bool {
+    global $ADMIN_USER, $ADMIN_PASS, $SECRET_KEY;
+
+    $clientId = $_GET['clientid'] ?? '';
+    $token    = $_GET['token']    ?? '';
+    $expires  = isset($_GET['expires']) ? (int)$_GET['expires'] : 0;
+
+    if ($token === '') return false;
+
+    // Old scheme with expiry and global secret
+    if ($expires > 0 && $ADMIN_USER !== '' && $ADMIN_PASS !== '' && $SECRET_KEY !== '') {
+        if ($expires < time()) return false;
+        $expected = hash_hmac('sha256', $ADMIN_USER . '|' . $ADMIN_PASS . '|' . $expires, $SECRET_KEY);
+        if (hash_equals($expected, $token)) return true;
+    }
+
+    // New scheme with per-client secret in front matter
+    if ($clientId !== '') {
+        $secret = fm_admin_secret_for($clientId);
+        if ($secret !== '') {
+            $expected2 = hash_hmac('sha256', $clientId, $secret);
+            if (hash_equals($expected2, $token)) return true;
+        }
+    }
+
+    return false;
+}
+
+function require_admin_auth(): void {
+    global $ADMIN_USER, $ADMIN_PASS;
+
+    // Allow valid token to bypass auth for clients
+    if (access_allowed_by_token()) return;
+
+    // If admin creds are not configured, block
+    if ($ADMIN_USER === '' || $ADMIN_PASS === '') {
+        http_response_code(401);
+        echo 'Auth required';
+        exit;
+    }
+
+    // Basic Auth for admins
+    if (!isset($_SERVER['PHP_AUTH_USER'])) {
+        header('WWW-Authenticate: Basic realm="Contracts Admin"');
+        header('HTTP/1.0 401 Unauthorized');
+        echo 'Auth required';
+        exit;
+    }
+    if ($_SERVER['PHP_AUTH_USER'] !== $ADMIN_USER || ($_SERVER['PHP_AUTH_PW'] ?? '') !== $ADMIN_PASS) {
+        header('WWW-Authenticate: Basic realm="Contracts Admin"');
+        header('HTTP/1.0 401 Unauthorized');
+        echo 'Invalid credentials';
+        exit;
+    }
+}
+
+require_admin_auth();
+
+
+/** The HTML code (and some PHP) is kept in PHP variables like $CONTRACT_HTML, $FOOTER, $CONTRACT_SIGNED_PHP, and $CLIENT_DATE_IP_COMPILED. **/
+
+function headerWithTitle(
+    string $title,
+    ?string $clientId = null,
+    ?string $preparedDate = null,
+    string $context = 'web' // 'web' or 'pdf'
+): string {
+    $safeTitle        = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
+    $safeJob          = htmlspecialchars((string)$clientId, ENT_QUOTES, 'UTF-8');
+    $safePreparedDate = htmlspecialchars((string)$preparedDate, ENT_QUOTES, 'UTF-8');
+
+    $baseHref = htmlspecialchars(
+        ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'https')
+        . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost')
+        . rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\') . '/',
+        ENT_QUOTES,
+        'UTF-8'
+    );
+
+    // CSS includes differ by context
+    $cssLinks = $context === 'web'
+        ? <<<HTML
+  <link rel="preconnect" href="https://cdn.jsdelivr.net">
+  <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">
+  <link href="style.css" rel="stylesheet">
+HTML
+        : <<<HTML
+  <!-- Minimal CSS for PDF -->
+  <link href="../internal/css/blueprint.css" rel="stylesheet">
+  <link href="style.css" rel="stylesheet">
+  <style>
+    @page { margin: 5mm 10mm 10mm 10mm; }
+    body { background:#fff;}
+    .container { max-width: 780px; margin: 0 auto; font-size:0.7rem; }
+    .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-4 { display: table-cell; width: 33.333%; 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: 100%; 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; }
+  </style>
+HTML;
+
+    // No JS in PDF
+    $jsLinks = $context === 'web'
+        ? <<<HTML
+  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"></script>
+HTML
+        : '';
+
+    // Navbar only for web
+    $nav = $context === 'web'
+        ? <<<HTML
+  <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>
+HTML
+        : '';
+
+    return <<<HTML
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <title>{$safeJob} - {$safeTitle}</title>
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="robots" content="noindex">
+  <link rel="shortcut icon" href="../internal/images/blueprint.ico" type="image/x-icon">
+  <base href="{$baseHref}">
+{$cssLinks}
+{$jsLinks}
+</head>
+<body>
+{$nav}
+  <main 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-12 col-md-6 text-start">
+          <img class="img-fluid pt-2 img-logo" src="../internal/images/blueprint-full-logo-medium.png" height="100" alt="Modulos Design">
+        </div>
+        <div class="col-12 col-md-6 text-end pt-3">
+          <h3 class="fw-bold mb-1" style="color:#373a3c;">Job: {$safeJob}</h3>
+          <h4 class="mb-1"><span class="fw-bold text-secondary">{$safePreparedDate}</span></h4>
+        </div>
+      </div>
+HTML;
+}
+
+function footerFor(string $context = 'web'): string {
+    $extra = $context === 'web'
+        ? <<<HTML
+  <script>
+    function printContract(){ window.print(); }
+  </script>
+HTML
+        : ''; // no JS for PDF
+
+    return <<<HTML
+    </div> <!-- /.rounded-3 -->
+  </main>
+{$extra}
+</body>
+</html>
+HTML;
+}
+
+
+if ($CLIENT_SIGNATURE == null) {
+    /** ⌛ Waiting for Client to sign: include signature elements and javascript **/
+    // If a signed file already exists for this client, send them there
+    if ($redirectToSigned) {
+        $pattern = __DIR__ . "/{$clientId}_signed_contract*.html";
+        $matches = glob($pattern);
+        if ($matches) {
+            usort($matches, fn($a, $b) => filemtime($b) <=> filemtime($a));
+            $latest = basename($matches[0]);
+            header("Location: " . $latest . "#hk", true, 302);
+            exit;
+        }
+    }
+
+    if (!headers_sent()) {
+        header('Content-Type: text/html; charset=UTF-8');
+    }
+
+    $preparedDate = $_GET['prepared'] ?? getPreparedDateFromMd($clientId) ?: date('F j, Y');
+
+    $HEADER = headerWithTitle(
+        'Unsigned Contract',
+        $clientId,       // show this as “Job: …”
+        $preparedDate,    // show this as the date
+        'web'
+    );
+
+
+    //$clientEmailInit = htmlspecialchars($_GET['client_email'] ?? '', ENT_QUOTES, 'UTF-8');
+
+    $FOOTER = <<<HTML
+  <div id="ui-unsigned"> 
+    <form method="post" class="noprint" id="signature_form">
+        <div id="signature-container">
+            <div id="canvas-container">
+                <canvas id="signature-pad" class="signature-pad" width="188" height="58.66"></canvas>
+            </div>
+        </div>
+
+        <div class="animate slide">
+            <div id="signature-controls" class="d-flex gap-2 justify-content-center mt-3">
+              <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-success rounded-0" disabled>Sign</button>
+            </div>
+        </div>
+
+        <div class="flow" style="max-width: 330px; margin-inline-start: auto;">
+            <h3 class="margin-top loading-signed hidden | animate slide" style="color: var(--clr-green-500); font-weight: 700;">Saving contract…</h3>
+            <small class="loading-signed hidden | animate slide delay-16"
+                style="font-weight: 600; color: var(--clr-blue-700);">
+                This shouldn't take more than a minute.
+            </small>
+        </div>
+
+        <input type="hidden" name="client_email" value="{$clientEmail}">
+        <input type="hidden" name="csrf" value="{$csrf}">
+        <input type="hidden" id="client_signature" name="client_signature" />
+        <input type="hidden" name="client_tz" value="">
+    </form>
+
+
+    <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 qr-code-container">
+            <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>
+
+  </div>
+  </div>
+  </main>
+  <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 id="contract_script_unsigned" type="module">
+  signature("#signature-pad")
+  function signature(selector) {
+
+      if (!document.querySelector(selector)) return
+
+      const canvas = document.querySelector(selector)
+
+      // https://github.com/szimek/signature_pad#options
+      const clientSignaturePad = new SignaturePad(canvas, {
+          penColor: "hsl(200, 100%, 30%)",
+          minDistance: 2,
+      })
+
+      resizeCanvas()
+
+      if (localStorage.getItem("client_signature")) {
+          document.querySelector("#confirm").disabled = false
+          // document.querySelector("#reset").disabled = false
+      }
+
+
+      // event listeners
+
+      // save signature to localStorage on change
+      clientSignaturePad.addEventListener("afterUpdateStroke", () => {
+          let data = clientSignaturePad.toDataURL("image/png")
+
+          document.querySelector("#client_signature").value = data
+          localStorage.setItem("client_signature", data)
+
+          // ! probably remove these:
+          document.querySelector("#confirm").disabled = false
+          // document.querySelector("#reset").disabled = false
+      })
+
+      // button to reset signature
+      document.querySelector("#reset")?.addEventListener("click", (e) => {
+          clientSignaturePad.clear()
+          localStorage.removeItem("client_signature")
+          document.querySelector("#client_signature").value = null
+          document.querySelector("#confirm").disabled = true
+          // document.querySelector("#reset").disabled = true
+      })
+
+      // form submit
+      document.querySelector("#signature_form").addEventListener("submit", (e) => {
+          // e.preventDefault();
+
+          e.target.querySelectorAll(".loading-signed").forEach((el) => {
+              el.classList.remove("hidden")
+          })
+
+          e.target.querySelector("#canvas-container").classList.add("just-signed")
+
+          let otherElements = document.querySelectorAll("#content > *:not(#ui-unsigned, #dev_signature)")
+          otherElements.forEach(element => {
+              // element.style.cssText = `opacity: .5;`
+              element.style.opacity = "0.5"
+          })
+
+      })
+
+      window.onresize = resizeCanvas
+
+
+      // needed for retina displays
+      function resizeCanvas() {
+          const ratio = Math.max(window.devicePixelRatio || 1, 1)
+          canvas.width = canvas.offsetWidth * ratio
+          canvas.height = canvas.offsetHeight * ratio
+          canvas.getContext("2d").scale(ratio, ratio)
+
+          let data = localStorage.getItem("client_signature");
+          if (data) {
+              // console.log(data)
+              clientSignaturePad.fromDataURL(data)
+              // disableResetButtonIfSignatureIsEmpty(data)
+              document.querySelector("#client_signature").value = data
+          }
+      }
+
+  }
+  </script>
+  <script>
+(function () {
+  const modal = document.getElementById('modal-qr')
+  const btnOpen = document.getElementById('show-modal-qr')
+  const btnClose = document.getElementById('close-modal-qr')
+  const canvas = document.getElementById('qr-code')
+
+  if (canvas && window.QRious) {
+    new QRious({
+      element: canvas,
+      value: window.location.href,
+      foreground: 'hsl(200, 30%, 20%)',
+      padding: 0,
+      size: 500
+    })
+  }
+
+  if (btnOpen && modal) {
+    btnOpen.addEventListener('click', function (e) {
+      e.preventDefault()
+      try {
+        if (typeof modal.showModal === 'function') {
+          modal.showModal()
+        } else {
+          // very old browser fallback
+          modal.setAttribute('open', '')
+        }
+      } catch (err) {
+        modal.setAttribute('open', '')
+      }
+    })
+  }
+
+  btnClose?.addEventListener('click', function () {
+    try {
+      if (modal.open) modal.close()
+      else modal.removeAttribute('open')
+    } catch (e) {
+      modal.removeAttribute('open')
+    }
+  })
+
+  // click outside to close
+  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>
+  <script>
+document.addEventListener('DOMContentLoaded', function () {
+  try {
+    var tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''
+    var tzField = document.querySelector('input[name="client_tz"]')
+    if (tzField) tzField.value = tz
+  } catch (e) {}
+})
+</script>
+  </body>
+</html>
+HTML;
+
+    echo $HEADER;
+    echo $CONTRACT_HTML;
+    //echo $DEV_SIGNATURE;
+    echo $FOOTER;
+
+
+} else {
+    /** Contract was just signed: put $CLIENT_SIGNATURE and the other parts in the .html file  **/
+    // Build dev signature meta
+    $devTimestamp = date('F j, Y \a\t g:i:s A T');
+    $devIP        = $_SERVER['SERVER_ADDR'] ?? 'UNKNOWN';
+    $DEV_SIGNATURE .=
+        '<div class="date-ip">' .
+        '<strong>Signed on:</strong> ' . htmlspecialchars($devTimestamp, ENT_QUOTES, 'UTF-8') . '<br>' .
+        '</div>';
+
+    // Client signed date and IP
+    $clientTz = $_POST['client_tz'] ?? '';
+    if ($clientTz && in_array($clientTz, timezone_identifiers_list(), true)) {
+        $tz = new DateTimeZone($clientTz);
+        $clientDate = (new DateTime('now', $tz))->format('F j, Y \a\t g:i:s A T');
+    } else {
+        $clientDate = gmdate('F j, Y \a\t g:i:s A \G\M\T');
+    }
+    //$clientIp = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? 'UNKNOWN';
+    $clientIp = getClientIp();
+
+    $CLIENT_SIGNATURE .=
+        '<div id="date-ip" class="date-ip">' .
+        '<strong>Signed on:</strong> ' . htmlspecialchars($clientDate, ENT_QUOTES, 'UTF-8') . '<br>' .
+        '<strong>Client IP:</strong> ' . htmlspecialchars($clientIp, ENT_QUOTES, 'UTF-8') .
+        '</div>';
+
+    // Optional names above signatures (prefer MD front-matter, then querystring/config)
+    $meta = parseFrontMatterForId($_GET['clientid'] ?? null);
+
+    // try client.name in the MD front-matter
+    $clientNameFromMd = '';
+    if (is_array($meta)) {
+        $clientNameFromMd = (string) (getByPath($meta, 'client.name', '') ?: ($meta['client']['name'] ?? ''));
+    }
+    $clientNameResolved = $clientName !== '' ? $clientName : $clientNameFromMd;
+
+    // likewise for dev.name (in case you ever move it to the MD)
+    $devNameFromMd = '';
+    if (is_array($meta)) {
+        $devNameFromMd = (string) (getByPath($meta, 'dev.name', '') ?: ($meta['dev']['name'] ?? ''));
+    }
+    $devNameResolved = $devName !== '' ? $devName : $devNameFromMd;
+
+    if ($devNameResolved !== '') {
+        $DEV_SIGNATURE = '<strong>' . htmlspecialchars($devNameResolved, ENT_QUOTES, 'UTF-8') . '</strong>' . $DEV_SIGNATURE;
+    }
+    if ($clientNameResolved !== '') {
+        $CLIENT_SIGNATURE = '<strong>' . htmlspecialchars($clientNameResolved, ENT_QUOTES, 'UTF-8') . '</strong>' . $CLIENT_SIGNATURE;
+    }
+
+    $preparedDate = getPreparedDateFromMd($clientId) ?: $clientDate;
+    // Assemble final HTML
+    $HEADER = headerWithTitle(
+        'Signed Contract',
+        $clientId,
+        $preparedDate      // you already computed this above
+    );
+
+    $CONTRACT_HTML = loadContractHtml($_GET['clientid'] ?? null);
+
+    $compiled = <<<HTML
+    <div class="row compiled-signatures align-items-end">
+      <div class="col compiled-signature">{$DEV_SIGNATURE}</div>
+      <div class="col compiled-signature">{$CLIENT_SIGNATURE}</div>
+    </div>
+	<br>
+    <div class="row download-pdf d-print-none">
+    	<a href="contracts/{$clientId}_signed_contract.pdf" download class="btn btn-light rounded-0" id="downloadpdf">Download PDF</a>
+    </div>
+HTML;
+
+    $closing = <<<HTML
+    </div>
+  </main>
+  <script>
+    function printContract(){ window.print(); }
+  </script>
+</body>
+</html>
+HTML;
+
+    // Build a unique filename like 3043_signed_contract_20250812-184501.html
+    $timestamp = date('Ymd-His');
+    //$htmlName  = "{$clientId}_signed_contract_{$timestamp}.html";
+    $htmlName = "{$clientId}_signed_contract.html";
+    if (file_exists($htmlName)) {
+        $htmlName = "{$clientId}_signed_contract_" . date('Ymd-His') . ".html";
+    }
+
+    //$output = $HEADER . $CONTRACT_HTML . $compiled . $closing;
+    // 1) WEB HTML to save
+    $preparedDate = getByPath(parseFrontMatterForId($_GET['clientid'] ?? null), 'dates.prepared', date('F j, Y'));
+
+    $headerWeb  = headerWithTitle("{$clientId} - Signed Contract", $clientId, $preparedDate, 'web');
+    $footerWeb  = footerFor('web');
+    $outputWeb  = $headerWeb . $CONTRACT_HTML . $compiled . $footerWeb;
+
+    file_put_contents($htmlName, $outputWeb);
+
+    // 2) PDF HTML (lean) to render
+    $headerPdf = headerWithTitle("{$clientId} - Signed Contract", $clientId, $preparedDate, 'pdf');
+    $footerPdf = footerFor('pdf');
+    $outputPdf = $headerPdf . $CONTRACT_HTML . $compiled . $footerPdf;
+
+    // Save HTML file
+    file_put_contents($htmlName, $outputWeb);
+
+    $options = new \Dompdf\Options();
+    $options->set('defaultFont', 'Helvetica');
+    $options->set('isRemoteEnabled', true);
+
+    $dompdf = new \Dompdf\Dompdf($options);
+    $dompdf->loadHtml($outputPdf, 'UTF-8');
+    $dompdf->setPaper('A4', 'portrait');
+    // Helpful for resolving relative paths in CSS/images:
+    $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
+    $dir  = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\') . '/';
+    $dompdf->setBasePath('https://' . $host . $dir);
+
+    $dompdf->render();
+
+    $pdfPath = 'contracts/' . $clientId . '_signed_contract.pdf';
+    file_put_contents($pdfPath, $dompdf->output());
+
+    //error_log("$clientId - Finished creating html and pdf, Create email next.\r\n", 3, "error_log");
+    error_log("$clientId - About to call sendEmails with clientEmail='{$clientEmail}' devEmail='{$devEmail}'\r\n", 3, "error_log");
+    // Now send emails (and attach PDF)
+    sendEmails($clientEmail, $devEmail, $fromAddress, $htmlName, $clientId, $pdfPath);
+
+    //error_log("$clientId - Finished creating email. Redirect to HTML NOW.\r\n", 3, "error_log");
+
+    // Redirect last
+    header('Location: ' . $htmlName . '#hk', true, 303);
+    exit;
+}
+
+// Function to email notifications; gets called when Client signs
+function sendEmails(
+    string $clientEmail,
+    string $devEmail,
+    string $fromAddress,
+    string $htmlName,
+    string $clientId,
+    string $pdfPath = ''
+): void {
+    global $cfg;
+
+    // 1) Clean + validate addresses
+    $clientEmail = preg_replace('/[\r\n]+/', '', $clientEmail);
+    $devEmail    = preg_replace('/[\r\n]+/', '', $devEmail);
+    $clientEmail = filter_var($clientEmail, FILTER_VALIDATE_EMAIL) ?: '';
+    $devEmail    = filter_var($devEmail, FILTER_VALIDATE_EMAIL) ?: '';
+    if (!$clientEmail && !$devEmail) return;
+
+    // 2) Inputs for the email template
+    $viewUrl      = htmlspecialchars(getHtmlUrl($htmlName), ENT_QUOTES, 'UTF-8');
+    $company      = $cfg['dev_name'] ?? 'Modulos Design';
+    $preparedDate = getPreparedDateFromMd($clientId) ?: date('F j, Y');
+
+    // Try to greet the client by name (from front-matter)
+    $meta             = parseFrontMatterForId($clientId);
+    $clientNameFromMd = is_array($meta) ? (string)(getByPath($meta, 'client.name', '')) : '';
+	$clientCompanyFromMd = is_array($meta) ? (string)(getByPath($meta, 'client.company', '')) : $clientNameFromMd;
+    $clientNameSafe   = $clientNameFromMd;
+
+    // Build a simple target list
+    $targets = [];
+    if ($clientEmail) $targets[] = ['to' => $clientEmail, 'kind' => 'client'];
+    if ($devEmail)    $targets[] = ['to' => $devEmail,    'kind' => 'dev'];
+
+    foreach ($targets as $t) {
+        // 3) Make the mailer
+        $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; // 465/SSL
+        $mail->Port       = $cfg['smtp_port'] ?? 465;
+
+        $mail->CharSet  = 'UTF-8';
+        $mail->Encoding = 'base64';
+        $mail->setFrom($fromAddress, $company);
+
+        // sensible reply-to
+        if ($t['kind'] === 'client' && $devEmail)  $mail->addReplyTo($devEmail);
+        if ($t['kind'] === 'dev'    && $clientEmail) $mail->addReplyTo($clientEmail);
+
+        $mail->addAddress($t['to']);
+        $mail->isHTML(true);
+
+        // 4) Embed the logo **on this exact $mail** and get the <img> tag
+        $logoHtml = email_logo_png_cid($mail, $cfg['dark_logo'] ?? '', $company, 200);
+        $safeSignature = email_logo_png_cid($mail, $cfg['dev_signature'] ?? '', $company, 100);
+
+        // 5) Build the body with correct argument order
+        [$subject, $html, $alt] = buildSignedContractEmail(
+            $logoHtml,        // <- first param is the HTML for the CID <img>
+            $viewUrl,
+            $clientId,
+            $clientNameSafe,
+            $preparedDate,
+            $company,
+        	$safeSignature
+        );
+
+        // Developer copy tweaks
+        if ($t['kind'] === 'dev') {
+            $subject = $clientId . ' ' . $clientCompanyFromMd . ' – Contract has been signed';
+            $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>';
+            // try to insert after the first <tr> block; fallback to simple boundary replace
+            $html = preg_replace('/(<tr>\s*<td[^>]*>.*?<\/td>\s*<\/tr>)/s', '$1' . $inject, $html, 1)
+                 ?: str_replace('</tr><tr>', '</tr>' . $inject . '<tr>', $html);
+            $alt .= "\n\nSigned by: " . ($clientEmail ?: 'unknown');
+        }
+
+        $mail->Subject = $subject;
+        $mail->Body    = $html;
+        if (!empty($alt)) $mail->AltBody = $alt;
+
+        // Optional BCC + attachment
+        $mail->addBCC('drafting@modulosdesign.com.au');
+        if ($pdfPath !== '' && is_file($pdfPath)) {
+            $mail->addAttachment($pdfPath, basename($pdfPath));
+        }
+
+        try {
+            $mail->send();
+        } catch (Exception $e) {
+            error_log("Mailer error to {$t['to']}: {$mail->ErrorInfo}\n", 3, "error_log");
+        }
+    }
+}
+
+
+function salutationFromName(string $fullName): string {
+    // Normalize whitespace (incl. non-breaking space) and collapse runs
+    $name = str_replace("\xC2\xA0", ' ', $fullName);        // NBSP → space
+    $name = trim(preg_replace('/\s+/u', ' ', $name));
+    if ($name === '') return 'there';
+
+    // Strip common trailing suffixes like ", MD", ", PhD", "Jr.", etc.
+    $name = preg_replace(
+        '/,?\s*(Jr\.?|Sr\.?|II|III|IV|MD|Ph\.?D|Esq\.?|J\.?D\.?|M\.?B\.?A\.?|RN|DDS|DMD)\s*$/iu',
+        '',
+        $name
+    );
+
+    // Remove one or more leading honorifics (with optional dot), incl. unicode spaces
+    $honorifics = '(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)';
+    $name = preg_replace('/^(?:' . $honorifics . ')\.?[\s\x{00A0}]+/iu', '', $name);
+    while (preg_match('/^' . $honorifics . '\.?[\s\x{00A0}]+/iu', $name)) {
+        $name = preg_replace('/^' . $honorifics . '(\.?)[\s\x{00A0}]+/iu', '', $name, 1);
+    }
+
+    // First non-initial token becomes the salutation
+    $tokens = preg_split('/[\s\x{00A0}]+/u', $name);
+    if (!$tokens) return 'there';
+    foreach ($tokens as $tok) {
+        $t = rtrim($tok, '.');                    // drop trailing dot from initials
+        if (!preg_match('/^[A-Za-z]\.?$/u', $t))  // skip single-letter initials
+            return $t;
+    }
+    return $tokens[0] ?: 'there';
+}
+
+function email_logo_png_cid(PHPMailer $mail, string $dataUrl, string $alt = 'Modulos Design', int $width = 140): string {
+    if ($dataUrl === '') return '';
+
+    // Handle minor whitespace/newlines in config values
+    $dataUrl = trim($dataUrl);
+
+    $prefix = 'data:image/png;base64,';
+    if (stripos($dataUrl, $prefix) !== 0) return '';
+
+    $bin = base64_decode(substr($dataUrl, strlen($prefix)), true);
+    if ($bin === false || $bin === '') return '';
+
+    // Deterministic CID so forwards/replies still render
+    $cid = 'logo_' . substr(sha1($bin), 0, 12) . '@modulos';
+
+    // This is the crucial bit you commented out
+    $mail->addStringEmbeddedImage($bin, $cid, 'logo.png', 'base64', 'image/png');
+
+    return '<img src="cid:' . $cid . '" alt="' . htmlspecialchars($alt, ENT_QUOTES, 'UTF-8') . '" width="' . (int)$width . '" style="display:block;border:0;outline:0;text-decoration:none;height:auto;">';
+}
+
+function buildSignedContractEmail(
+    string $logoHtml,
+    string $viewUrl,
+    string $clientId,
+    string $clientName = '',
+    string $preparedDate = '',
+    string $company = 'Modulos Design',
+	string $safeSignature
+): array {
+    $firstName     = salutationFromName($clientName);
+    $firstNameSafe = htmlspecialchars($firstName, ENT_QUOTES, 'UTF-8');
+    $safeUrl       = htmlspecialchars($viewUrl, ENT_QUOTES, 'UTF-8');
+    $safeCompany   = htmlspecialchars($company, ENT_QUOTES, 'UTF-8');
+    $safeJob       = htmlspecialchars($clientId, ENT_QUOTES, 'UTF-8');
+    $safePrepared  = htmlspecialchars($preparedDate, ENT_QUOTES, 'UTF-8');
+    $preparedPart  = $preparedDate ? " (prepared {$safePrepared})" : '';
+
+    $subject = "{$safeJob} – Copy of Signed Contract";
+
+    $html = <<<HTML
+<!-- Preheader stays hidden at 0px -->
+<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 contract — here’s your copy and access link.
+</div>
+
+<div style="background:#f6f7fb;padding:24px;font-size:14px;line-height:1.6;">
+  <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;line-height:1.6;font-family:Arial,Helvetica,sans-serif;">
+    <tr>
+      <td style="font-size:14px;line-height:1.6;padding:20px 24px;background:#D9CCC1;color:#ffffff;">
+        <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-size:14px;line-height:1.6;">
+          <tr>
+            <td style="font-size:14px;line-height:1.6;">$logoHtml</td>
+            <td align="right" style="font-weight:700;font-size:14px;line-height:1.6;">Job #$safeJob</td>
+          </tr>
+        </table>
+      </td>
+    </tr>
+
+    <tr>
+      <td style="padding:28px 24px 8px;line-height:1.6;color:#635A4A;font-size:14px;">
+        <div style="font-size:14px;margin-bottom:8px;line-height:1.6;">Hello {$firstNameSafe},</div>
+        <div style="font-size:14px;line-height:1.6;">
+          Thank you for signing the contract{$preparedPart}. A copy is attached for your records,
+          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;line-height:1.6;">
+        <!--[if mso]>
+        <v:rect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
+                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:16px;line-height:1.6;">View Contract</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;line-height:1.6;mso-hide:all"
+           target="_blank" rel="noopener">View Contract</a>
+        <!--<![endif]-->
+      </td>
+    </tr>
+
+    <tr>
+      <td style="padding:8px 24px 24px;font-size:14px;line-height:1.6;color:#635A4A;">
+        <div style="font-size:14px;line-height:1.6;">
+          If the button doesn’t work, copy and paste this link into your browser:<br>
+          <span style="word-break:break-all;color:#635A4A;font-size:14px;line-height:1.6;">$safeUrl</span>
+        </div>
+        <div style="font-size:14px;line-height:1.6;margin-top:18px;">
+          Thanks again — we’re excited to be working with you and looking forward to getting started.<br><br>
+          <b>Kind Regards,</b><br><br>$safeSignature<br>Benjamin Harris<br>Modulos Design<br>0402 984 082  |  drafting@modulosdesign.com.au
+        </div>
+      </td>
+    </tr>
+
+    <tr>
+      <td style="padding:12px 24px;background:#28261E;color:#D9CCC1;font-size:14px;line-height:1.6;">
+        This is an automated message. Please reply to this email if you have any questions.
+      </td>
+    </tr>
+  </table>
+</div>
+HTML;
+
+    $alt = "Hello {$firstName},\n\nThe contract has been signed{$preparedPart}.\n\nView/download: {$viewUrl}\n\nThanks,\n{$company}";
+
+    return [$subject, $html, $alt];
+}
+?>

+ 25 - 0
contracts/contracts-admin/.htaccess

@@ -0,0 +1,25 @@
+# disable the server signature
+ServerSignature Off
+
+
+# Secures Access to the Office and Employee Home IP's Only
+#<RequireAll>
+#    Require all denied
+#    Require ip 122.150.76.27
+#    Require ip 172.21.0.2
+#    Require ip 192.168.8.0/24
+#</RequireAll>
+
+
+# remove php and html extensions
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteRule ^([^\.]+)$ $1.php [NC,L]
+RewriteRule ^([^\.]+)$ $1.html [NC,L]
+
+# Disable directory browsing
+Options -Indexes
+
+# Additional security
+<FilesMatch "config\.php$">
+    Require all denied
+</FilesMatch>

+ 111 - 0
contracts/contracts-admin/README.md

@@ -0,0 +1,111 @@
+# Contracts Admin MVP
+
+This is a single-file PHP admin and JSON API that lets you:
+
+- List all `.md` contracts stored in a `/contracts` folder
+- Edit a contract in a modal and save changes back to disk
+- Email a signing link to a client and mark the item as "sent"
+- Mark contracts as "signed" automatically from your existing `contract.php` page, or manually from the UI
+
+## Quick start
+
+1. Copy `contracts-admin.php` to a folder that is **not** public or is protected by HTTP auth.
+2. Make sure your contracts live in a folder named `contracts` alongside this file. Each file is `clientid.md`.
+3. Open the admin in a browser and log in with `admin / changeme`.
+4. Click **Edit** to update an `.md`. Click **Email link** to send the client a link like:
+
+   ```
+   https://modulosdesign.com.au/contracts/contract.php?clientid=3043
+   ```
+
+   The "sent" tick updates automatically on success.
+
+## Configuration
+
+Open the top of `contracts-admin.php` and adjust:
+
+- `CONTRACTS_DIR` absolute path to your contracts folder
+- `BASE_URL` base URL where the public `contract.php` lives
+- `ADMIN_SHARED_SECRET` shared token used by `contract.php` to mark items as signed
+- `ADMIN_USER` and `ADMIN_PASS` for Basic Auth
+- Database: by default uses SQLite `contracts.sqlite`. You can override with `DB_DSN`, `DB_USER`, `DB_PASS` for MySQL or Postgres
+
+If you already have a `config.php` with `$pdo` or constants, keep it next to the file. It will be included automatically.
+
+## Database
+
+The app auto-creates a table named `contract_status`.
+
+```sql
+CREATE TABLE IF NOT EXISTS contract_status (
+  clientid        TEXT PRIMARY KEY,
+  sent            INTEGER DEFAULT 0,
+  sent_at         TEXT NULL,
+  signed          INTEGER DEFAULT 0,
+  signed_at       TEXT NULL,
+  last_email_to   TEXT NULL,
+  pdf_path        TEXT NULL,
+  signer_name     TEXT NULL,
+  signer_ip       TEXT NULL
+);
+```
+
+On MySQL, use `VARCHAR(64)` for `clientid` and `DATETIME` for timestamps.
+
+## Hooking into your existing `contract.php`
+
+After the client signs and the PDF has been generated and emailed, call the admin endpoint to mark it signed.
+
+**Add this snippet near the end of your `contract.php` after a successful send:**
+
+```php
+// Mark as signed in the admin database
+$clientid = $_GET['clientid'] ?? '';
+$signerName = $clientName ?? null; // replace with your variable if available
+$pdfPath = $savedPdfPath ?? null;  // replace with your PDF path if you store it
+
+$ch = curl_init('https://your-admin-url/contracts-admin.php');
+curl_setopt($ch, CURLOPT_POST, true);
+curl_setopt($ch, CURLOPT_POSTFIELDS, [
+  'action' => 'mark_signed',
+  'secret' => ADMIN_SHARED_SECRET, // define the same secret in both places
+  'clientid' => $clientid,
+  'name' => $signerName,
+  'pdf' => $pdfPath,
+]);
+curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+curl_exec($ch);
+curl_close($ch);
+```
+
+If you prefer direct DB access from `contract.php`, insert or update `contract_status` with `signed=1` instead.
+
+## Email sending
+
+The MVP uses PHP `mail()` by default. If PHPMailer is present and SMTP constants are defined (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_SECURE`), it will send via SMTP.
+
+The email body is simple and friendly. Tweak it in `send_contract_email()`.
+
+## Security
+
+- Protect this admin with HTTP Basic Auth or an allowlist in your reverse proxy
+- The signing webhook uses a shared secret
+- Filenames are validated to prevent path traversal
+
+## Embedding
+
+You can embed the admin as an iframe inside Grav's admin route accessible only to you. Example shortcode:
+
+```html
+<iframe src="/protected/contracts-admin.php" width="100%" height="900" style="border:0"></iframe>
+```
+
+For better integration, use an Nginx rule to restrict by your IP or VPN.
+
+## Styling and UX
+
+- Sticky header for the table
+- Search box filters by client id or last sent email
+- Buttons for edit, email, copy link, and open
+
+You can brand it by adding your logo and colors in the `<style>` block.

+ 1636 - 0
contracts/contracts-admin/contracts-admin.php

@@ -0,0 +1,1636 @@
+<?php
+/**
+ * Contracts Admin MVP
+ * Single-file admin + JSON API for listing, editing, and emailing contract links.
+ */
+
+declare(strict_types=1);
+date_default_timezone_set('Australia/Hobart');
+
+session_start();
+if ($_SERVER["REQUEST_METHOD"] === "POST") {
+    // allow the public "mark_signed" webhook to skip CSRF (it uses a shared secret)
+    $isMarkSigned = (($_POST['action'] ?? '') === 'mark_signed');
+    if (!$isMarkSigned) {
+        $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");
+
+// Load cfg array
+$cfg = @include __DIR__ . "/../config.php";
+$cfg = is_array($cfg) ? $cfg : [];
+
+// PHPMailer (your internal includes)
+use PHPMailer\PHPMailer\PHPMailer;
+use PHPMailer\PHPMailer\SMTP;
+use PHPMailer\PHPMailer\Exception;
+require_once "../../internal/phpmailer/src/Exception.php";
+require_once "../../internal/phpmailer/src/PHPMailer.php";
+require_once "../../internal/phpmailer/src/SMTP.php";
+
+define('BASE_URL', 'https://modulosdesign.com.au/contracts'); // no /contracts-admin here
+
+/* -------------------------------------------------------------------------- */
+/*                                CONFIGURATION                                */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * If you already have a config.php with DB and SMTP, include it here.
+ * You can also define constants below to override.
+ */
+$external_config = '../config.php';
+if (file_exists($external_config)) {
+    require_once $external_config;
+}
+
+// Contracts directory. Update to your absolute path in production.
+if (!defined('CONTRACTS_DIR')) {
+    define('CONTRACTS_DIR', '../contracts');
+}
+
+// Base URL where the public signing page lives
+// Example: https://modulosdesign.com.au/contracts
+//if (!defined('BASE_URL')) {
+//    define('BASE_URL', 'https://modulosdesign.com.au/contracts/contracts-admin');
+//}
+
+// Shared secret so the public signing page can mark a contract as "signed"
+if (!defined('ADMIN_SHARED_SECRET')) {
+    define('ADMIN_SHARED_SECRET', 'change-this-secret');
+}
+
+// Admin auth. Use HTTP Basic Auth for the MVP.
+if (!defined('ADMIN_USER')) define('ADMIN_USER', 'admin');
+if (!defined('ADMIN_PASS')) define('ADMIN_PASS', 'changeme');
+
+// Database configuration. If nothing is defined, we auto-fallback to SQLite.
+if (!defined('DB_DSN'))  define('DB_DSN', 'sqlite:' . __DIR__ . '/contracts.sqlite');
+if (!defined('DB_USER')) define('DB_USER', '');
+if (!defined('DB_PASS')) define('DB_PASS', '');
+
+// Optional: SMTP configuration if you prefer PHPMailer or similar.
+// For the MVP we use mail() by default. If you have PHPMailer autoloaded, we will try to use it.
+if (!defined('MAIL_FROM')) define('MAIL_FROM', 'no-reply@modulosdesign.com.au');
+if (!defined('MAIL_FROM_NAME')) define('MAIL_FROM_NAME', 'Modulos Design');
+
+// --- LOA (Authorisation) config ---
+if (!defined('LOA_DIR'))         define('LOA_DIR', '../loa'); // sibling to ../contracts
+if (!defined('LOA_BASE_URL'))    define('LOA_BASE_URL', 'https://modulosdesign.com.au/contracts'); // where loa.php lives
+// IMPORTANT: set this to the SAME secret used in loa.php ($CFG['secret'] / APP_HMAC_SECRET)
+if (!defined('LOA_TOKEN_SECRET')) define('LOA_TOKEN_SECRET', 'd1Epy6ryzgLYjLEBlpiHFrgST8JbAjgksjj3hIO5zCK5DChqYpWUdr8jeWR7xEgd');
+
+
+$tab = $_GET['tab'] ?? 'contracts';
+
+/* -------------------------------------------------------------------------- */
+/*                               HELPER FUNCTIONS                              */
+/* -------------------------------------------------------------------------- */
+
+function require_admin_auth(): void {
+    if (!isset($_SERVER['PHP_AUTH_USER'])) {
+        header('WWW-Authenticate: Basic realm="Contracts Admin"');
+        header('HTTP/1.0 401 Unauthorized');
+        echo 'Auth required';
+        exit;
+    }
+    if ($_SERVER['PHP_AUTH_USER'] !== ADMIN_USER || ($_SERVER['PHP_AUTH_PW'] ?? '') !== ADMIN_PASS) {
+        header('WWW-Authenticate: Basic realm="Contracts Admin"');
+        header('HTTP/1.0 401 Unauthorized');
+        echo 'Invalid credentials';
+        exit;
+    }
+}
+
+function json_response(array $payload, int $code = 200): void {
+    http_response_code($code);
+    header('Content-Type: application/json; charset=utf-8');
+    echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+    exit;
+}
+
+function clientid_from_filename(string $fn): string {
+    return preg_replace('/\.md$/i', '', basename($fn));
+}
+
+function safe_clientid(string $id): string {
+    // Accept digits and simple ids like "3043" or "client-3043". Adjust if needed.
+    if (!preg_match('/^[A-Za-z0-9_-]+$/', $id)) {
+        throw new RuntimeException('Invalid client id');
+    }
+    return $id;
+}
+
+function contract_path(string $clientid): string {
+    $found = find_contract_path_by_clientid($clientid);
+    if ($found) return $found; // existing file in any subfolder
+
+    // default location for new files
+    $id = safe_clientid($clientid);
+    return rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . $id . '.md';
+}
+
+function ensure_db(): PDO {
+    static $pdo = null;
+    if ($pdo instanceof PDO) return $pdo;
+
+    $pdo = new PDO(DB_DSN, DB_USER, DB_PASS, [
+        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+    ]);
+
+    // Create table if missing
+    $pdo->exec("CREATE TABLE IF NOT EXISTS contract_status (
+        clientid        TEXT PRIMARY KEY,
+        sent            INTEGER DEFAULT 0,
+        sent_at         TEXT NULL,
+        signed          INTEGER DEFAULT 0,
+        signed_at       TEXT NULL,
+        last_email_to   TEXT NULL,
+        pdf_path        TEXT NULL,
+        signer_name     TEXT NULL,
+        signer_ip       TEXT NULL
+    )");
+
+    return $pdo;
+}
+
+function get_status(string $clientid): array {
+    $pdo = ensure_db();
+    $stmt = $pdo->prepare("SELECT * FROM contract_status WHERE clientid = :id");
+    $stmt->execute([':id' => $clientid]);
+    $row = $stmt->fetch();
+    if (!$row) {
+        return [
+            'clientid' => $clientid,
+            'sent' => 0,
+            'sent_at' => null,
+            'signed' => 0,
+            'signed_at' => null,
+            'last_email_to' => null,
+            'pdf_path' => null,
+            'signer_name' => null,
+            'signer_ip' => null,
+        ];
+    }
+    return $row;
+}
+
+function set_sent(string $clientid, string $email): void {
+    $pdo = ensure_db();
+    $stmt = $pdo->prepare("INSERT INTO contract_status (clientid, sent, sent_at, last_email_to)
+                           VALUES (:id, 1, :now, :email)
+                           ON CONFLICT(clientid) DO UPDATE SET
+                             sent = 1,
+                             sent_at = :now,
+                             last_email_to = :email");
+    $stmt->execute([
+        ':id' => $clientid,
+        ':now' => date('Y-m-d H:i:s'),
+        ':email' => $email
+    ]);
+}
+
+function set_signed(string $clientid, ?string $signerName, ?string $signerIp, ?string $pdfPath = null): void {
+    $pdo = ensure_db();
+    $stmt = $pdo->prepare("INSERT INTO contract_status (clientid, signed, signed_at, signer_name, signer_ip, pdf_path)
+                           VALUES (:id, 1, :now, :name, :ip, :pdf)
+                           ON CONFLICT(clientid) DO UPDATE SET
+                             signed = 1,
+                             signed_at = :now,
+                             signer_name = COALESCE(:name, signer_name),
+                             signer_ip = COALESCE(:ip, signer_ip),
+                             pdf_path = COALESCE(:pdf, pdf_path)");
+    $stmt->execute([
+        ':id' => $clientid,
+        ':now' => date('Y-m-d H:i:s'),
+        ':name' => $signerName,
+        ':ip' => $signerIp,
+        ':pdf' => $pdfPath
+    ]);
+}
+
+function extract_front_matter_fields(string $file): array {
+    $out = [];
+    $txt = @file_get_contents($file);
+    if (!$txt) return $out;
+
+    // Grab the first front matter block
+    if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) return $out;
+    $fm = $m[1];
+
+    // Very simple line-based pulls (keeps dependencies out)
+    // client:
+    if (preg_match('/^\s*client\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block)) {
+        $clientBlock = $block[1];
+        if (preg_match('/^\s*name\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi',  $clientBlock, $mm)) $out['client_name']  = trim($mm[1]);
+        if (preg_match('/^\s*email\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $clientBlock, $mm)) $out['client_email'] = trim($mm[1]);
+        if (preg_match('/^\s*id\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi',    $clientBlock, $mm)) $out['client_id']    = trim($mm[1]);
+
+    }
+
+    if (preg_match('/^\s*project\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $fm, $mm)) $out['project'] = trim($mm[1]);
+    if (preg_match('/^\s*job\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi',     $fm, $mm)) $out['job']     = trim($mm[1]);
+
+    if (preg_match('/^\s*user\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi',   $fm, $mm)) $out['admin_user']   = trim($mm[1]);
+    if (preg_match('/^\s*pass\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi',   $fm, $mm)) $out['admin_pass']   = trim($mm[1]);
+    if (preg_match('/^\s*secret\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $fm, $mm)) $out['admin_secret'] = trim($mm[1]);
+
+    return $out;
+}
+
+function url_join(string $base, string $path): string {
+    return rtrim($base, '/') . '/' . ltrim($path, '/');
+}
+
+function contract_public_url(string $clientid): string {
+    $meta = extract_front_matter_fields(contract_path($clientid));
+    $secret = $meta['admin_secret'] ?? ($meta['admin']['secret'] ?? '');
+    if ($secret === '') {
+        throw new RuntimeException("Missing admin secret for client ID: {$clientid}");
+    }
+    $token = hash_hmac('sha256', $clientid, $secret);
+    $signUrl = url_join(BASE_URL, 'contract.php');
+    return $signUrl . '?clientid=' . rawurlencode($clientid) . '&token=' . rawurlencode($token);
+}
+
+function email_logo_png_cid(PHPMailer $mail, string $dataUrl, string $alt = 'Modulos Design', int $width = 200): string {
+    if ($dataUrl === '') return '';
+    // Fast check for the PNG data URL prefix
+    $prefix = 'data:image/png;base64,';
+    if (stripos($dataUrl, $prefix) !== 0) return '';
+
+    $bin = base64_decode(substr($dataUrl, strlen($prefix)), true);
+    if ($bin === false) return '';
+
+    // Stable CID so replies and forwards keep the reference
+    $cid = 'logo_' . substr(sha1($bin), 0, 12) . '@modulos';
+    $mail->addStringEmbeddedImage($bin, $cid, 'logo.png', 'base64', 'image/png');
+
+    return '<img src="cid:' . $cid . '" alt="' . htmlspecialchars($alt, ENT_QUOTES, 'UTF-8') . '" width="' . (int)$width . '" style="display:block;border:0;outline:0;text-decoration:none;height:auto;">';
+}
+
+function salutationFromName(string $fullName): string {
+    // Normalize whitespace (incl. NBSP) and collapse runs
+    $name = str_replace("\xC2\xA0", ' ', $fullName);
+    $name = trim(preg_replace('/\s+/u', ' ', $name));
+    if ($name === '') return 'there';
+
+    // Strip a single pair of surrounding quotes if present
+    if ($name !== '') {
+        $q0 = $name[0];
+        $q1 = substr($name, -1);
+        if (($q0 === '"' && $q1 === '"') || ($q0 === "'" && $q1 === "'")) {
+            $name = substr($name, 1, -1);
+            $name = trim($name);
+        }
+    }
+
+    // Strip common trailing suffixes
+    $name = preg_replace(
+        '/,?\s*(Jr\.?|Sr\.?|II|III|IV|MD|Ph\.?D|Esq\.?|J\.?D\.?|M\.?B\.?A\.?|RN|DDS|DMD)\s*$/iu',
+        '',
+        $name
+    );
+
+    // Remove one or more leading honorifics (with optional dot)
+    $honorifics = '(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)';
+    while (preg_match('/^' . $honorifics . '\.?[\s\x{00A0}]+/iu', $name)) {
+        $name = preg_replace('/^' . $honorifics . '\.?[\s\x{00A0}]+/iu', '', $name, 1);
+    }
+
+    // First non-initial token
+    foreach (preg_split('/[\s\x{00A0}]+/u', $name) as $tok) {
+        $t = rtrim($tok, '.');
+        if (!preg_match('/^[A-Za-z]\.?$/u', $t)) return $t;
+    }
+    return 'there';
+}
+
+
+function send_contract_email(string $email, string $clientid): bool {
+    global $cfg;
+    $mail = new PHPMailer(true);
+    // Build the public link and load metadata from the .md file
+    $link      = contract_public_url($clientid);
+
+    $mdPath    = contract_path($clientid);
+    $meta      = extract_front_matter_fields($mdPath); // name, email, project, job includes client_name, job, admin.secret, etc.
+    $clientName = $meta['client_name'] ?? '';
+    $firstName   = salutationFromName($clientName);
+    $safeFirst   = htmlspecialchars($firstName ?: 'there', ENT_QUOTES, 'UTF-8');
+    $safeCompany = htmlspecialchars($cfg['company_name'] ?? (defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : 'Modulos Design'), ENT_QUOTES, 'UTF-8');
+    $safeJob     = htmlspecialchars($meta['job'] ?? $clientid, ENT_QUOTES, 'UTF-8');
+    $safeSignature = email_logo_png_cid($mail, $cfg['dev_signature'] ?? '', $safeCompany, 100);
+
+
+    // Prepared date from file mtime (or today if missing)
+    $prepTs        = @filemtime($mdPath) ?: time();
+    $preparedPart  = ' (prepared ' . date('j F Y', $prepTs) . ')';
+
+    // Logo HTML: prefer full HTML from cfg; else build simple <img> if a URL is present
+    $logoHtml = email_logo_png_cid($mail, $cfg['dark_logo'] ?? '', $safeCompany, 200);
+
+    $safeUrl = htmlspecialchars($link, ENT_QUOTES, 'UTF-8');
+
+    // Exact same visual structure as your contract.php style, but wording for "ready to sign"
+    $html = build_admin_email_html_template(
+        $logoHtml,
+        $safeJob,
+        $safeFirst,
+        htmlspecialchars($preparedPart, ENT_QUOTES, 'UTF-8'),
+        $safeUrl,
+        $safeCompany,
+        $safeSignature
+    );
+
+    $alt  = "Hello {$safeFirst},\n\n"
+        . "Your contract{$preparedPart} is ready to view and sign:\n"
+        . "{$link}\n\n"
+        . "Thanks again.\n"
+        . "Kind Regards,\n{$safeCompany}";
+
+    // Send with PHPMailer using your $cfg SMTP (same pattern as contract.php)
+    try {
+
+        $mail->CharSet  = 'UTF-8';
+        $mail->Encoding = 'base64';
+
+        // SMTP if host present
+        $smtpHost = $cfg['smtp_host'] ?? '';
+        if ($smtpHost !== '') {
+            $mail->isSMTP();
+            $mail->SMTPDebug = SMTP::DEBUG_OFF;
+            $mail->Host       = $smtpHost;
+            $mail->SMTPAuth   = true;
+            $mail->Username   = $cfg['smtp_username'] ?? '';
+            $mail->Password   = $cfg['smtp_password'] ?? '';
+            $secure = strtolower((string)($cfg['smtp_secure'] ?? 'ssl')); // 'ssl' or 'tls'
+            if ($secure === 'ssl') {
+                $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
+                $mail->Port       = (int)($cfg['smtp_port'] ?? 465);
+            } else {
+                $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
+                $mail->Port       = (int)($cfg['smtp_port'] ?? 587);
+            }
+        }
+
+        $fromAddress = $cfg['smtp_from']      ?? (defined('MAIL_FROM') ? MAIL_FROM : 'no-reply@modulosdesign.com.au');
+        $fromName    = $cfg['smtp_from_name'] ?? (defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : 'Modulos Design');
+
+        $mail->setFrom($fromAddress, $fromName);
+        if (!empty($cfg['smtp_reply_to'])) $mail->addReplyTo($cfg['smtp_reply_to']);
+        $mail->addAddress($email);
+
+        // Optional BCC list (comma separated)
+        if (!empty($cfg['smtp_bcc'])) {
+            foreach (explode(',', $cfg['smtp_bcc']) as $bcc) {
+                $bcc = trim($bcc);
+                if ($bcc !== '') $mail->addBCC($bcc);
+            }
+        }
+
+        $mail->isHTML(true);
+        $mail->Subject = "Your Building Design contract";
+        $mail->Body    = $html;
+        $mail->AltBody = $alt;
+        $mail->addBCC('drafting@modulosdesign.com.au');
+
+        $mail->send();
+        return true;
+    } catch (Throwable $e) {
+        error_log("contracts-admin: PHPMailer failed for {$email}: ".$e->getMessage());
+        // Fallback to mail()
+        $headers  = "MIME-Version: 1.0\r\n";
+        $headers .= "Content-type: text/html; charset=UTF-8\r\n";
+        $headers .= "From: ".$safeCompany." <".$fromAddress.">\r\n";
+        return mail($email, "Your Building Design contract", $html, $headers);
+    }
+}
+
+// Return absolute paths to every *.md under CONTRACTS_DIR (recursively)
+function list_all_contract_md_files(): array {
+    $base = rtrim(CONTRACTS_DIR, '/\\');
+    $files = [];
+    if (!is_dir($base)) return $files;
+
+    $it = new RecursiveIteratorIterator(
+        new RecursiveDirectoryIterator(
+            $base,
+            FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS
+        ),
+        RecursiveIteratorIterator::LEAVES_ONLY
+    );
+
+    foreach ($it as $path => $info) {
+        if ($info->isFile() && strtolower($info->getExtension()) === 'md') {
+            $files[] = $path;
+        }
+    }
+    return $files;
+}
+
+// Pretty display path (relative to CONTRACTS_DIR)
+function contract_relpath(string $abs): string {
+    $base = realpath(CONTRACTS_DIR) ?: CONTRACTS_DIR;
+    $absR = realpath($abs) ?: $abs;
+    $rel  = ltrim(str_replace('\\','/', substr($absR, strlen($base))), '/');
+    return $rel ?: basename($absR);
+}
+
+// Find the real path for {clientid}.md anywhere under CONTRACTS_DIR
+function find_contract_path_by_clientid(string $clientid): ?string {
+    $id = safe_clientid($clientid);
+    $needle = $id . '.md';
+
+    // quick root check
+    $direct = rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . $needle;
+    if (is_file($direct)) return $direct;
+
+    $it = new RecursiveIteratorIterator(
+        new RecursiveDirectoryIterator(
+            rtrim(CONTRACTS_DIR, '/\\'),
+            FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS
+        ),
+        RecursiveIteratorIterator::LEAVES_ONLY
+    );
+    foreach ($it as $path => $info) {
+        if ($info->isFile()
+            && strtolower($info->getExtension()) === 'md'
+            && strcasecmp($info->getFilename(), $needle) === 0) {
+            return $path;
+        }
+    }
+    return null;
+}
+
+
+/** Build a starter Markdown file with YAML front matter. */
+function build_markdown_template(string $clientid, ?string $name, ?string $email, ?string $project): string {
+    $today = date('Y-m-d');
+    $name  = $name ?? '';
+    $email = $email ?? '';
+    $project = $project ?? '';
+
+    // Generate secure random credentials
+    $adminUser   = bin2hex(random_bytes(4));   // 8 hex chars (~4 bytes)
+    $adminPass   = bin2hex(random_bytes(8));   // 16 hex chars (~8 bytes)
+    $adminSecret = bin2hex(random_bytes(16));  // 32 hex chars (~16 bytes)
+
+    $frontMatter = <<<YAML
+---
+client:
+  id: "{$clientid}"
+  name: "{$name}"
+  email: "{$email}"
+  phone:
+  address:
+project: "{$project}"
+dates:
+  prepared: "{$today}"
+dev:
+  name: 'Modulos Design'
+  email: 'ben@modulos.com.au'
+  phone: '0402 984 082'
+  address: '34 Coplestone Street, Scottsdale, Tas 7260'
+version: 1
+quote:
+  number: "{$clientid}"
+admin:
+  user: "{$adminUser}"
+  pass: "{$adminPass}"
+  secret: "{$adminSecret}"
+---
+
+YAML;
+
+    $body = <<<YAML
+    # Contract of work
+    This Contract is made and entered into as of the date above by and between **[dev.name]** and **[client.name]** (hereinafter referred to as \"Client\").
+    ##### 1. Scope of Services
+    YAML;
+
+    return $frontMatter . $body;
+}
+
+/* ------------------------- LOA HELPERS ------------------------- */
+function loa_path(string $job): string {
+    $id = safe_clientid($job);
+    return rtrim(LOA_DIR, '/\\') . DIRECTORY_SEPARATOR . $id . '.md';
+}
+function loa_public_url(string $job): string {
+    $token  = hash_hmac('sha256', 'loa|'.$job, LOA_TOKEN_SECRET);
+    $signUrl= url_join(LOA_BASE_URL, 'loa.php');
+    return $signUrl . '?job=' . rawurlencode($job) . '&token=' . rawurlencode($token);
+}
+/** Minimal front-matter pulls for LOA */
+function extract_loa_fields(string $file): array {
+    $out = ['client_name'=>'','client_email'=>'','client_address'=>'','property_address'=>''];
+    $txt = @file_get_contents($file);
+    if (!$txt) return $out;
+    if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) return $out;
+    $fm = $m[1];
+
+    $ctx = null;
+    foreach (preg_split('/\R/', $fm) as $line) {
+        // Any new TOP-LEVEL key (no leading spaces) resets context
+        if (preg_match('/^\S[^:]*:\s*$/', $line)) {
+            if (preg_match('/^client\s*:\s*$/', $line))   { $ctx = 'client'; }
+            elseif (preg_match('/^property\s*:\s*$/', $line)) { $ctx = 'property'; }
+            else { $ctx = null; }
+            continue;
+        }
+
+        if ($ctx === 'client') {
+            if (preg_match('/^\s*name\s*:\s*(.+)$/', $line, $mm))   $out['client_name']    = trim($mm[1], " \t\"'");
+            if (preg_match('/^\s*email\s*:\s*(.+)$/', $line, $mm))  $out['client_email']   = trim($mm[1], " \t\"'");
+            if (preg_match('/^\s*address\s*:\s*(.+)$/', $line, $mm))$out['client_address'] = trim($mm[1], " \t\"'");
+        } elseif ($ctx === 'property') {
+            if (preg_match('/^\s*address\s*:\s*(.+)$/', $line, $mm))$out['property_address']= trim($mm[1], " \t\"'");
+        }
+    }
+
+    // Fallback: if no property address, use client address
+    if ($out['property_address'] === '' && $out['client_address'] !== '') {
+        $out['property_address'] = $out['client_address'];
+    }
+    return $out;
+}
+
+
+function lookup_job_for_loa(string $job): array {
+    $job = safe_clientid($job);
+    $empty = ['client_name'=>'','client_email'=>'','property_address'=>'','source'=>null];
+
+    foreach (list_all_contract_md_files() as $file) {
+        $txt = @file_get_contents($file); if (!$txt) continue;
+        if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) continue;
+        $fm = $m[1];
+
+        $fm_job = null;
+        if (preg_match('/^\s*job\s*:\s*["\']?([^"\r\n]+)["\']?/mi', $fm, $mm)) $fm_job = trim($mm[1]);
+        $fname_id = clientid_from_filename($file);
+
+        if ($fm_job === $job || $fname_id === $job) {
+            $info = extract_front_matter_fields($file);
+            $client_name  = $info['client_name']  ?? '';
+            $client_email = $info['client_email'] ?? '';
+
+            $client_addr = null;
+            if (preg_match('/^\s*client\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block)
+                && preg_match('/^\s*address\s*:\s*(.+)$/mi', $block[1], $ma)) {
+                $client_addr = trim($ma[1], " \t\"'");
+            }
+            $prop_addr = null;
+            if (preg_match('/^\s*property\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $pblock)
+                && preg_match('/^\s*address\s*:\s*(.+)$/mi', $pblock[1], $mp)) {
+                $prop_addr = trim($mp[1], " \t\"'");
+            }
+            return [
+                'client_name'      => $client_name,
+                'client_email'     => $client_email,
+                'property_address' => $prop_addr ?: $client_addr ?: '',
+                'source'           => 'contract',
+                'clientid'         => $fname_id,
+            ];
+        }
+    }
+    return $empty;
+}
+
+
+
+/* -------------------------------------------------------------------------- */
+/*                                  API MODE                                   */
+/* -------------------------------------------------------------------------- */
+
+$action = $_GET['action'] ?? $_POST['action'] ?? null;
+if ($action) {
+    // For API calls, enforce admin auth except for mark_signed which uses a shared secret.
+    if ($action !== 'mark_signed') {
+        require_admin_auth();
+    }
+
+    try {
+        switch ($action) {
+            case 'list':
+                $files = glob(rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . '*.md');
+                $rows = [];
+                foreach ($files as $file) {
+                    $clientid = clientid_from_filename($file);
+                    $stat = get_status($clientid);
+
+                    // build signed public URL (with token)
+                    $publicUrl = null;
+                    try {
+                        $publicUrl = contract_public_url($clientid);
+                    } catch (Throwable $e) {
+                        $publicUrl = null; // missing secret or bad front matter
+                    }
+
+                    $rows[] = [
+                        'clientid' => $clientid,
+                        'filename' => basename($file),
+                        'size' => filesize($file),
+                        'mtime' => filemtime($file),
+                        'sent' => (int)($stat['sent'] ?? 0),
+                        'sent_at' => $stat['sent_at'] ?? null,
+                        'signed' => (int)($stat['signed'] ?? 0),
+                        'signed_at' => $stat['signed_at'] ?? null,
+                        'last_email_to' => $stat['last_email_to'] ?? null,
+                        'public_url' => $publicUrl,   // <-- add this
+                    ];
+                }
+                // Sort by most-recent modified first
+                usort($rows, fn($a,$b) => ($b['mtime'] <=> $a['mtime']));
+                json_response(['ok' => true, 'contracts' => $rows]);
+                break;
+
+            case 'read':
+                $clientid = safe_clientid($_GET['clientid'] ?? $_POST['clientid'] ?? '');
+                $path = contract_path($clientid);
+                if (!file_exists($path)) {
+                    json_response(['ok' => false, 'error' => 'File not found'], 404);
+                }
+                $content = file_get_contents($path);
+                json_response(['ok' => true, 'content' => $content]);
+                break;
+
+            case 'save':
+                $clientid = safe_clientid($_POST['clientid'] ?? '');
+                $content = $_POST['content'] ?? '';
+                $path = contract_path($clientid);
+                if (!is_dir(CONTRACTS_DIR)) {
+                    @mkdir(CONTRACTS_DIR, 0775, true);
+                }
+                $ok = file_put_contents($path, $content, LOCK_EX);
+                if ($ok === false) {
+                    json_response(['ok' => false, 'error' => 'Write failed'], 500);
+                }
+                json_response(['ok' => true]);
+                break;
+
+            case 'send_link':
+                $clientid = safe_clientid($_POST['clientid'] ?? '');
+                $email = trim($_POST['email'] ?? '');
+                if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+                    json_response(['ok' => false, 'error' => 'Invalid email'], 400);
+                }
+                $ok = send_contract_email($email, $clientid);
+                if ($ok) {
+                    set_sent($clientid, $email);
+                    json_response(['ok' => true]);
+                } else {
+                    json_response(['ok' => false, 'error' => 'Failed to send email'], 500);
+                }
+                break;
+
+            case 'mark_signed':
+                // This endpoint is meant to be called from the public contracts.php after a successful signature
+                $secret = $_GET['secret'] ?? $_POST['secret'] ?? '';
+                if ($secret !== ADMIN_SHARED_SECRET) {
+                    json_response(['ok' => false, 'error' => 'Unauthorized'], 401);
+                }
+                $clientid = safe_clientid($_GET['clientid'] ?? $_POST['clientid'] ?? '');
+                $signerName = $_GET['name'] ?? $_POST['name'] ?? null;
+                $pdfPath = $_GET['pdf'] ?? $_POST['pdf'] ?? null;
+                $ip = $_SERVER['REMOTE_ADDR'] ?? null;
+                set_signed($clientid, $signerName, $ip, $pdfPath);
+                json_response(['ok' => true]);
+                break;
+
+            case 'toggle_signed':
+                // Manual override from the admin UI
+                $clientid = safe_clientid($_POST['clientid'] ?? '');
+                $flag = (int)($_POST['flag'] ?? 0);
+                if ($flag) {
+                    set_signed($clientid, null, null, null);
+                } else {
+                    $pdo = ensure_db();
+                    $stmt = $pdo->prepare("UPDATE contract_status SET signed = 0, signed_at = NULL WHERE clientid = :id");
+                    $stmt->execute([':id' => $clientid]);
+                }
+                json_response(['ok' => true]);
+                break;
+            case 'toggle_sent': {
+    $clientid = safe_clientid($_POST['clientid'] ?? '');
+    $flag = (int)($_POST['flag'] ?? 0);
+    $pdo = ensure_db();
+    if ($flag) {
+        $stmt = $pdo->prepare("UPDATE contract_status SET sent = 1, sent_at = :now WHERE clientid = :id");
+        $stmt->execute([':id' => $clientid, ':now' => date('Y-m-d H:i:s')]);
+    } else {
+        $stmt = $pdo->prepare("UPDATE contract_status SET sent = 0, sent_at = NULL WHERE clientid = :id");
+        $stmt->execute([':id' => $clientid]);
+    }
+    json_response(['ok' => true]);
+    break;
+}
+            case 'create':
+                // Create a new {clientid}.md using a starter template
+                $clientid  = safe_clientid($_POST['clientid'] ?? '');
+                $name      = trim($_POST['name'] ?? '');
+                $email     = trim($_POST['email'] ?? '');
+                $project   = trim($_POST['project'] ?? '');
+                $overwrite = (int)($_POST['overwrite'] ?? 0);
+
+                $path = contract_path($clientid);
+                if (file_exists($path) && !$overwrite) {
+                    json_response(['ok' => false, 'error' => 'File already exists'], 409);
+                }
+                if (!is_dir(CONTRACTS_DIR)) {
+                    @mkdir(CONTRACTS_DIR, 0775, true);
+                }
+                $content = build_markdown_template($clientid, $name, $email, $project);
+                $ok = file_put_contents($path, $content, LOCK_EX);
+                if ($ok === false) {
+                    json_response(['ok' => false, 'error' => 'Create failed'], 500);
+                }
+                // Seed DB row if missing
+                $pdo = ensure_db();
+                try {
+                    $stmt = $pdo->prepare("INSERT OR IGNORE INTO contract_status (clientid, sent, signed) VALUES (:id, 0, 0)");
+                    $stmt->execute([':id' => $clientid]);
+                } catch (Throwable $e) {}
+                json_response(['ok' => true]);
+                break;
+
+            case 'loa_list': {
+                require_admin_auth();
+                $rows = [];
+                foreach (glob(rtrim(LOA_DIR,'/\\').'/*.md') as $file) {
+                    $base = basename($file);
+                    if ($base === 'default-authorisation.md') continue;
+                    $job  = clientid_from_filename($file); // filename without .md
+                    $st   = @stat($file);
+                    $info = extract_loa_fields($file);
+                    $signedPdf = rtrim(LOA_DIR,'/\\')."/{$job}_signed_loa.pdf";
+                    $rows[] = [
+                        'job'      => $job,
+                        'filename' => $base,
+                        'mtime'    => $st ? ($st['mtime'] ?? time()) : time(),
+                        'client'   => $info['client_name'],
+                        'email'    => $info['client_email'],
+                        'address'  => $info['property_address'],
+                        'signed'   => (int)file_exists($signedPdf),
+                        'public_url' => loa_public_url($job),
+                        'pdf_url'    => url_join(LOA_BASE_URL, "loa/{$job}_signed_loa.pdf"),
+                        'html_url'   => url_join(LOA_BASE_URL, "loa/{$job}_signed_loa.html"),
+                    ];
+                }
+                usort($rows, fn($a,$b)=>($b['mtime']<=>$a['mtime']));
+                json_response(['ok'=>true,'loas'=>$rows]); }
+
+            case 'loa_read': {
+                require_admin_auth();
+                $job = safe_clientid($_GET['job'] ?? $_POST['job'] ?? '');
+                $path= loa_path($job);
+                if (!file_exists($path)) json_response(['ok'=>false,'error'=>'File not found'],404);
+                json_response(['ok'=>true,'content'=>file_get_contents($path)]);
+            }
+
+            case 'loa_save': {
+                require_admin_auth();
+                $job = safe_clientid($_POST['job'] ?? '');
+                $content = $_POST['content'] ?? '';
+                $path= loa_path($job);
+                if (!is_dir(LOA_DIR)) @mkdir(LOA_DIR,0775,true);
+                $ok = file_put_contents($path,$content,LOCK_EX);
+                if ($ok===false) json_response(['ok'=>false,'error'=>'Write failed'],500);
+                json_response(['ok'=>true]);
+            }
+
+            case 'loa_create': {
+                require_admin_auth();
+                $job   = safe_clientid($_POST['job'] ?? '');
+                $name  = trim($_POST['client_name'] ?? '');
+                $email = trim($_POST['client_email'] ?? '');
+                $addr  = trim($_POST['property_address'] ?? '');
+                $dst   = loa_path($job);
+                if (file_exists($dst) && !((int)($_POST['overwrite'] ?? 0))) {
+                    json_response(['ok'=>false,'error'=>'File already exists'],409);
+                }
+                if (!is_dir(LOA_DIR)) @mkdir(LOA_DIR,0775,true);
+                $tpl = @file_get_contents(rtrim(LOA_DIR,'/\\').'/default-authorisation.md') ?: "---\njob: {$job}\n---\n# Authorisation";
+                // light substitutions
+                $tpl = preg_replace('/^---\R(.+?)\R---/s', function($m) use($job,$name,$email,$addr){
+                    $yaml = $m[1];
+                    $yaml = preg_replace('/\bjob:\s*.*/','job: '.$job,$yaml);
+                    if ($name  !== '') $yaml = preg_replace('/(client:\s*\R(?:.*\R)*?)^\s*name:.*$/m', '$1  name: '.$name, $yaml, 1);
+                    if ($email !== '') $yaml = preg_replace('/(client:\s*\R(?:.*\R)*?)^\s*email:.*$/m','$1  email: '.$email,$yaml,1);
+                    if ($addr  !== '') $yaml = preg_replace('/(property:\s*\R(?:.*\R)*?)^\s*address:.*$/m','$1  address: '.$addr,$yaml,1);
+                    return "---\n".$yaml."\n---";
+                }, $tpl, 1) ?? $tpl;
+                file_put_contents($dst,$tpl);
+                json_response(['ok'=>true]);
+            }
+
+            case 'loa_send_link': {
+                require_admin_auth();
+                $job = safe_clientid($_POST['job'] ?? '');
+                $to  = trim($_POST['email'] ?? '');
+                if (!filter_var($to, FILTER_VALIDATE_EMAIL)) json_response(['ok'=>false,'error'=>'Invalid email'],400);
+                $url = loa_public_url($job);
+                $addr = extract_loa_fields(loa_path($job))['property_address'] ?: ('Job '.$job);
+
+                $mail = new PHPMailer(true);
+                try {
+                    // (mirror your SMTP bootstrap)
+                    $smtpHost = $cfg['smtp_host'] ?? '';
+                    if ($smtpHost) {
+                        $mail->isSMTP();
+                        $mail->SMTPDebug = SMTP::DEBUG_OFF;
+                        $mail->Host      = $smtpHost;
+                        $mail->SMTPAuth  = true;
+                        $mail->Username  = $cfg['smtp_username'] ?? '';
+                        $mail->Password  = $cfg['smtp_password'] ?? '';
+                        $secure = strtolower((string)($cfg['smtp_secure'] ?? 'ssl'));
+                        if ($secure === 'ssl') { $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; $mail->Port = (int)($cfg['smtp_port'] ?? 465); }
+                        else { $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = (int)($cfg['smtp_port'] ?? 587); }
+                    }
+                    $fromAddress = $cfg['smtp_from'] ?? (defined('MAIL_FROM') ? MAIL_FROM : 'no-reply@modulosdesign.com.au');
+                    $fromName    = $cfg['smtp_from_name'] ?? (defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : 'Modulos Design');
+
+                    $mail->setFrom($fromAddress, $fromName);
+                    if (!empty($cfg['smtp_reply_to'])) $mail->addReplyTo($cfg['smtp_reply_to']);
+                    $mail->addAddress($to);
+                    if (!empty($cfg['smtp_bcc'])) foreach (explode(',', $cfg['smtp_bcc']) as $bcc) { $bcc=trim($bcc); if ($bcc) $mail->addBCC($bcc); }
+                    // Always keep your audit trail BCC
+                    $mail->addBCC('drafting@modulosdesign.com.au');
+
+                    $btn = '<a href="'.htmlspecialchars($url,ENT_QUOTES).'" style="display:inline-block;padding:12px 22px;background:#635A4A;color:#fff;text-decoration:none">Open Authorisation</a>';
+                    $mail->isHTML(true);
+                    $mail->Subject = "Please review & sign your Authorisation - {$addr}";
+                    $mail->Body    = "<p>Hi,</p><p>Please review and sign the Authorisation for <strong>".htmlspecialchars($addr,ENT_QUOTES)."</strong>.</p><p>{$btn}</p><p>If the button doesn't work, use this link:<br>".htmlspecialchars($url,ENT_QUOTES)."</p>";
+                    $mail->AltBody = "Please review and sign the Authorisation for {$addr}\n{$url}";
+                    $mail->send();
+                    json_response(['ok'=>true]);
+                } catch (Throwable $e) {
+                    error_log('loa_send_link: '.$e->getMessage());
+                    json_response(['ok'=>false,'error'=>'Failed to send email'],500);
+                }
+            }
+
+        	case 'loa_lookup': {
+    			require_admin_auth();
+    			$job = safe_clientid($_GET['job'] ?? $_POST['job'] ?? '');
+    			$data = lookup_job_for_loa($job);
+    			$found = (bool)($data['client_name'] || $data['client_email'] || $data['property_address']);
+    			json_response(['ok' => true, 'found' => $found, 'data' => $data]);
+			}
+
+
+            default:
+                json_response(['ok' => false, 'error' => 'Unknown action'], 400);
+        }
+    } catch (Throwable $e) {
+        json_response(['ok' => false, 'error' => $e->getMessage()], 500);
+    }
+    exit;
+}
+
+/* -------------------------------------------------------------------------- */
+/*                               EMAIL TEMPLATE                               */
+/*  Build the styled HTML email body identical in structure to contract.php,  */
+/*  adjusted for "ready to sign" wording.                                     */
+/* -------------------------------------------------------------------------- */
+
+function build_admin_email_html_template(string $logoHtml, string $safeJob, string $firstNameSafe, string $preparedPartHtml, string $safeUrl, string $safeCompany, string $safeSignature): string {
+    $html = <<<HTML
+<!-- Preheader stays hidden at 0px -->
+<div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:0;line-height:0;">
+  Your contract is ready to view and sign. Lets get started!
+</div>
+
+<div style="background:#f6f7fb;padding:24px;font-size:14px;line-height:1.6;">
+  <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;line-height:1.6;font-family:Arial,Helvetica,sans-serif;">
+    <tr>
+      <td style="font-size:14px;line-height:1.6;padding:20px 24px;background:#D9CCC1;color:#ffffff;">
+        <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-size:14px;line-height:1.6;">
+          <tr>
+            <td style="font-size:14px;line-height:1.6;">$logoHtml</td>
+            <td align="right" style="font-weight:700;font-size:14px;line-height:1.6;">Job #$safeJob</td>
+          </tr>
+        </table>
+      </td>
+    </tr>
+
+    <tr>
+      <td style="padding:28px 24px 8px;line-height:1.6;color:#635A4A;font-size:14px;">
+        <div style="font-size:14px;margin-bottom:8px;line-height:1.6;">Hello {$firstNameSafe},</div>
+        <div style="font-size:14px;line-height:1.6;">
+          Your contract{$preparedPartHtml} is ready to view and sign. Use the link below:
+        </div>
+      </td>
+    </tr>
+
+    <tr>
+      <td align="center" style="padding:20px 24px 8px;font-size:14px;line-height:1.6;">
+        <!--[if mso]>
+        <v:rect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
+                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:16px;line-height:1.6;">View Contract</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;line-height:1.6;mso-hide:all"
+           target="_blank" rel="noopener">View Contract</a>
+        <!--<![endif]-->
+      </td>
+    </tr>
+
+    <tr>
+      <td style="padding:8px 24px 24px;font-size:14px;line-height:1.6;color:#635A4A;">
+        <div style="font-size:14px;line-height:1.6;">
+          If the button doesn’t work, copy and paste this link into your browser:<br>
+          <span style="word-break:break-all;color:#635A4A;font-size:14px;line-height:1.6;">$safeUrl</span>
+        </div>
+        <div style="font-size:14px;line-height:1.6;margin-top:18px;">
+          Thank you once again. We’re excited to be working with you and look forward to getting started. Once the contract is signed, we will issue an invoice for the initial deposit and begin work as soon as we receive confirmation.<br><br>
+          <b>Kind Regards,</b><br><br>$safeSignature<br>Benjamin Harris<br>$safeCompany<br>0402 984 082  |  drafting@modulosdesign.com.au
+        </div>
+      </td>
+    </tr>
+
+    <tr>
+      <td style="padding:12px 24px;background:#28261E;color:#D9CCC1;font-size:14px;line-height:1.6;">
+        This is an automated message. Please reply to this email if you have any questions.
+      </td>
+    </tr>
+  </table>
+</div>
+HTML;
+    return $html;
+}
+
+
+// No action, render the admin UI
+require_admin_auth();
+?>
+<!doctype html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <title>Contracts Admin</title>
+        <link rel="shortcut icon" href="../../internal/images/blueprint.ico" type="image/x-icon">
+
+        <meta name="robots" content="noindex">
+        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
+        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js" integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q" crossorigin="anonymous"></script>
+        <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: #f7f7f7; }
+            .badge-status { font-size: .85rem; }
+            .status-sent { background: #e6f4ea; color: #0f5132; }
+            .status-signed { background: #e7f1ff; color: #084298; }
+            .monosmall { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: .85rem; }
+            .pointer { cursor: pointer; }
+            .table thead th { position: sticky; top: 0; background: #fff; z-index: 1; }
+        </style>
+        <script>window.CSRF = "<?php echo $csrf; ?>";</script>
+    </head>
+
+    <nav class="navbar navbar-expand-lg sticky-top bg-brown-dark brown-light border-bottom border-body d-print-none" data-bs-theme="dark">
+        <div class="container-fluid">
+            <span class="navbar-brand brown-light">
+                <img src="../../internal/images/blueprint-logo-light.png" alt="Modulos Design" width="30" height="24" class="d-inline-block align-text-top" >
+                Modulos Design
+            </span>
+            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent" aria-controls="navbarContent" aria-expanded="false" aria-label="Toggle navigation">
+                <span class="navbar-toggler-icon"></span>
+            </button>
+            <div class="collapse navbar-collapse" id="navbarContent">
+                <ul class="navbar-nav me-auto mb-2 mb-lg-0">
+                    <li class="nav-item">
+                        <a class="nav-link active" aria-current="page" href="#">Home</a>
+                    </li>
+                    <li class="nav-item"><a class="nav-link <?= $tab==='contracts'?'active':'' ?>" href="?tab=contracts">Contracts</a></li>
+                    <li class="nav-item"><a class="nav-link <?= $tab==='loas'?'active':'' ?>" href="?tab=loas">LOAs</a></li>
+                </ul>
+            </div>
+        </div>
+    </nav>
+
+    <body>
+        <div class="container py-4">
+
+            <?php if ($tab === 'contracts'): ?>
+
+            <div class="row">
+                <div class="col-12 col-md">
+                    <h1 class="h3 mb-0">Contracts Admin</h1>
+                </div>
+                <div class="col-12 col-md-4">
+                    <div class="input-group mb-3">
+                        <input id="search" type="search" class="form-control rounded-0" placeholder="Search client id or email">
+                        <button class="btn btn-sm bg-brown-five brown-three rounded-0" id="refreshBtn">Refresh Page</button>
+                        <button class="btn btn-sm bg-brown-three brown-five rounded-0" id="newBtn">Create New</button>
+                    </div>
+                </div>
+            </div>
+
+            <div class="alert alert-info rounded-0">
+                Contracts folder: <span class="monosmall"><?php echo htmlspecialchars(CONTRACTS_DIR, ENT_QUOTES, 'UTF-8'); ?></span>.
+                Signing page base URL: <span class="monosmall"><?php echo htmlspecialchars(BASE_URL, ENT_QUOTES, 'UTF-8'); ?></span>
+            </div>
+
+            <div class="table-responsive">
+                <table class="table table-hover align-middle" id="contractsTable">
+                    <thead>
+                        <tr>
+                            <th>Client ID</th>
+                            <th>Client</th>
+                            <th>Modified</th>
+                            <th>Sent</th>
+                            <th>Signed</th>
+                            <th>Email</th>
+                            <th>Actions</th>
+                        </tr>
+                    </thead>
+                    <tbody></tbody>
+                </table>
+            </div>
+        </div>
+
+        <!-- Edit Modal -->
+        <div class="modal fade" id="editModal" tabindex="-1" aria-hidden="true">
+            <div class="modal-dialog modal-xl modal-dialog-scrollable">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h5 class="modal-title">Edit Contract <span id="editClientId" class="text-muted"></span></h5>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                    </div>
+                    <div class="modal-body">
+                        <textarea id="editContent" class="form-control" rows="20" spellcheck="false"></textarea>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-sm bg-brown-five brown-three rounded-0" data-bs-dismiss="modal">Close</button>
+                        <button type="button" class="btn btn-sm bg-brown-three brown-five rounded-0" id="saveBtn">Save</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- Send Modal -->
+        <div class="modal fade" id="sendModal" tabindex="-1" aria-hidden="true">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h5 class="modal-title">Email Contract Link</h5>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                    </div>
+                    <div class="modal-body">
+                        <div class="mb-3">
+                            <label class="form-label">Send to email</label>
+                            <input id="sendEmail" type="email" class="form-control" placeholder="client@example.com">
+                        </div>
+                        <div class="alert alert-secondary">
+                            The email includes a link to the signing page for this client id.
+                        </div>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-sm bg-brown-five brown-three rounded-0" data-bs-dismiss="modal">Close</button>
+                        <button type="button" class="btn btn-sm bg-brown-three brown-five rounded-0" id="confirmSendBtn">Send</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- New Contract Modal -->
+        <div class="modal fade" id="newModal" tabindex="-1" aria-hidden="true">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h5 class="modal-title">Create new contract</h5>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                    </div>
+                    <div class="modal-body">
+                        <div class="mb-3">
+                            <label class="form-label">Client ID (used for filename)</label>
+                            <input id="newClientId" type="text" class="form-control" placeholder="3043">
+                            <div class="form-text">Allowed letters, numbers, underscore, hyphen</div>
+                        </div>
+                        <div class="mb-3">
+                            <label class="form-label">Client name</label>
+                            <input id="newName" type="text" class="form-control" placeholder="Client Name">
+                        </div>
+                        <div class="mb-3">
+                            <label class="form-label">Client email</label>
+                            <input id="newEmail" type="email" class="form-control" placeholder="client@example.com">
+                        </div>
+                        <div class="mb-3">
+                            <label class="form-label">Project title</label>
+                            <input id="newProject" type="text" class="form-control" placeholder="Project">
+                        </div>
+                        <div class="form-check">
+                            <input class="form-check-input" type="checkbox" id="newOverwrite">
+                            <label class="form-check-label" for="newOverwrite">Overwrite if file exists</label>
+                        </div>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-sm bg-brown-five brown-three rounded-0" data-bs-dismiss="modal">Close</button>
+                        <button type="button" class="btn btn-sm bg-brown-three brown-five rounded-0" id="createBtn">Create</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
+        <script>
+            const tableBody = document.querySelector('#contractsTable tbody');
+            const searchInput = document.querySelector('#search');
+            const refreshBtn = document.querySelector('#refreshBtn');
+            const newBtn = document.querySelector('#newBtn');
+
+            let currentRows = [];
+            let currentEditId = null;
+            let currentSendId = null;
+
+            function fmtDate(ts) {
+                if (!ts) return '';
+                const d = new Date(ts * 1000);
+                return d.toLocaleString();
+            }
+
+            function linkFor(id) {
+                return '../contract.php?clientid=' + encodeURIComponent(id);
+            }
+
+            function renderTable(rows) {
+                tableBody.innerHTML = '';
+                rows.forEach(r => {
+                    const url = r.public_url || ''; // empty string if missing
+                    const tr = document.createElement('tr');
+                    tr.innerHTML = `
+						<td class="monosmall">${r.clientid}</td>
+						<td>${r.filename}</td>
+						<td>${fmtDate(r.mtime)}</td>
+						<td>
+						${r.sent ? '<span class="badge badge-status status-sent">✓ sent</span>' : '<span class="text-muted">—</span>'}
+						<button class="btn rounded-0 btn-sm ${r.sent ? 'btn-outline-danger' : 'btn-outline-success'} ms-2 toggleSentBtn">${r.sent ? 'Clear' : 'Mark sent'}</button>
+									</td>
+						<td>
+						${r.signed ? '<span class="badge badge-status status-signed">✓ signed</span>' : '<span class="text-muted">—</span>'}
+						<button class="btn rounded-0 btn-sm ${r.signed ? 'btn-outline-danger' : 'btn-outline-success'} ms-2 toggleSignedBtn">${r.signed ? 'Clear' : 'Mark signed'}</button>
+									</td>
+						<td>${r.last_email_to ? r.last_email_to : ''}</td>
+						<td>
+						<div class="btn-group">
+						<button class="btn rounded-0 btn-sm bg-brown-five brown-three editBtn">Edit</button>
+						<button class="btn rounded-0 btn-sm bg-brown-three brown-five sendBtn">Email link</button>
+						<button class="btn rounded-0 btn-sm btn-outline-dark copyBtn" data-link="${url}">Copy link</button>
+						<a class="btn rounded-0 btn-sm btn-outline-secondary" href="${url}" target="_blank" rel="noopener">Open</a>
+						</div>
+						</td>`;
+                    tr.querySelector('.editBtn').addEventListener('click', () => openEdit(r.clientid));
+                    tr.querySelector('.sendBtn').addEventListener('click', () => openSend(r.clientid));
+                    tr.querySelector('.copyBtn').addEventListener('click', (ev) => {
+  						const url = ev.currentTarget.getAttribute('data-link') || '';
+  						if (url) navigator.clipboard.writeText(url);
+					});
+                    tr.querySelector('.toggleSignedBtn').addEventListener('click', async () => {
+                        const flag = r.signed ? 0 : 1;
+                        const fd = new FormData();
+                        fd.append('action', 'toggle_signed');
+                        fd.append('clientid', r.clientid);
+                        fd.append('flag', flag);
+                        fd.append('csrf', window.CSRF);
+                        const res = await fetch('?action=toggle_signed', { method: 'POST', body: fd });
+                        const js = await res.json();
+                        if (js.ok) {
+                            loadData();
+                        } else {
+                            alert(js.error || 'Failed');
+                        }
+                    });
+                    tr.querySelector('.toggleSentBtn').addEventListener('click', async () => {
+                        const flag = r.sent ? 0 : 1;
+                        const fd = new FormData();
+                        fd.append('action', 'toggle_sent');
+                        fd.append('clientid', r.clientid);
+                        fd.append('flag', flag);
+                        fd.append('csrf', window.CSRF);
+                        const res = await fetch('?action=toggle_sent', { method: 'POST', body: fd });
+                        const js = await res.json();
+                        if (js.ok) {
+                            loadData();
+                        } else {
+                            alert(js.error || 'Failed');
+                        }
+                    });
+                    tableBody.appendChild(tr);
+                });
+            }
+
+            async function loadData() {
+                const res = await fetch('?action=list');
+                const js = await res.json();
+                if (!js.ok) {
+                    alert(js.error || 'Failed to load');
+                    return;
+                }
+                currentRows = js.contracts;
+                applyFilter();
+            }
+
+            function applyFilter() {
+                const q = searchInput.value.trim().toLowerCase();
+                if (!q) {
+                    renderTable(currentRows);
+                    return;
+                }
+                const rows = currentRows.filter(r =>
+                                                r.clientid.toLowerCase().includes(q) ||
+                                                (r.last_email_to || '').toLowerCase().includes(q)
+                                               );
+                renderTable(rows);
+            }
+
+            async function openEdit(id) {
+                currentEditId = id;
+                const res = await fetch('?action=read&clientid=' + encodeURIComponent(id));
+                const js = await res.json();
+                if (!js.ok) { alert(js.error || 'Failed'); return; }
+                document.querySelector('#editClientId').textContent = id;
+                document.querySelector('#editContent').value = js.content;
+                const modal = new bootstrap.Modal('#editModal');
+                modal.show();
+            }
+
+            document.querySelector('#saveBtn').addEventListener('click', async () => {
+                const content = document.querySelector('#editContent').value;
+                const fd = new FormData();
+                fd.append('action', 'save');
+                fd.append('clientid', currentEditId);
+                fd.append('content', content);
+                fd.append('csrf', window.CSRF);
+                const res = await fetch('?action=save', { method: 'POST', body: fd });
+                const js = await res.json();
+                if (js.ok) {
+                    bootstrap.Modal.getInstance(document.querySelector('#editModal')).hide();
+                    loadData();
+                } else {
+                    alert(js.error || 'Save failed');
+                }
+            });
+
+            function openSend(id) {
+                currentSendId = id;
+                document.querySelector('#sendEmail').value = '';
+                const modal = new bootstrap.Modal('#sendModal');
+                modal.show();
+            }
+
+            document.querySelector('#confirmSendBtn').addEventListener('click', async () => {
+                const email = document.querySelector('#sendEmail').value.trim();
+                if (!email) { alert('Please enter an email'); return; }
+                const fd = new FormData();
+                fd.append('action', 'send_link');
+                fd.append('clientid', currentSendId);
+                fd.append('email', email);
+                fd.append('csrf', window.CSRF);
+                const res = await fetch('?action=send_link', { method: 'POST', body: fd });
+                const js = await res.json();
+                if (js.ok) {
+                    bootstrap.Modal.getInstance(document.querySelector('#sendModal')).hide();
+                    loadData();
+                } else {
+                    alert(js.error || 'Failed to send');
+                }
+            });
+
+            newBtn.addEventListener('click', () => {
+                document.querySelector('#newClientId').value = '';
+                document.querySelector('#newName').value = '';
+                document.querySelector('#newEmail').value = '';
+                document.querySelector('#newProject').value = '';
+                document.querySelector('#newOverwrite').checked = false;
+                const modal = new bootstrap.Modal('#newModal');
+                modal.show();
+            });
+
+            document.querySelector('#createBtn').addEventListener('click', async () => {
+                const id = document.querySelector('#newClientId').value.trim();
+                const name = document.querySelector('#newName').value.trim();
+                const email = document.querySelector('#newEmail').value.trim();
+                const project = document.querySelector('#newProject').value.trim();
+                const overwrite = document.querySelector('#newOverwrite').checked ? 1 : 0;
+                if (!id) { alert('Client ID is required'); return; }
+                const fd = new FormData();
+                fd.append('action', 'create');
+                fd.append('clientid', id);
+                fd.append('name', name);
+                fd.append('email', email);
+                fd.append('project', project);
+                fd.append('overwrite', overwrite);
+                fd.append('csrf', window.CSRF);
+                const res = await fetch('?action=create', { method: 'POST', body: fd });
+                const js = await res.json();
+                if (js.ok) {
+                    bootstrap.Modal.getInstance(document.querySelector('#newModal')).hide();
+                    loadData();
+                } else {
+                    alert(js.error || 'Create failed');
+                }
+            });
+
+            searchInput.addEventListener('input', applyFilter);
+            refreshBtn.addEventListener('click', loadData);
+
+            loadData();
+        </script>
+
+        <?php else: /* LOAs tab */ ?>
+        <div class="row">
+            <div class="col-12 col-md">
+                <h1 class="h3 mb-0">LOA Admin</h1>
+            </div>
+            <div class="col-12 col-md-4">
+                <div class="input-group mb-3">
+                    <input id="loaSearch" type="search" class="form-control rounded-0" placeholder="Search job, client, email">
+                    <button class="btn btn-sm bg-brown-five brown-three rounded-0" id="loaRefreshBtn">Refresh Page</button>
+                    <button class="btn btn-sm bg-brown-three brown-five rounded-0" id="loaNewBtn">Create LOA</button>
+                </div>
+            </div>
+        </div>
+
+        <div class="alert alert-info rounded-0">
+            LOA folder: <span class="monosmall"><?= htmlspecialchars(LOA_DIR, ENT_QUOTES) ?></span>.
+            Signing page base URL: <span class="monosmall"><?= htmlspecialchars(LOA_BASE_URL, ENT_QUOTES) ?>/loa.php</span>
+        </div>
+
+        <div class="table-responsive">
+            <table class="table table-hover align-middle" id="loaTable">
+                <thead>
+                    <tr>
+                        <th>Job</th>
+                        <th>Client</th>
+                        <th>Property</th>
+                        <th>Modified</th>
+                        <th>Signed</th>
+                        <th>Email</th>
+                        <th>Actions</th>
+                    </tr>
+                </thead>
+                <tbody></tbody>
+            </table>
+        </div>
+
+        <!-- LOA Edit Modal -->
+        <div class="modal fade" id="loaEditModal" tabindex="-1" aria-hidden="true">
+            <div class="modal-dialog modal-xl modal-dialog-scrollable">
+                <div class="modal-content">
+                    <div class="modal-header"><h5 class="modal-title">Edit LOA <span id="loaEditJob" class="text-muted"></span></h5>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
+                    <div class="modal-body">
+                        <textarea id="loaEditContent" class="form-control" rows="20" spellcheck="false"></textarea>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-sm bg-brown-five brown-three rounded-0" data-bs-dismiss="modal">Close</button>
+                        <button type="button" class="btn btn-sm bg-brown-three brown-five rounded-0" id="loaSaveBtn">Save</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- LOA Send Modal -->
+        <div class="modal fade" id="loaSendModal" tabindex="-1" aria-hidden="true">
+            <div class="modal-dialog"><div class="modal-content">
+                <div class="modal-header"><h5 class="modal-title">Email LOA Link</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
+                <div class="modal-body">
+                    <div class="mb-3"><label class="form-label">Send to email</label>
+                        <input id="loaSendEmail" type="email" class="form-control" placeholder="client@example.com"></div>
+                    <div class="alert alert-secondary">The email includes a link to the LOA signing page for this job.</div>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-sm bg-brown-five brown-three rounded-0" data-bs-dismiss="modal">Close</button>
+                    <button type="button" class="btn btn-sm bg-brown-three brown-five rounded-0" id="loaConfirmSendBtn">Send</button>
+                </div>
+                </div></div>
+        </div>
+
+        <!-- LOA New Modal -->
+        <div class="modal fade" id="loaNewModal" tabindex="-1" aria-hidden="true">
+            <div class="modal-dialog"><div class="modal-content">
+                <div class="modal-header"><h5 class="modal-title">Create new LOA</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
+                <div class="modal-body">
+                    <div class="mb-3"><label class="form-label">Job #</label>
+                        <input id="loaNewJob" type="text" class="form-control" placeholder="1234"></div>
+                    <div class="mb-3"><label class="form-label">Client name</label>
+                        <input id="loaNewName" type="text" class="form-control" placeholder="Client Name"></div>
+                    <div class="mb-3"><label class="form-label">Client email</label>
+                        <input id="loaNewEmail" type="email" class="form-control" placeholder="client@example.com"></div>
+                    <div class="mb-3"><label class="form-label">Property address</label>
+                        <input id="loaNewAddress" type="text" class="form-control" placeholder="1 Example St, Scottsdale TAS"></div>
+                    <div class="form-check">
+                        <input class="form-check-input" type="checkbox" id="loaNewOverwrite">
+                        <label class="form-check-label" for="loaNewOverwrite">Overwrite if file exists</label>
+                    </div>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-sm bg-brown-five brown-three rounded-0" data-bs-dismiss="modal">Close</button>
+                    <button type="button" class="btn btn-sm bg-brown-three brown-five rounded-0" id="loaCreateBtn">Create</button>
+                </div>
+                </div></div>
+        </div>
+
+        <script>
+            (() => {
+                const tbody = document.querySelector('#loaTable tbody');
+                const search = document.querySelector('#loaSearch');
+                const refresh = document.querySelector('#loaRefreshBtn');
+                const btnNew = document.querySelector('#loaNewBtn');
+
+                let rows = [];
+                let currentJob = null;
+
+                function fmtDate(ts){ if(!ts) return ''; const d=new Date(ts*1000); return d.toLocaleString(); }
+
+                function render(list){
+                    tbody.innerHTML = '';
+                    list.forEach(r => {
+                        const tr = document.createElement('tr');
+                        tr.innerHTML = `
+							<td class="monosmall">${r.job}</td>
+							<td>${r.client || ''}</td>
+							<td>${r.address || ''}</td>
+							<td>${fmtDate(r.mtime)}</td>
+							<td>${r.signed ? '<span class="badge badge-status status-signed">✓ signed</span>' : '<span class="text-muted">—</span>'}</td>
+							<td>${r.email || ''}</td>
+							<td>
+							<div class="btn-group">
+							<button class="btn rounded-0 btn-sm bg-brown-five brown-three loaEditBtn">Edit</button>
+							<button class="btn rounded-0 btn-sm bg-brown-three brown-five loaSendBtn">Email link</button>
+							<button class="btn rounded-0 btn-sm btn-outline-dark loaCopyBtn">Copy link</button>
+							<a class="btn rounded-0 btn-sm btn-outline-secondary" href="${r.public_url}" target="_blank" rel="noopener">Open</a>
+							<a class="btn rounded-0 btn-sm btn-outline-success" href="${r.pdf_url}" target="_blank">PDF</a>
+							<a class="btn rounded-0 btn-sm btn-outline-success" href="${r.html_url}" target="_blank">HTML</a>
+							</div>
+							</td>`;
+                        tr.querySelector('.loaEditBtn').addEventListener('click', () => openEdit(r.job));
+                        tr.querySelector('.loaSendBtn').addEventListener('click', () => openSend(r.job, r.email));
+                        tr.querySelector('.loaCopyBtn').addEventListener('click', () => navigator.clipboard.writeText(r.public_url));
+                        tbody.appendChild(tr);
+                    });
+                }
+
+                async function load(){
+                    const res = await fetch('?action=loa_list');
+                    const js = await res.json();
+                    if (!js.ok){ alert(js.error || 'Failed to load'); return; }
+                    rows = js.loas;
+                    applyFilter();
+                }
+
+                function applyFilter(){
+                    const q = (search.value||'').toLowerCase().trim();
+                    if (!q) return render(rows);
+                    const f = rows.filter(r =>
+                                          r.job.toLowerCase().includes(q) ||
+                                          (r.client||'').toLowerCase().includes(q) ||
+                                          (r.email||'').toLowerCase().includes(q) ||
+                                          (r.address||'').toLowerCase().includes(q)
+                                         );
+                    render(f);
+                }
+
+                async function openEdit(job){
+                    currentJob = job;
+                    const res = await fetch('?action=loa_read&job='+encodeURIComponent(job));
+                    const js = await res.json();
+                    if (!js.ok){ alert(js.error||'Failed'); return; }
+                    document.querySelector('#loaEditJob').textContent = job;
+                    document.querySelector('#loaEditContent').value = js.content;
+                    new bootstrap.Modal('#loaEditModal').show();
+                }
+                document.querySelector('#loaSaveBtn').addEventListener('click', async () => {
+                    const content = document.querySelector('#loaEditContent').value;
+                    const fd = new FormData();
+                    fd.append('action','loa_save');
+                    fd.append('job', currentJob);
+                    fd.append('content', content);
+                    fd.append('csrf', window.CSRF);
+                    const res = await fetch('?action=loa_save', { method:'POST', body: fd });
+                    const js = await res.json();
+                    if (js.ok) {
+                        bootstrap.Modal.getInstance(document.querySelector('#loaEditModal')).hide();
+                        load();
+                    } else alert(js.error || 'Save failed');
+                });
+
+                function openSend(job, prefill){
+                    currentJob = job;
+                    document.querySelector('#loaSendEmail').value = prefill || '';
+                    new bootstrap.Modal('#loaSendModal').show();
+                }
+                document.querySelector('#loaConfirmSendBtn').addEventListener('click', async () => {
+                    const email = document.querySelector('#loaSendEmail').value.trim();
+                    if (!email) { alert('Please enter an email'); return; }
+                    const fd = new FormData();
+                    fd.append('action','loa_send_link');
+                    fd.append('job', currentJob);
+                    fd.append('email', email);
+                    fd.append('csrf', window.CSRF);
+                    const res = await fetch('?action=loa_send_link', { method:'POST', body: fd });
+                    const js = await res.json();
+                    if (js.ok) {
+                        bootstrap.Modal.getInstance(document.querySelector('#loaSendModal')).hide();
+                        load();
+                    } else alert(js.error || 'Failed to send');
+                });
+
+                btnNew.addEventListener('click', ()=>{
+                    document.querySelector('#loaNewJob').value='';
+                    document.querySelector('#loaNewName').value='';
+                    document.querySelector('#loaNewEmail').value='';
+                    document.querySelector('#loaNewAddress').value='';
+                    document.querySelector('#loaNewOverwrite').checked=false;
+                    new bootstrap.Modal('#loaNewModal').show();
+                });
+                document.querySelector('#loaCreateBtn').addEventListener('click', async ()=>{
+                    const job = document.querySelector('#loaNewJob').value.trim();
+                    if (!job) { alert('Job # is required'); return; }
+                    const fd = new FormData();
+                    fd.append('action','loa_create');
+                    fd.append('job', job);
+                    fd.append('client_name', document.querySelector('#loaNewName').value.trim());
+                    fd.append('client_email', document.querySelector('#loaNewEmail').value.trim());
+                    fd.append('property_address', document.querySelector('#loaNewAddress').value.trim());
+                    fd.append('overwrite', document.querySelector('#loaNewOverwrite').checked ? 1 : 0);
+                    fd.append('csrf', window.CSRF);
+                    const res = await fetch('?action=loa_create', { method:'POST', body: fd });
+                    const js = await res.json();
+                    if (js.ok) {
+                        bootstrap.Modal.getInstance(document.querySelector('#loaNewModal')).hide();
+                        load();
+                    } else alert(js.error || 'Create failed');
+                });
+
+                search.addEventListener('input', applyFilter);
+                refresh.addEventListener('click', load);
+                load();
+            })();
+                             
+            const jobInput  = document.querySelector('#loaNewJob');
+			const nameInput = document.querySelector('#loaNewName');
+			const emailInput= document.querySelector('#loaNewEmail');
+			const addrInput = document.querySelector('#loaNewAddress');
+
+			function debounce(fn, ms){ let t; return (...args)=>{ clearTimeout(t); t=setTimeout(()=>fn(...args), ms); }; }
+
+			const lookupJob = debounce(async () => {
+			  const job = jobInput.value.trim();
+			  if (!job) return;
+			  try {
+				const res = await fetch('?action=loa_lookup&job=' + encodeURIComponent(job));
+				const js  = await res.json();
+				if (js.ok && js.found && js.data) {
+				  const d = js.data;
+				  if (d.client_name && !nameInput.value)  nameInput.value  = d.client_name;
+				  if (d.client_email && !emailInput.value) emailInput.value = d.client_email;
+				  if (d.property_address && !addrInput.value) addrInput.value = d.property_address;
+
+				  // quick visual hint
+				  [nameInput, emailInput, addrInput].forEach(el => {
+					if (el.value) { el.classList.add('is-valid'); setTimeout(()=>el.classList.remove('is-valid'), 1200); }
+				  });
+				}
+			  } catch(e) { /* ignore */ }
+			}, 300);
+
+			jobInput.addEventListener('input', lookupJob);
+			jobInput.addEventListener('blur', lookupJob);
+
+        </script>
+
+        <?php endif; ?>
+
+        <footer class="footer fixed-bottom">
+            <div class="container">
+                <div class="row text-center">
+                    <p>© 2025 - Modulos Design</p>
+                </div>
+            </div>
+        </footer>
+    </body>
+</html>

+ 12 - 0
contracts/contracts-admin/schema.mysql.sql

@@ -0,0 +1,12 @@
+-- MySQL schema for contract_status
+CREATE TABLE IF NOT EXISTS contract_status (
+  clientid        VARCHAR(64) PRIMARY KEY,
+  sent            TINYINT(1) DEFAULT 0,
+  sent_at         DATETIME NULL,
+  signed          TINYINT(1) DEFAULT 0,
+  signed_at       DATETIME NULL,
+  last_email_to   VARCHAR(255) NULL,
+  pdf_path        VARCHAR(255) NULL,
+  signer_name     VARCHAR(255) NULL,
+  signer_ip       VARCHAR(45) NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

+ 5 - 0
contracts/contracts/.htaccess

@@ -0,0 +1,5 @@
+Options -Indexes
+
+<FilesMatch "\.md$">
+    Require all denied
+</FilesMatch>

+ 0 - 0
dompdf/AUTHORS.md → contracts/dompdf/AUTHORS.md


+ 0 - 0
dompdf/LICENSE.LGPL → contracts/dompdf/LICENSE.LGPL


+ 0 - 0
dompdf/README.md → contracts/dompdf/README.md


+ 0 - 0
dompdf/VERSION → contracts/dompdf/VERSION


+ 0 - 0
dompdf/autoload.inc.php → contracts/dompdf/autoload.inc.php


+ 74 - 0
contracts/dorset_fill.php

@@ -0,0 +1,74 @@
+<?php
+declare(strict_types=1);
+use setasign\Fpdi\Fpdi;
+/**
+ * @return string|null Absolute path to the generated Dorset PDF, or null on failure
+ */
+
+function generate_dorset_application(string $job, array $vars, array $cfg, string $templatePath, string $outPath): ?string {
+    if (!is_file($templatePath)) return null;
+
+	// Property Details
+    $addr        = $vars['property']['address'] ?? '';
+    $title 		 = 'Title: ' . ($vars['property']['title'] ?? '') . '  PID: '  . ($vars['property']['pid'] ?? '');
+    // Owner
+	$ownerName   = $vars['client']['name'] ?? '';
+    $ownerEmail  = $vars['client']['email'] ?? '';
+    $ownerPhone  = $vars['client']['phone'] ?? '';
+    $ownerAddr   = $vars['client']['address'] ?? '';
+	$sigOwnerPng = __DIR__ . "/loa/{$job}/{$job}_signature.png";
+
+	// Applicant (your business)
+    $appName     = ($cfg['dev_name'] ?? '') . ' - ' . ($cfg['dev_company'] ?? '') ?? 'Benjamin Harris - Modulos Design';
+	$appEmail    = $cfg['dev_email']   ?? 'drafting@modulosdesign.com.au';
+	$appPhone    = $cfg['dev_phone']   ?? '0402 984 082';
+	$appAddress  = $cfg['dev_address'] ?? '34 Coplestone St, Scottsdale, Tas 7260';
+	$sigAppPng 	 = __DIR__ . "/applicant_signature.png";
+	
+	
+    $today       = date('d/m/Y');
+
+    $pdf = new Fpdi();
+    $pdf->AddPage();
+    $pdf->setSourceFile($templatePath);
+    $tpl = $pdf->importPage(1);
+    $pdf->useTemplate($tpl, 0, 0, 210); // A4 width in mm
+
+    $pdf->SetFont('Helvetica','',11);
+
+    // helpers
+    $T = function($x,$y,$t) use($pdf){ $pdf->SetXY($x,$y); $pdf->Cell(0,5,(string)$t,0,0); };
+	$MB = function($x,$y,$w,$t) use($pdf){ $pdf->SetFont('Helvetica','B',12); $pdf->SetXY($x,$y); $pdf->MultiCell($w,5,(string)$t,0,'L'); $pdf->SetFont('Helvetica','',11); };
+
+    $X = function($x,$y)    use($pdf){ $pdf->Text($x,$y,'X'); };
+
+    // --- Map to fields (adjust if your copy’s baseline differs) ---
+    $MB(58, 133, 122, $addr);          // Full Address 45,  95
+    $MB(58, 140, 122, $title);         // PID/Title 55, 103
+
+    // Applicant (your business)
+    $T(58, 159, $appName);
+    $T(58, 167, $appAddress);
+    $T(70, 174, $appPhone);
+    $T(128,174, $appEmail);
+	if (is_file($sigAppPng)) $pdf->Image($sigAppPng, 60, 179, 30); // Signature of Owner
+    $T(128, 182, $today);         // Date
+
+    // Owner Authorisation
+    $T(58, 203, $ownerName);
+    $T(58, 210, $ownerAddr);
+    $T(70, 217, $ownerPhone);
+    $T(128, 217, $ownerEmail);
+    if (is_file($sigOwnerPng)) $pdf->Image($sigOwnerPng, 60, 220, 50); // Signature of Owner
+    $T(128, 226, $today);
+
+    // Information Requested – default ticks (tweak to taste / make UI later)
+    $X(107,  95);   // Planning Permit & Associated Docs
+    $X(185,  95);   // Occupancy & Completion Certificates
+    $X(107,  102);   // Building Plans…
+    $X(185, 102);   // Building/Plumbing Notices & Orders
+    $X(107,  109);  // Plumbing Plans…
+
+    $pdf->Output('F', $outPath);
+    return is_file($outPath) ? $outPath : null;
+}

+ 1618 - 0
contracts/edit_application.php

@@ -0,0 +1,1618 @@
+<?php
+error_reporting(E_ALL);
+ini_set("display_errors", 1);
+
+date_default_timezone_set("Australia/Hobart");
+
+session_start();
+if ($_SERVER["REQUEST_METHOD"] === "POST") {
+    // allow the public "mark_signed" webhook to skip CSRF (it uses a shared secret)
+    $isMarkSigned = (($_POST['action'] ?? '') === 'mark_signed');
+    if (!$isMarkSigned) {
+        $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");
+
+// Load cfg array
+$cfg = @include __DIR__ . "/config.php";
+$cfg = is_array($cfg) ? $cfg : [];
+
+// HTTP Basic Auth — must be configured in .env
+$_au = $cfg['admin_user'] ?? '';
+$_ap = $cfg['admin_pass'] ?? '';
+if ($_au === '' || $_ap === '' ||
+    !isset($_SERVER['PHP_AUTH_USER']) ||
+    $_SERVER['PHP_AUTH_USER'] !== $_au ||
+    ($_SERVER['PHP_AUTH_PW'] ?? '') !== $_ap) {
+    header('WWW-Authenticate: Basic realm="Modulos Contracts Admin"');
+    header('HTTP/1.0 401 Unauthorized');
+    echo 'Authentication required.';
+    exit;
+}
+unset($_au, $_ap);
+
+// PHPMailer (same as contracts-admin)
+use PHPMailer\PHPMailer\PHPMailer;
+use PHPMailer\PHPMailer\SMTP;
+use PHPMailer\PHPMailer\Exception;
+require_once "../internal/phpmailer/src/Exception.php";
+require_once "../internal/phpmailer/src/PHPMailer.php";
+require_once "../internal/phpmailer/src/SMTP.php";
+
+// tiny JSON responder
+function json_response(array $payload, int $code = 200): void {
+    http_response_code($code);
+    header('Content-Type: application/json; charset=utf-8');
+    echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+    exit;
+}
+
+
+// Where to store correspondence PDFs (filesystem) and how to serve them (URL)
+if (!defined('CORR_UPLOAD_DIR')) define('CORR_UPLOAD_DIR', __DIR__ . '/uploads');
+if (!defined('CORR_UPLOAD_URL')) define('CORR_UPLOAD_URL', '/contracts/uploads');
+if (!is_dir(CORR_UPLOAD_DIR)) @mkdir(CORR_UPLOAD_DIR, 0775, true);
+
+// Where your .md contracts live (adjust if different)
+if (!defined('PROGRESS_BASE_URL')) {
+    define('PROGRESS_BASE_URL', 'https://modulosdesign.com.au/contracts');
+}
+if (!defined('CONTRACTS_DIR')) {
+    $contractsDir = realpath(__DIR__ . '/contracts');
+    if ($contractsDir === false) {
+        // fallback if the folder doesn't exist or path differs
+        $contractsDir = __DIR__ . '/../contracts';
+    }
+    define('CONTRACTS_DIR', $contractsDir);
+}
+
+$dsn = 'mysql:host=' . $cfg['db_host'] . ';dbname=' . $cfg['db_name'] . ';charset=utf8mb4';
+$options = [
+    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
+    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+];
+
+try {
+    $pdo = new PDO($dsn, $cfg['db_username'], $cfg['db_password'], $options);
+} catch (PDOException $e) {
+    exit('Database connection failed: ' . $e->getMessage());
+}
+
+$app_id_raw = $_GET['id'] ?? '';
+$app_id = preg_match('/^\d+$/', $app_id_raw) ? $app_id_raw : '0';
+
+// Load existing stages for this application
+$rows = $pdo->prepare("SELECT * FROM application_stages WHERE application_id = ? ORDER BY position ASC, id ASC");
+$rows->execute([$app_id]);
+$existing = [];
+foreach ($rows as $r) {
+    $pos = is_null($r['position']) ? null : (int)$r['position'];
+    if ($pos !== null) $existing[$pos] = $r;
+}
+
+$stmt = $pdo->prepare("SELECT * FROM applications WHERE id = ?");
+$stmt->execute([$app_id]);
+$app = $stmt->fetch();
+
+if (!$app) {
+    http_response_code(404);
+    exit("Application not found.");
+}
+
+// Pick the id that matches your contracts/{clientid}.md filename.
+$prefer = (string)($app['client_id'] ?? $app['clientid'] ?? $app_id);
+$prefer = preg_replace('/[^A-Za-z0-9_-]/', '', $prefer);
+
+$candidates = array_unique(array_filter([
+    $prefer,
+    preg_replace('/[^A-Za-z0-9_-]/', '', (string)($app['reference'] ?? '')),
+]));
+
+$progressUrl = '';
+$progressErr = '';
+$usedClientId = null;
+
+$clientEmail = trim((string)($app['client_email'] ?? ''));
+if (!filter_var($clientEmail, FILTER_VALIDATE_EMAIL)) { $clientEmail = ''; }
+
+foreach ($candidates as $cid) {
+    if ($cid === '') continue;
+    $md = contract_path($cid);
+    if (is_file($md)) {
+        $usedClientId = $cid;
+
+        $clientEmail = '';
+        if ($usedClientId) {
+            $meta = extract_front_matter_fields(contract_path($cid));
+            if (!empty($meta['client_email']) && filter_var($meta['client_email'], FILTER_VALIDATE_EMAIL)) {
+                $clientEmail = trim($meta['client_email']);
+            }
+        }
+        if (!$clientEmail) {
+            $clientEmail = trim((string)($app['client_email'] ?? ''));
+        }
+        if (!filter_var($clientEmail, FILTER_VALIDATE_EMAIL)) {
+            $clientEmail = '';
+        }
+
+        try {
+            $progressUrl = progress_public_url($cid, $app_id);
+        } catch (Throwable $e) {
+            $progressErr = $e->getMessage();
+        }
+        break;
+    }
+}
+
+if (!$progressUrl && !$progressErr) {
+    $tried = [];
+    foreach ($candidates as $cid) { $tried[] = contract_path($cid); }
+    $progressErr = "Contract file not found. Tried: " . implode(' | ', $tried);
+}
+
+// Predefine default stages (can be adjusted per application later)
+$defaultStages = [
+    'Submission to Council',
+    'Council Acknowledgement',
+    'Fees Paid',
+    'Confirmed Valid (42 Days Start)',
+    'Public Advertisement Start',
+    'Public Advertisement End',
+    'Council Decision Due'
+];
+
+// --- Create correspondence entry ---
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'add_correspondence') {
+    $tz = new DateTimeZone('Australia/Hobart');
+
+    $typeAllow      = ['incoming','outgoing','note'];
+    $channelAllow   = ['email','phone','meeting','other'];
+    $visibilityAllow= ['client','internal'];
+
+    $type       = in_array($_POST['type'] ?? 'note', $typeAllow, true) ? $_POST['type'] : 'note';
+    $channel    = in_array($_POST['channel'] ?? 'other', $channelAllow, true) ? $_POST['channel'] : 'other';
+    $visibility = in_array($_POST['visibility'] ?? 'client', $visibilityAllow, true) ? $_POST['visibility'] : 'client';
+    $subject    = trim($_POST['subject'] ?? '') ?: null;
+    $author     = trim($_POST['author'] ?? '') ?: null;
+    $pin        = isset($_POST['pin']) ? 1 : 0;
+
+    $bodyRaw    = trim($_POST['body'] ?? '');
+    if ($bodyRaw === '') { $bodyRaw = '(no content)'; }
+
+    // event_at: prefer user input, else "now"
+    $eventAtRaw = trim($_POST['event_at'] ?? '');
+    try {
+        $eventAt = $eventAtRaw ? new DateTime($eventAtRaw, $tz) : new DateTime('now', $tz);
+    } catch (Exception $e) {
+        $eventAt = new DateTime('now', $tz);
+    }
+
+    $stmt = $pdo->prepare("
+        INSERT INTO application_correspondence
+        (application_id, event_at, type, channel, subject, body, author, visibility, pin)
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+    ");
+    $stmt->execute([
+        $app_id,
+        $eventAt->format('Y-m-d H:i:s'),
+        $type,
+        $channel,
+        $subject,
+        $bodyRaw,
+        $author,
+        $visibility,
+        $pin
+    ]);
+
+    $corrId = (int)$pdo->lastInsertId();
+
+    if (!empty($_FILES['attachments']) && is_array($_FILES['attachments']['name'])) {
+        $finfo   = new finfo(FILEINFO_MIME_TYPE);
+        $allowed = ['application/pdf' => 'pdf'];
+        $baseDir = rtrim(CORR_UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $app_id . DIRECTORY_SEPARATOR . $corrId;
+        if (!is_dir($baseDir)) @mkdir($baseDir, 0775, true);
+
+        $ins = $pdo->prepare("
+        INSERT INTO application_correspondence_files
+        (application_id, correspondence_id, original_name, file_url, file_path, mime, size)
+        VALUES (?, ?, ?, ?, ?, ?, ?)
+    ");
+
+        $names = $_FILES['attachments']['name'];
+        $tmps  = $_FILES['attachments']['tmp_name'];
+        $errs  = $_FILES['attachments']['error'];
+        $sizes = $_FILES['attachments']['size'];
+
+        for ($i = 0; $i < count($names); $i++) {
+            if ($errs[$i] !== UPLOAD_ERR_OK || $tmps[$i] === '') continue;
+
+            $mime = $finfo->file($tmps[$i]) ?: '';
+            if (!isset($allowed[$mime])) continue; // only PDFs
+
+            // Safe filename
+            $orig = preg_replace('/[^\w\.\- ]+/', '_', (string)$names[$i]);
+            $slug = substr(sha1($orig . microtime(true)), 0, 10) . '.pdf';
+            $dest = $baseDir . DIRECTORY_SEPARATOR . $slug;
+
+            if (move_uploaded_file($tmps[$i], $dest)) {
+                $url = rtrim(CORR_UPLOAD_URL, '/')
+                    . '/' . rawurlencode((string)$app_id)
+                    . '/' . rawurlencode((string)$corrId)
+                    . '/' . rawurlencode($slug);
+
+                $ins->execute([
+                    $app_id, $corrId, $orig, $url, $dest, $mime, (int)$sizes[$i]
+                ]);
+            }
+        }
+    }
+    // (keep the attachments block as-is above)
+
+    $shouldNotify = !empty($_POST['notify_client']) && ($visibility === 'client');
+
+    error_log("notify? ".($shouldNotify?'yes':'no')." to='$clientEmail' url='$progressUrl'");
+    if ($shouldNotify) {
+        /* $prefer = (string)($app['client_id'] ?? $app['clientid'] ?? $app_id);
+        $prefer = preg_replace('/[^A-Za-z0-9_-]/', '', $prefer);
+        $candidates = array_unique(array_filter([$prefer, preg_replace('/[^A-Za-z0-9_-]/', '', (string)($app['reference'] ?? ''))]));
+        $clientid = null;
+        foreach ($candidates as $cid) { if ($cid && is_file(contract_path($cid))) { $clientid = $cid; break; } }
+        $progressUrl = $clientid ? progress_public_url($clientid, $app_id) : ''; */
+
+        $to = $clientEmail;
+        if ($progressUrl && filter_var($to, FILTER_VALIDATE_EMAIL)) {
+            $corrIdForMail = $corrId; // we just inserted it
+            $qr = $pdo->prepare("SELECT original_name, file_url FROM application_correspondence_files WHERE correspondence_id = ? ORDER BY id ASC");
+            $qr->execute([$corrIdForMail]);
+            $attRows = $qr->fetchAll(PDO::FETCH_ASSOC) ?: [];
+            $atts = array_map(fn($a)=>['name'=>$a['original_name'], 'url'=>$a['file_url']], $attRows);
+
+            $ok = send_progress_update_email(
+                $to,
+                $progressUrl,
+                ($app['reference'] ?: $app_id),
+                $cfg,
+                [
+                    'when'        => dt_human($eventAt->format('Y-m-d H:i:s')),
+                    'type'        => $type,
+                    'channel'     => $channel,
+                    'subject'     => $subject ?: ucfirst($type),
+                    'author'      => $author ?: '',
+                    'body'        => $bodyRaw,
+                    'attachments' => $atts,
+                ]
+            );
+            // optional debug
+            if (!$ok) error_log("update_correspondence: send_progress_update_email returned false (to=$to)");
+        } else {
+            // optional debug
+            error_log("update_correspondence: not sending (to='$to', url='$progressUrl')");
+        }
+    }
+
+    // Redirect to avoid resubmission and jump to the timeline section
+    header("Location: ".$_SERVER['REQUEST_URI']."#correspondence");
+    exit;
+}
+// --- Update correspondence entry ---
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'update_correspondence') {
+    $id = (int)($_POST['id'] ?? 0);
+    if ($id > 0) {
+        $tz = new DateTimeZone('Australia/Hobart');
+
+        $typeAllow      = ['incoming','outgoing','note'];
+        $channelAllow   = ['email','phone','meeting','other'];
+        $visibilityAllow= ['client','internal'];
+
+        $type       = in_array($_POST['type'] ?? 'note', $typeAllow, true) ? $_POST['type'] : 'note';
+        $channel    = in_array($_POST['channel'] ?? 'other', $channelAllow, true) ? $_POST['channel'] : 'other';
+        $visibility = in_array($_POST['visibility'] ?? 'client', $visibilityAllow, true) ? $_POST['visibility'] : 'client';
+
+        $subject    = trim($_POST['subject'] ?? '') ?: null;
+        $author     = trim($_POST['author'] ?? '') ?: null;
+        $pin        = isset($_POST['pin']) ? 1 : 0;
+        $bodyRaw    = trim($_POST['body'] ?? '') ?: '(no content)';
+
+        $eventAtRaw = trim($_POST['event_at'] ?? '');
+        try { $eventAt = $eventAtRaw ? new DateTime($eventAtRaw, $tz) : new DateTime('now', $tz); }
+        catch (Exception $e) { $eventAt = new DateTime('now', $tz); }
+
+        $stmt = $pdo->prepare("
+            UPDATE application_correspondence
+               SET event_at=?, type=?, channel=?, subject=?, body=?, author=?, visibility=?, pin=?, updated_at=NOW()
+             WHERE id=? AND application_id=?
+        ");
+        $stmt->execute([
+            $eventAt->format('Y-m-d H:i:s'),
+            $type, $channel, $subject, $bodyRaw, $author, $visibility, $pin,
+            $id, $app_id
+        ]);
+
+        // accept newly added PDFs on edit as well
+        if (!empty($_FILES['attachments']) && is_array($_FILES['attachments']['name'])) {
+            $corrId = $id; // we're editing this row
+            $finfo   = new finfo(FILEINFO_MIME_TYPE);
+            $allowed = ['application/pdf' => 'pdf'];
+            $baseDir = rtrim(CORR_UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $app_id . DIRECTORY_SEPARATOR . $corrId;
+            if (!is_dir($baseDir)) @mkdir($baseDir, 0775, true);
+
+            $ins = $pdo->prepare("
+        INSERT INTO application_correspondence_files
+        (application_id, correspondence_id, original_name, file_url, file_path, mime, size)
+        VALUES (?, ?, ?, ?, ?, ?, ?)
+    ");
+
+            $names = $_FILES['attachments']['name'];
+            $tmps  = $_FILES['attachments']['tmp_name'];
+            $errs  = $_FILES['attachments']['error'];
+            $sizes = $_FILES['attachments']['size'];
+
+            for ($i = 0; $i < count($names); $i++) {
+                if ($errs[$i] !== UPLOAD_ERR_OK || $tmps[$i] === '') continue;
+
+                $mime = $finfo->file($tmps[$i]) ?: '';
+                if (!isset($allowed[$mime])) continue; // PDF only
+
+                $orig = preg_replace('/[^\w\.\- ]+/', '_', (string)$names[$i]);
+                $slug = substr(sha1($orig . microtime(true)), 0, 10) . '.pdf';
+                $dest = $baseDir . DIRECTORY_SEPARATOR . $slug;
+
+                if (move_uploaded_file($tmps[$i], $dest)) {
+                    $url = rtrim(CORR_UPLOAD_URL, '/')
+                        . '/' . rawurlencode((string)$app_id)
+                        . '/' . rawurlencode((string)$corrId)
+                        . '/' . rawurlencode($slug);
+
+                    $ins->execute([$app_id, $corrId, $orig, $url, $dest, $mime, (int)$sizes[$i]]);
+                }
+            }
+        }
+
+        // (keep the attachments block as-is above)
+        $shouldNotify = !empty($_POST['notify_client']) && ($visibility === 'client');
+
+        if ($shouldNotify) {
+            $prefer = (string)($app['client_id'] ?? $app['clientid'] ?? $app_id);
+            $prefer = preg_replace('/[^A-Za-z0-9_-]/', '', $prefer);
+            $candidates = array_unique(array_filter([$prefer, preg_replace('/[^A-Za-z0-9_-]/', '', (string)($app['reference'] ?? ''))]));
+            $clientid = null;
+            foreach ($candidates as $cid) { if ($cid && is_file(contract_path($cid))) { $clientid = $cid; break; } }
+            $progressUrl = $clientid ? progress_public_url($clientid, $app_id) : '';
+
+            $to = $clientEmail;
+            if ($progressUrl && filter_var($to, FILTER_VALIDATE_EMAIL)) {
+                $corrIdForMail = $id; // we're editing this one
+                $qr = $pdo->prepare("SELECT original_name, file_url FROM application_correspondence_files WHERE correspondence_id = ? ORDER BY id ASC");
+                $qr->execute([$corrIdForMail]);
+                $attRows = $qr->fetchAll(PDO::FETCH_ASSOC) ?: [];
+                $atts = array_map(fn($a)=>['name'=>$a['original_name'], 'url'=>$a['file_url']], $attRows);
+
+                send_progress_update_email(
+                    $to,
+                    $progressUrl,
+                    ($app['reference'] ?: $app_id),
+                    $cfg,
+                    [
+                        'when'        => dt_human($eventAt->format('Y-m-d H:i:s')),
+                        'type'        => $type,
+                        'channel'     => $channel,
+                        'subject'     => $subject ?: ucfirst($type),
+                        'author'      => $author ?: '',
+                        'body'        => $bodyRaw,
+                        'attachments' => $atts,
+                    ]
+                );
+            }
+        }
+    }
+
+    header("Location: ".$_SERVER['REQUEST_URI']."#correspondence");
+    exit;
+}
+
+// ---- AJAX: send progress link ----
+if (($_GET['action'] ?? $_POST['action'] ?? '') === 'send_progress_link') {
+    // CSRF already validated at top
+
+    $email = trim($_POST['email'] ?? '');
+    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+        json_response(['ok' => false, 'error' => 'Invalid email'], 400);
+    }
+
+    // Build or reuse the progress URL
+    $prefer = (string)($app['client_id'] ?? $app['clientid'] ?? $app_id);
+    $prefer = preg_replace('/[^A-Za-z0-9_-]/', '', $prefer);
+
+    $candidates = array_unique(array_filter([
+        $prefer,
+        preg_replace('/[^A-Za-z0-9_-]/', '', (string)($app['reference'] ?? '')),
+    ]));
+
+    $clientid = null;
+    foreach ($candidates as $cid) {
+        if ($cid && is_file(contract_path($cid))) { $clientid = $cid; break; }
+    }
+    if (!$clientid) {
+        $pathsTried = implode(' | ', array_map(fn($c)=>contract_path($c), $candidates));
+        json_response(['ok' => false, 'error' => "Contract file not found. Tried: {$pathsTried}"], 404);
+    }
+
+    try {
+        $url = progress_public_url($clientid, $app_id);
+    } catch (Throwable $e) {
+        json_response(['ok' => false, 'error' => $e->getMessage()], 500);
+    }
+
+    $jobRefOrId = $app['reference'] ?: $app_id;
+    $sent = send_progress_email($email, $url, $jobRefOrId, $cfg);
+
+    if ($sent) {
+        json_response(['ok' => true, 'url' => $url]);
+    } else {
+        json_response(['ok' => false, 'error' => 'Failed to send email'], 500);
+    }
+}
+
+
+// Helpers
+function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
+function excerpt($s, $n=180){
+    $s = trim(preg_replace('/\s+/', ' ', (string)$s));
+    return mb_strlen($s) > $n ? mb_substr($s,0,$n-1).'…' : $s;
+}
+function dt_local($mysql, $tz='Australia/Hobart'){
+    if (!$mysql) return '';
+    $d = new DateTime($mysql, new DateTimeZone($tz));
+    return $d->format('Y-m-d\TH:i');
+}
+function dt_human($mysql, $tz='Australia/Hobart'){
+    if (!$mysql) return '';
+    $d = new DateTime($mysql, new DateTimeZone($tz));
+    return $d->format('D d M Y, h:ia');
+}
+
+// Build contracts/{clientid}.md path
+function contract_path(string $clientid): string {
+    $id = preg_replace('/[^A-Za-z0-9_-]/', '', $clientid);
+    return rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . $id . '.md';
+}
+
+// Tiny front-matter puller (same idea as contracts-admin)
+function extract_front_matter_fields(string $file): array {
+    $out = [];
+    $txt = @file_get_contents($file);
+    if (!$txt) return $out;
+    if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) return $out;
+    $fm = $m[1];
+
+    // admin.secret inside an admin: block, or a flat admin_secret
+    if (preg_match('/^\s*admin\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block)) {
+        $adminBlock = $block[1];
+        if (preg_match('/^\s*secret\s*:\s*["\']?([^"\']+)["\']?/mi', $adminBlock, $mm)) {
+            $out['admin_secret'] = trim($mm[1]);
+        }
+    }
+    if (empty($out['admin_secret']) && preg_match('/^\s*admin_secret\s*:\s*["\']?([^"\']+)["\']?/mi', $fm, $mm)) {
+        $out['admin_secret'] = trim($mm[1]);
+    }
+
+    // client.email inside a client: block, or flat client_email / email
+    if (preg_match('/^\s*client\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block)) {
+        $clientBlock = $block[1];
+        if (preg_match('/^\s*email\s*:\s*["\']?([^"\']+)["\']?/mi', $clientBlock, $mm)) {
+            $out['client_email'] = trim($mm[1]);
+        }
+    }
+    if (empty($out['client_email'])) {
+        if (preg_match('/^\s*client_email\s*:\s*["\']?([^"\']+)["\']?/mi', $fm, $mm)) {
+            $out['client_email'] = trim($mm[1]);
+        } elseif (preg_match('/^\s*email\s*:\s*["\']?([^"\']+)["\']?/mi', $fm, $mm)) {
+            $out['client_email'] = trim($mm[1]);
+        }
+    }
+
+    return $out;
+}
+
+// Build the signed public progress URL (namespaced HMAC)
+function progress_public_url(string $clientid, $appId): string {
+    $meta   = extract_front_matter_fields(contract_path($clientid));
+    $secret = $meta['admin_secret'] ?? '';
+    if ($secret === '') {
+        throw new RuntimeException("Missing admin secret for client ID: {$clientid}");
+    }
+    $token = hash_hmac('sha256', 'progress|' . (string)$appId, $secret);
+    $base  = rtrim(PROGRESS_BASE_URL, '/');
+    return $base . '/progress.php?id=' . rawurlencode((string)$appId)
+        . '&clientid=' . rawurlencode($clientid)
+        . '&token=' . rawurlencode($token);
+}
+
+
+foreach ($candidates as $cid) {
+    if ($cid === '') continue;
+    $md = contract_path($cid);
+    if (is_file($md)) {
+        $usedClientId = $cid;
+
+        $clientEmail = '';
+        if ($usedClientId) {
+            $meta = extract_front_matter_fields(contract_path($cid));
+            if (!empty($meta['client_email']) && filter_var($meta['client_email'], FILTER_VALIDATE_EMAIL)) {
+                $clientEmail = trim($meta['client_email']);
+            }
+        }
+        if (!$clientEmail) {
+            $clientEmail = trim((string)($app['client_email'] ?? ''));
+        }
+        if (!filter_var($clientEmail, FILTER_VALIDATE_EMAIL)) {
+            $clientEmail = '';
+        }
+
+        try {
+            $progressUrl = progress_public_url($cid, $app_id);
+        } catch (Throwable $e) {
+            $progressErr = $e->getMessage();
+        }
+        break;
+    }
+}
+
+if (!$progressUrl && !$progressErr) {
+    // Nothing matched; explain what we tried
+    $tried = [];
+    foreach ($candidates as $cid) { $tried[] = contract_path($cid); }
+    $progressErr = "Contract file not found. Tried: " . implode(' | ', $tried);
+}
+
+
+$rows = $pdo->prepare("
+  SELECT id, event_at, type, channel, subject, body, author, visibility, pin, created_at
+    FROM application_correspondence
+   WHERE application_id = ?
+ORDER BY pin DESC, event_at DESC, id DESC
+   LIMIT 30
+");
+$rows->execute([$app_id]);
+$correspondence = $rows->fetchAll(PDO::FETCH_ASSOC);
+
+// -------------------------------------------- EMAIL HELPERS -----------------------------------------
+// embed a PNG data URL as CID (same helper you already have)
+function email_logo_png_cid(PHPMailer $mail, string $dataUrl, string $alt = 'Modulos Design', int $width = 200): string {
+    if ($dataUrl === '') return '';
+    $prefix = 'data:image/png;base64,';
+    if (stripos($dataUrl, $prefix) !== 0) return '';
+    $bin = base64_decode(substr($dataUrl, strlen($prefix)), true);
+    if ($bin === false) return '';
+    $cid = 'logo_' . substr(sha1($bin), 0, 12) . '@modulos';
+    $mail->addStringEmbeddedImage($bin, $cid, 'logo.png', 'base64', 'image/png');
+    return '<img src="cid:' . $cid . '" alt="' . htmlspecialchars($alt, ENT_QUOTES, 'UTF-8') . '" width="' . (int)$width . '" style="display:block;border:0;outline:0;text-decoration:none;height:auto;">';
+}
+
+// email HTML body — same structure as contracts-admin but wording for “Progress”
+function build_progress_email_html_template(string $logoHtml, string $safeJob, string $firstNameSafe, string $safeUrl, string $safeCompany, string $safeSignature): string {
+    return <<<HTML
+<div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:0;line-height:0;">
+  Your application progress.
+</div>
+
+<div style="background:#f6f7fb;padding:24px;font-size:14px;line-height:1.6;">
+  <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" width="600" bgcolor="#FFFFFF" style="width:600px;max-width:100%;background-color:#FFFFFF;border-radius:8px;overflow:hidden; font-size:14px;line-height:1.6;font-family:Arial,Helvetica,sans-serif;background-image:linear-gradient(#FFFFFF,#FFFFFF);">
+    <tr>
+      <td bgcolor="#D9CCC1" style="padding:20px 24px;background-color:#D9CCC1;color:#ffffff;">
+        <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
+          <tr>
+            <td>{$logoHtml}</td>
+            <td align="right" style="font-weight:700;">Council Application #{$safeJob}</td>
+          </tr>
+        </table>
+      </td>
+    </tr>
+
+    <tr>
+      <td bgcolor="#f8f9fa"
+          style="padding:28px 24px 8px;color:#635A4A;background-color:#f8f9fa;background-image:linear-gradient(#f8f9fa,#f8f9fa);">
+        <div>Hello {$firstNameSafe},</div>
+      </td>
+    </tr>
+
+    <tr>
+      <td align="center" bgcolor="#f8f9fa" style="padding:20px 24px 8px;background-color:#f8f9fa;background-image:linear-gradient(#f8f9fa,#f8f9fa);">
+        <!--[if mso]>
+        <v:rect xmlns:v="urn:schemas-microsoft-com:vml" href="{$safeUrl}" style="height:42px;v-text-anchor:middle;width:260px;" stroked="f" fillcolor="#635A4A">
+          <w:anchorlock/>
+          <center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;">View Progress</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;" target="_blank" rel="noopener">View Progress</a>
+        <!--<![endif]-->
+      </td>
+    </tr>
+
+    <tr>
+      <td bgcolor="#f8f9fa" style="padding:8px 24px 24px;color:#635A4A;background-color:#f8f9fa;background-image:linear-gradient(#f8f9fa,#f8f9fa);">
+        <div style="margin-top:18px;">
+          Thank you.<br><br>
+          <b>Kind Regards,</b><br><br>{$safeSignature}<br>Benjamin Harris<br>{$safeCompany}<br>0402 984 082 &nbsp;|&nbsp; drafting@modulosdesign.com.au
+        </div>
+      </td>
+    </tr>
+
+    <tr>
+      <td bgcolor="#28261E" style="padding:12px 24px;background-color:#28261E;color:#D9CCC1;">
+        This is an automated message. Please reply to this email if you have any questions.
+      </td>
+    </tr>
+  </table>
+</div>
+HTML;
+}
+
+
+// send mail (PHPMailer, same SMTP pattern as contracts-admin)
+function send_progress_email(string $email, string $progressUrl, string $jobRefOrId, array $cfg): bool {
+    $mail = new PHPMailer(true);
+
+    $safeCompany = htmlspecialchars($cfg['company_name'] ?? 'Modulos Design', ENT_QUOTES, 'UTF-8');
+    $safeUrl     = htmlspecialchars($progressUrl, ENT_QUOTES, 'UTF-8');
+    $safeJob     = htmlspecialchars((string)$jobRefOrId, ENT_QUOTES, 'UTF-8');
+
+    $firstName = 'there';
+    if (!empty($cfg['client_name'])) {
+        $firstName = htmlspecialchars($cfg['client_name'], ENT_QUOTES, 'UTF-8');
+    }
+
+    $logoHtml     = email_logo_png_cid($mail, $cfg['dark_logo'] ?? '', $safeCompany, 200);
+    $signatureImg = email_logo_png_cid($mail, $cfg['dev_signature'] ?? '', $safeCompany, 100);
+
+    $html = build_progress_email_html_template($logoHtml, $safeJob, $firstName, $safeUrl, $safeCompany, $signatureImg);
+    $alt  = "Hello {$firstName},\n\nYour application progress page is ready:\n{$progressUrl}\n\nKind Regards,\n{$safeCompany}";
+
+    // <- set these BEFORE the try so the fallback can use them
+    $fromAddress = $cfg['smtp_from']      ?? 'no-reply@modulosdesign.com.au';
+    $fromName    = $cfg['smtp_from_name'] ?? 'Modulos Design';
+
+    try {
+        $mail->CharSet  = 'UTF-8';
+        $mail->Encoding = 'base64';
+
+        $smtpHost = $cfg['smtp_host'] ?? '';
+        if ($smtpHost !== '') {
+            $mail->isSMTP();
+            $mail->SMTPDebug = SMTP::DEBUG_OFF;
+            $mail->Host       = $smtpHost;
+            $mail->SMTPAuth   = true;
+            $mail->Username   = $cfg['smtp_username'] ?? '';
+            $mail->Password   = $cfg['smtp_password'] ?? '';
+            $secure = strtolower((string)($cfg['smtp_secure'] ?? 'ssl'));
+            if ($secure === 'ssl') {
+                $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
+                $mail->Port       = (int)($cfg['smtp_port'] ?? 465);
+            } else {
+                $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
+                $mail->Port       = (int)($cfg['smtp_port'] ?? 587);
+            }
+        }
+
+        $mail->setFrom($fromAddress, $fromName);
+        if (!empty($cfg['smtp_reply_to'])) $mail->addReplyTo($cfg['smtp_reply_to']);
+        $mail->addAddress($email);
+
+        if (!empty($cfg['smtp_bcc'])) {
+            foreach (explode(',', $cfg['smtp_bcc']) as $bcc) {
+                $bcc = trim($bcc);
+                if ($bcc !== '') $mail->addBCC($bcc);
+            }
+        }
+        $mail->addBCC('drafting@modulosdesign.com.au');
+
+        $mail->isHTML(true);
+        $mail->Subject = "Your Application Progress Dashboard";
+        $mail->Body    = $html;
+        $mail->AltBody = $alt;
+
+        $mail->send();
+        return true;
+    } catch (Throwable $e) {
+        error_log("send_progress_email failed for {$email}: ".$e->getMessage());
+        // Fallback to PHP mail()
+        $headers  = "MIME-Version: 1.0\r\n";
+        $headers .= "Content-type: text/html; charset=UTF-8\r\n";
+        $headers .= "From: {$fromName} <{$fromAddress}>\r\n";
+        return mail($email, "Your Application Progress Page", $html, $headers);
+    }
+}
+
+function send_progress_update_email(
+    string $email,
+    string $progressUrl,
+    string $jobRefOrId,
+    array $cfg,
+    array $update // ['when'=>..., 'type'=>..., 'channel'=>..., 'subject'=>..., 'author'=>..., 'body'=>..., 'attachments'=>[['name'=>..., 'url'=>...], ...]]
+): bool {
+    $mail = new PHPMailer(true);
+
+    $safeCompany = htmlspecialchars($cfg['company_name'] ?? 'Modulos Design', ENT_QUOTES, 'UTF-8');
+    $safeUrl     = htmlspecialchars($progressUrl, ENT_QUOTES, 'UTF-8');
+    $safeJob     = htmlspecialchars((string)$jobRefOrId, ENT_QUOTES, 'UTF-8');
+
+    $firstName = htmlspecialchars($cfg['client_name'] ?? 'there', ENT_QUOTES, 'UTF-8');
+
+    $logoHtml     = email_logo_png_cid($mail, $cfg['dark_logo'] ?? '', $safeCompany, 200);
+    $signatureImg = email_logo_png_cid($mail, $cfg['dev_signature'] ?? '', $safeCompany, 100);
+
+    $when   = htmlspecialchars($update['when'] ?? '', ENT_QUOTES, 'UTF-8');
+    $subj   = htmlspecialchars($update['subject'] ?? ucfirst($update['type'] ?? 'Update'), ENT_QUOTES, 'UTF-8');
+    $author = htmlspecialchars($update['author'] ?? '', ENT_QUOTES, 'UTF-8');
+    $body   = nl2br(htmlspecialchars(mb_strimwidth((string)($update['body'] ?? ''), 0, 600, '…'), ENT_QUOTES, 'UTF-8'));
+
+    // attachments list (as links)
+    $attHtml = '';
+    if (!empty($update['attachments'])) {
+        $attHtml .= '<ul style="margin:8px 0 0 16px;padding:0;">';
+        foreach ($update['attachments'] as $a) {
+            $attHtml .= '<li><a href="https://modulosdesign.com.au' . htmlspecialchars($a['url'], ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener">'
+                . htmlspecialchars($a['name'], ENT_QUOTES, 'UTF-8')
+                . '</a></li>';
+        }
+        $attHtml .= '</ul>';
+    }
+
+    $html = build_progress_email_html_template(
+        $logoHtml,
+        $safeJob,
+        $firstName,
+        $safeUrl,
+        $safeCompany,
+        $signatureImg
+    );
+
+    // inject an “update” block right above the CTA (cheap & cheerful; keeps your template)
+    $updateBlock =
+        '<tr><td bgcolor="#f8f9fa" style="padding:14px 24px;color:#635A4A;">'
+        . '<div style="font-weight:700;margin-bottom:6px;">New update posted</div>'
+        . '<div><b>When:</b> ' . $when . '</div>'
+        . '<div><b>Subject:</b> ' . $subj . '</div>'
+        . ($author ? '<div><b>Author:</b> ' . $author . '</div>' : '')
+        . '<div style="margin-top:10px;border-left:3px solid #D9CCC1;padding-left:10px;">' . $body . '</div>'
+        . ($attHtml ? '<div style="margin-top:10px;"><b>Attachments:</b>' . $attHtml . '</div>' : '')
+        . '</td></tr>';
+
+    $html = str_replace(
+        '<tr>'."\n".'      <td align="center"',
+        $updateBlock . "\n".'<tr>'."\n".'      <td align="center"',
+        $html
+    );
+
+    $alt = "New update on your application #{$jobRefOrId}\n"
+        . ($when ? "When: {$when}\n" : '')
+        . "Subject: {$subj}\n\n"
+        . strip_tags((string)$update['body']) . "\n\n"
+        . "View your progress: {$progressUrl}";
+
+    $fromAddress = $cfg['smtp_from']      ?? 'no-reply@modulosdesign.com.au';
+    $fromName    = $cfg['smtp_from_name'] ?? 'Modulos Design';
+
+    try {
+        $mail->CharSet  = 'UTF-8';
+        $mail->Encoding = 'base64';
+
+        if (!empty($cfg['smtp_host'])) {
+            $mail->isSMTP();
+            $mail->SMTPDebug = SMTP::DEBUG_OFF;
+            $mail->Host       = $cfg['smtp_host'];
+            $mail->SMTPAuth   = true;
+            $mail->Username   = $cfg['smtp_username'] ?? '';
+            $mail->Password   = $cfg['smtp_password'] ?? '';
+            $secure = strtolower((string)($cfg['smtp_secure'] ?? 'ssl'));
+            if ($secure === 'ssl') {
+                $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
+                $mail->Port       = (int)($cfg['smtp_port'] ?? 465);
+            } else {
+                $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
+                $mail->Port       = (int)($cfg['smtp_port'] ?? 587);
+            }
+        }
+
+        $mail->setFrom($fromAddress, $fromName);
+        if (!empty($cfg['smtp_reply_to'])) $mail->addReplyTo($cfg['smtp_reply_to']);
+        $mail->addAddress($email);
+        if (!empty($cfg['smtp_bcc'])) {
+            foreach (explode(',', $cfg['smtp_bcc']) as $bcc) {
+                $bcc = trim($bcc); if ($bcc !== '') $mail->addBCC($bcc);
+            }
+        }
+        $mail->addBCC('drafting@modulosdesign.com.au');
+
+        $mail->isHTML(true);
+        $mail->Subject = "Update posted – Application #{$jobRefOrId}";
+        $mail->Body    = $html;
+        $mail->AltBody = $alt;
+
+        $mail->send();
+        return true;
+    } catch (Throwable $e) {
+        error_log("send_progress_update_email failed: ".$e->getMessage());
+        return false;
+    }
+}
+
+
+// Load attachments for the visible correspondence list
+$attByCorr = [];
+if (!empty($correspondence)) {
+    $ids = array_column($correspondence, 'id');
+    $ph  = implode(',', array_fill(0, count($ids), '?'));
+    $qr  = $pdo->prepare("
+        SELECT id, correspondence_id, original_name, file_url
+        FROM application_correspondence_files
+        WHERE correspondence_id IN ($ph)
+        ORDER BY id ASC
+    ");
+    $qr->execute($ids);
+    foreach ($qr->fetchAll(PDO::FETCH_ASSOC) as $a) {
+        $attByCorr[(int)$a['correspondence_id']][] = $a;
+    }
+}
+
+
+?>
+<!doctype html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <title>Edit Timeline – <?= htmlspecialchars($app['reference']) ?></title>
+
+        <link rel="shortcut icon" href="../internal/images/blueprint.ico" type="image/x-icon">
+
+        <meta name="robots" content="noindex">
+        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
+        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js" integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q" crossorigin="anonymous"></script>
+        <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>
+            .card-sm .card-body { padding: .75rem .9rem; }
+            .card-sm .bi { font-size: 1rem; }
+            .dropzone-sm {
+                border: 1px dashed #bbb;
+                background: #fafafa;
+                border-radius: .25rem;
+                padding: .5rem .75rem;
+                font-size: .875rem;
+                cursor: pointer;
+                user-select: none;
+            }
+            .dropzone-sm.dragover { background: #f1f1f1; border-color: #666; }
+            .dz-list > div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+        </style>
+    </head>
+
+    <body class="bg-light">
+        <nav class="navbar bg-brown-dark brown-light border-bottom border-body d-print-none">
+            <div class="container-fluid">
+                <span class="navbar-brand brown-light">
+                    <img src="../internal/images/blueprint-logo-light.png" alt="Logo" width="30" height="24" class="d-inline-block align-text-top">
+                    Modulos Design
+                </span>
+                <div class="ms-auto d-flex gap-2">
+                    <a href="../internal/dashboard.php" class="btn btn-sm btn-outline-light"><i class="bi bi-grid-fill"></i> Dashboard</a>
+                    <a href="../internal/client-brief.php?drg=<?= $app_id ?>" class="btn btn-sm btn-outline-light"><i class="bi bi-person-fill"></i> Client Brief</a>
+                    <a href="admin_dashboard.php" class="btn btn-sm btn-outline-light"><i class="bi bi-list-ul"></i> All Applications</a>
+                </div>
+            </div>
+        </nav>
+        <div class="container my-5">
+            <h2>Edit Timeline for Job: <?= htmlspecialchars($app['reference']) ?></h2>
+            <form method="POST" action="save_stages.php" enctype="multipart/form-data">
+                <input type="hidden" name="application_id" value="<?= $app_id ?>">
+                <div class="mb-3 row">
+                    <label class="col-sm-3 col-form-label">Submission Date</label>
+                    <div class="col-sm-4">
+                        <input type="date" class="form-control form-control-sm rounded-0" name="submission_date" id="submission_date" value="<?= htmlspecialchars($app['submission_date'] ?? '') ?>">
+                    </div>
+                </div>
+                <div class="mb-3 row">
+                    <label class="col-sm-3 col-form-label">Planning Required By</label>
+                    <div class="col-sm-4">
+                        <input type="date" class="form-control form-control-sm rounded-0" name="required_by" id="required_by" value="<?= htmlspecialchars($app['required_by'] ?? '') ?>">
+                    </div>
+                </div>
+            	<div class="mb-3 row">
+  					<label class="col-sm-3 col-form-label">Statutory clock</label>
+  					<div class="col-sm-9 d-flex align-items-center gap-3">
+    					<div class="form-check">
+      						<input class="form-check-input rounded-0" type="checkbox" id="clock_paused" name="clock_paused" value="1"
+             				<?= !empty($app['clock_paused']) ? 'checked' : '' ?>>
+      						<label class="form-check-label" for="clock_paused">Pause clock (RFI)</label>
+    					</div>
+    					<input type="text" class="form-control form-control-sm rounded-0" style="max-width:420px" name="clock_pause_reason" placeholder="Reason, e.g. Council RFI received" value="<?= htmlspecialchars($app['clock_pause_reason'] ?? '') ?>">
+  					</div>
+				</div>
+                <hr>
+                <h5>Milestones</h5>
+                <div class="table-responsive">
+                    <table class="table table-sm table-bordered align-middle">
+                        <thead>
+                            <tr>
+                                <th>#</th>
+                                <th>Stage Name</th>
+                                <th>Status</th>
+                                <th>Date</th>
+                                <th>Notes</th>
+                                <th>PDF</th>
+                            </tr>
+                        </thead>
+                        <tbody id="stagesBody">
+                            <?php
+    $rowsOut = [];
+            // Materialize existing rows in order
+            ksort($existing);
+            $pos = 0;
+            foreach ($existing as $i => $row) {
+                $rowsOut[] = [
+                    'id'      => $row['id'] ?? '',
+                    'pos'     => $pos++,
+                    'title'   => $row['title'] ?? ('Stage ' . ($i+1)),
+                    'status'  => $row['status'] ?? 'pending',
+                    'date'    => $row['stage_date'] ?? '',
+                    'notes'   => $row['description'] ?? '',
+                    'pdf'     => $row['pdf_path'] ?? '',
+                ];
+            }
+            // Pad with defaults if needed
+            for ($i = count($rowsOut); $i < max(count($rowsOut), count($defaultStages)); $i++) {
+                $rowsOut[] = [
+                    'id' => '',
+                    'pos' => $i,
+                    'title' => $defaultStages[$i] ?? ('Stage ' . ($i+1)),
+                    'status' => 'pending',
+                    'date' => '',
+                    'notes' => '',
+                    'pdf' => '',
+                ];
+            }
+
+            // Render
+            foreach ($rowsOut as $r):
+                            ?>
+                            <tr data-row="<?= (int)$r['pos'] ?>">
+                                <td><?= (int)($r['pos']+1) ?></td>
+                                <td>
+                                    <input type="hidden" name="stages[<?= (int)$r['pos'] ?>][id]" value="<?= h($r['id']) ?>">
+                                    <input type="hidden" name="stages[<?= (int)$r['pos'] ?>][position]" value="<?= (int)$r['pos'] ?>">
+                                    <input type="text" class="form-control form-control-sm rounded-0" name="stages[<?= (int)$r['pos'] ?>][title]" value="<?= h($r['title']) ?>">
+                                </td>
+                                <td>
+                                    <select name="stages[<?= (int)$r['pos'] ?>][status]" class="form-select form-select-sm rounded-0">
+                                        <option value="pending"  <?= $r['status']==='pending'  ? 'selected' : '' ?>>Pending</option>
+                                        <option value="current"  <?= $r['status']==='current'  ? 'selected' : '' ?>>Current</option>
+                                        <option value="complete" <?= $r['status']==='complete' ? 'selected' : '' ?>>Complete</option>
+                                        <option value="paused"   <?= $r['status']==='paused'   ? 'selected' : '' ?>>Paused (RFI)</option>
+                                    </select>
+                                </td>
+                                <td>
+                                    <input
+                                           type="date"
+                                           id="stage_date_<?= (int)$r['pos'] ?>"
+                                           name="stages[<?= (int)$r['pos'] ?>][date]"
+                                           class="form-control form-control-sm rounded-0"
+                                           value="<?= h($r['date']) ?>"
+                                           >
+                                </td>
+                                <td><textarea name="stages[<?= (int)$r['pos'] ?>][notes]" class="form-control form-control-sm rounded-0" rows="1"><?= h($r['notes']) ?></textarea></td>
+                                <td class="small">
+                                    <?php if ($r['pdf']): ?>
+                                    <a href="<?= h($r['pdf']) ?>" target="_blank" class="d-block mb-1">Current PDF</a>
+                                    <label class="form-check"><input class="form-check-input rounded-0" type="checkbox" name="stages[<?= (int)$r['pos'] ?>][remove_pdf]" value="1"><span class="form-check-label">Remove</span></label>
+                                    <?php endif; ?>
+                                    <input type="file" name="stages[<?= (int)$r['pos'] ?>][pdf]" class="form-control form-control-sm rounded-0">
+                                </td>
+                            </tr>
+                            <?php endforeach; ?>
+                        </tbody>
+                    </table>
+
+                </div>
+                <button type="button" class="btn btn-sm btn-outline-primary rounded-0" id="btnAddStage"><i class="bi bi-plus-lg"></i> Add stage</button>
+                <button type="submit" class="btn btn-sm btn-outline-secondary rounded-0">Save Timeline</button>
+                <a href="admin_dashboard.php" class="btn btn-sm btn-secondary rounded-0">Back</a>
+            </form>
+
+            <div class="row mt-2">
+                <div class="col-6">
+                    <div class="d-flex gap-2">
+                        <button type="button" class="btn btn-sm btn-outline-primary rounded-0" id="btnPrefill">Prefill (don’t overwrite)</button>
+                        <button type="button" class="btn btn-sm btn-outline-danger rounded-0" id="btnPrefillOverwrite">Recalculate (overwrite)</button>
+                    </div>
+                </div>
+                <div class="col-6">
+                    <div class="text-end">
+                        <?php if ($progressUrl): ?>
+                        <button class="btn rounded-0 btn-sm btn-outline-dark" type="button" onclick="navigator.clipboard.writeText('<?= htmlspecialchars($progressUrl, ENT_QUOTES) ?>')">
+                            Copy progress link
+                        </button>
+                        <a class="btn rounded-0 btn-sm btn-outline-secondary" href="<?= htmlspecialchars($progressUrl) ?>" target="_blank" rel="noopener">
+                            Open progress
+                        </a>
+                        <button class="btn rounded-0 btn-sm bg-brown-three brown-five" type="button" data-bs-toggle="modal" data-bs-target="#sendProgressModal">
+                            Email link
+                        </button>
+                        <?php else: ?>
+                        <button class="btn rounded-0 btn-sm btn-outline-dark" type="button" disabled>Copy progress link</button>
+                        <button class="btn rounded-0 btn-sm btn-outline-secondary" type="button" disabled>Open progress</button>
+                        <button class="btn rounded-0 btn-sm bg-brown-three brown-five" type="button" disabled>Email link</button>
+                        <?php if ($progressErr): ?>
+                        <div class="small text-danger mt-1"><?= htmlspecialchars($progressErr) ?></div>
+                        <?php endif; ?>
+                        <?php endif; ?>
+                    </div>
+                </div>
+            </div>
+
+            <hr class="my-4">
+            <div id="correspondence" class="row">
+                <div class="col-12 col-xl-5 mb-4">
+                    <div class="card border-0 shadow-sm">
+                        <div class="card-header bg-white">
+                            <strong>Add correspondence / note</strong>
+                        </div>
+                        <div class="card-body">
+                            <form method="post" class="row g-3" enctype="multipart/form-data">
+                                <input type="hidden" name="csrf" value="<?= $csrf ?>">
+                                <input type="hidden" name="action" value="add_correspondence">
+
+                                <div class="col-6">
+                                    <label class="form-label">When</label>
+                                    <input type="datetime-local" name="event_at" class="form-control form-control-sm"
+                                           value="<?= htmlspecialchars((new DateTime('now', new DateTimeZone('Australia/Hobart')))->format('Y-m-d\TH:i')) ?>">
+                                </div>
+                                <div class="col-6">
+                                    <label class="form-label">Visibility</label>
+                                    <select name="visibility" class="form-select form-select-sm">
+                                        <option value="client">Client-visible</option>
+                                        <option value="internal">Internal</option>
+                                    </select>
+                                </div>
+
+                                <div class="col-4">
+                                    <label class="form-label">Type</label>
+                                    <select name="type" class="form-select form-select-sm">
+                                        <option value="incoming">Incoming</option>
+                                        <option value="outgoing">Outgoing</option>
+                                        <option value="note" selected>Note</option>
+                                    </select>
+                                </div>
+                                <div class="col-4">
+                                    <label class="form-label">Channel</label>
+                                    <select name="channel" class="form-select form-select-sm">
+                                        <option value="email">Email</option>
+                                        <option value="phone">Phone</option>
+                                        <option value="meeting">Meeting</option>
+                                        <option value="other" selected>Other</option>
+                                    </select>
+                                </div>
+                                <div class="col-4 d-flex align-items-end">
+                                    <div class="form-check">
+                                        <input class="form-check-input" type="checkbox" name="pin" id="pin">
+                                        <label class="form-check-label" for="pin">Pin to top</label>
+                                    </div>
+                                </div>
+
+                                <div class="col-6">
+                                    <label class="form-label">Subject</label>
+                                    <input type="text" name="subject" id="corrSubject" class="form-control form-control-sm" placeholder="Optional">
+                                </div>
+                                <div class="col-6">
+                                    <label class="form-label">Author</label>
+                                    <input type="text" name="author" id="corrAuthor" class="form-control form-control-sm" placeholder="e.g. Council Officer">
+                                </div>
+
+                                <div class="col-12">
+                                    <label class="form-label">Paste email / note</label>
+                                    <textarea name="body" id="corrBody" rows="6" class="form-control form-control-sm" placeholder="Paste email text here..."></textarea>
+                                    <div class="form-text">
+                                        Tip: click <a href="#" id="tryParse">Try auto-parse</a> to fill Subject/When from headers (Subject:, Date:, From:).
+                                    </div>
+                                </div>
+
+                                <div class="col-12">
+                                    <label class="form-label">Attach PDF(s)</label>
+                                    <div id="corrDropZone" class="dropzone-sm">
+                                        <input id="corrFiles" type="file" name="attachments[]" accept="application/pdf" multiple hidden>
+                                        <div class="dz-instructions">
+                                            Drag & drop PDF here, or <u>click to browse</u>.
+                                        </div>
+                                        <div id="corrFileList" class="dz-list small text-muted"></div>
+                                    </div>
+                                    <div class="form-text">PDF only.</div>
+                                </div>
+
+                                <div class="col-12">
+                                    <div class="form-check">
+                                        <input class="form-check-input" type="checkbox" name="notify_client" id="notify_client" value="1">
+                                        <label class="form-check-label" for="notify_client">
+                                            Email client
+                                        </label>
+                                    </div>
+                                    <div class="form-text">
+                                        Sent if Client-visible
+                                        <?= $clientEmail ? ' – <b>'.h($clientEmail).'</b>.' : '' ?>
+                                    </div>
+                                </div>
+
+                                <div class="col-12 text-end">
+                                    <button type="submit" class="btn btn-sm btn-secondary rounded-0">Save</button>
+                                </div>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+                <div class="col-12 col-xl-7">
+                    <div class="d-flex justify-content-between align-items-center mb-2">
+                        <h5 class="mb-0">Recent correspondence</h5>
+                        <span class="text-muted small"><?= count($correspondence) ?> shown</span>
+                    </div>
+
+                    <div class="row row-cols-1 gy-2">
+                        <?php if (empty($correspondence)): ?>
+                        <div class="text-muted">No correspondence yet.</div>
+                        <?php else:
+                        // icon maps (define once)
+                        $badgeMap = [
+                            'email_incoming' => 'bi-envelope-arrow-up',
+                            'email_outgoing' => 'bi-send-check',
+                            'phone_incoming' => 'bi-telephone-inbound',
+                            'phone_outgoing' => 'bi-telephone-outbound',
+                            'note'           => 'bi-journal-text',
+                        ];
+                        $fallbackByChannel = [
+                            'email'   => 'bi-envelope',
+                            'phone'   => 'bi-telephone',
+                            'meeting' => 'bi-people',
+                            'other'   => 'bi-chat-dots',
+                        ];
+
+                        foreach ($correspondence as $c):
+                        // per-row values
+                        $typeVal    = strtolower(trim($c['type'] ?? 'note'));        // incoming|outgoing|note
+                        $channelVal = strtolower(trim($c['channel'] ?? 'other'));    // email|phone|meeting|other
+                        $key        = ($typeVal === 'note') ? 'note' : "{$channelVal}_{$typeVal}";
+                        $icon       = $badgeMap[$key] ?? ($fallbackByChannel[$channelVal] ?? 'bi-journal-text');
+
+                        $badge = $c['visibility']==='internal' ? '<span class="badge text-bg-secondary ms-2">Internal</span>' : '';
+                        $pin   = $c['pin'] ? '<i class="bi bi-pin-angle-fill text-warning ms-1" title="Pinned"></i>' : '';
+                        ?>
+                        <div class="col">
+                            <div class="card card-sm shadow-sm border-0">
+                                <div class="card-body p-3">
+                                    <div class="d-flex justify-content-between">
+                                        <div class="d-flex align-items-center gap-2">
+                                            <i class="bi <?= $icon ?> text-muted"></i>
+                                            <strong><?= h($c['subject'] ?: ucfirst($c['type'])) ?></strong>
+                                            <?= $badge ?> <?= $pin ?>
+                                        </div>
+                                        <small class="text-muted"><?= dt_human($c['event_at']) ?></small>
+                                    </div>
+                                    <div class="small text-muted mt-1"><?= h(excerpt($c['body'])) ?></div>
+                                    <div class="d-flex gap-2 mt-2">
+                                        <button
+                                                class="btn btn-sm btn-outline-secondary rounded-0"
+                                                data-bs-toggle="modal" data-bs-target="#editCorrModal"
+                                                data-id="<?= (int)$c['id'] ?>"
+                                                data-event="<?= h(dt_local($c['event_at'])) ?>"
+                                                data-type="<?= h($c['type']) ?>"
+                                                data-channel="<?= h($c['channel']) ?>"
+                                                data-subject="<?= h($c['subject']) ?>"
+                                                data-author="<?= h($c['author']) ?>"
+                                                data-visibility="<?= h($c['visibility']) ?>"
+                                                data-pin="<?= (int)$c['pin'] ?>"
+                                                data-body="<?= h($c['body']) ?>"
+                                                >Edit</button>
+                                    </div>
+                                    <?php
+    $cid = (int)$c['id'];
+                                               if (!empty($attByCorr[$cid])):
+                                    ?>
+                                    <div class="mt-2">
+                                        <?php foreach ($attByCorr[$cid] as $a): ?>
+                                        <a class="btn btn-sm btn-outline-secondary rounded-0 me-1"
+                                           href="<?= h($a['file_url']) ?>" target="_blank" rel="noopener">
+                                            <i class="bi bi-file-earmark-pdf"></i> <?= h($a['original_name']) ?>
+                                        </a>
+                                        <?php endforeach; ?>
+                                    </div>
+                                    <?php endif; ?>
+                                </div>
+                            </div>
+                        </div>
+                        <?php endforeach; endif; ?>
+                    </div>
+
+                </div>
+            </div>
+        </div>
+        <div class="modal fade" id="editCorrModal" tabindex="-1" aria-hidden="true">
+            <div class="modal-dialog modal-lg modal-dialog-scrollable">
+                <form method="post" class="modal-content" enctype="multipart/form-data">
+                    <input type="hidden" name="csrf" value="<?= $csrf ?>">
+                    <input type="hidden" name="action" value="update_correspondence">
+                    <input type="hidden" name="id" id="ec_id">
+
+                    <div class="modal-header">
+                        <h5 class="modal-title">Edit correspondence</h5>
+                        <button type="button" class="btn btn-sm btn-close rounded-0" data-bs-dismiss="modal" aria-label="Close"></button>
+                    </div>
+
+                    <div class="modal-body">
+                        <div class="row g-3">
+                            <div class="col-6">
+                                <label class="form-label">When</label>
+                                <input type="datetime-local" class="form-control form-control-sm rounded-0" name="event_at" id="ec_event">
+                            </div>
+                            <div class="col-6">
+                                <label class="form-label">Visibility</label>
+                                <select name="visibility" id="ec_visibility" class="form-select form-select-sm rounded-0">
+                                    <option value="client">Client-visible</option>
+                                    <option value="internal">Internal</option>
+                                </select>
+                            </div>
+
+                            <div class="col-4">
+                                <label class="form-label">Type</label>
+                                <select name="type" id="ec_type" class="form-select form-select-sm rounded-0">
+                                    <option value="incoming">Incoming</option>
+                                    <option value="outgoing">Outgoing</option>
+                                    <option value="note">Note</option>
+                                </select>
+                            </div>
+                            <div class="col-4">
+                                <label class="form-label">Channel</label>
+                                <select name="channel" id="ec_channel" class="form-select form-select-sm rounded-0">
+                                    <option value="email">Email</option>
+                                    <option value="phone">Phone</option>
+                                    <option value="meeting">Meeting</option>
+                                    <option value="other">Other</option>
+                                </select>
+                            </div>
+                            <div class="col-4 d-flex align-items-end">
+                                <div class="form-check">
+                                    <input class="form-check-input rounded-0" type="checkbox" name="pin" id="ec_pin">
+                                    <label class="form-check-label" for="ec_pin">Pin to top</label>
+                                </div>
+
+                                <div class="form-check">
+                                    <input class="form-check-input" type="checkbox" name="notify_client" id="ec_notify_client" value="1">
+                                    <label class="form-check-label" for="ec_notify_client">Email client</label>
+                                    <div class="form-text">
+                                        Sent if Client-visible
+                                        <?= $clientEmail ? ' – <b>'.h($clientEmail).'</b>.' : '' ?>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <div class="col-6">
+                                <label class="form-label">Subject</label>
+                                <input type="text" name="subject" id="ec_subject" class="form-control form-control-sm rounded-0">
+                            </div>
+                            <div class="col-6">
+                                <label class="form-label">Author</label>
+                                <input type="text" name="author" id="ec_author" class="form-control form-control-sm rounded-0">
+                            </div>
+
+                            <div class="col-12">
+                                <label class="form-label">Body</label>
+                                <textarea name="body" id="ec_body" rows="8" class="form-control form-control-sm rounded-0"></textarea>
+                            </div>
+
+                            <div class="col-12">
+                                <label class="form-label">Attach PDF(s)</label>
+                                <div id="ec_corrDropZone" class="dropzone-sm">
+                                    <input id="ec_corrFiles" type="file" name="attachments[]" accept="application/pdf" multiple hidden>
+                                    <div class="dz-instructions">Drag & drop PDF here, or <u>click to browse</u>.</div>
+                                    <div id="ec_corrFileList" class="dz-list small text-muted"></div>
+                                </div>
+                                <div class="form-text">PDF only.</div>
+                            </div>
+                        </div>
+                    </div>
+
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-sm btn-light rounded-0" data-bs-dismiss="modal">Cancel</button>
+                        <button type="submit" class="btn btn-sm btn-primary rounded-0">Save changes</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+
+        <!-- Send Progress Modal -->
+        <div class="modal fade" id="sendProgressModal" tabindex="-1" aria-hidden="true">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h5 class="modal-title">Email Progress Link</h5>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                    </div>
+                    <div class="modal-body">
+                        <div class="mb-3">
+                            <label class="form-label">Send to email</label>
+                            <input id="progressEmail" type="email" class="form-control" placeholder="client@example.com" value="<?= htmlspecialchars($clientEmail) ?>">
+                        </div>
+                        <div class="alert alert-secondary">
+                            The email includes a link to this application’s public progress page.
+                        </div>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="button" class="btn rounded-0 btn-sm bg-brown-five brown-three" data-bs-dismiss="modal">Close</button>
+                        <button type="button" class="btn rounded-0 btn-sm bg-brown-three brown-five" id="confirmSendProgressBtn">Send</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+        <script>
+            window.CSRF = "<?= $csrf ?>";
+            const editModal = document.getElementById('editCorrModal');
+            editModal?.addEventListener('show.bs.modal', function (ev) {
+            const btn = ev.relatedTarget;
+            const get = (k) => btn.getAttribute('data-' + k) || '';
+
+            document.getElementById('ec_id').value        = get('id');
+            document.getElementById('ec_event').value     = get('event');
+            document.getElementById('ec_subject').value   = get('subject');
+            document.getElementById('ec_author').value    = get('author');
+            document.getElementById('ec_body').value      = get('body');
+
+            document.getElementById('ec_type').value      = get('type') || 'note';
+            document.getElementById('ec_channel').value   = get('channel') || 'other';
+            document.getElementById('ec_visibility').value= get('visibility') || 'client';
+            document.getElementById('ec_pin').checked     = get('pin') === '1';
+            });
+
+            (function(){
+            const STG = {
+            SUBMIT: 0,
+                ACK: 1,
+                    FEES: 2,
+                        VALID: 3,
+                            AD_START: 4,
+                                AD_END: 5,
+                                    DECISION: 6
+            };
+
+            const subEl = document.getElementById('submission_date');
+            const reqEl = document.getElementById('required_by');
+
+            function parseDate(str){ return str ? new Date(str + 'T00:00:00') : null; }
+            function fmt(d){ if(!d) return ''; const m=('0'+(d.getMonth()+1)).slice(-2); const day=('0'+d.getDate()).slice(-2); return `${d.getFullYear()}-${m}-${day}`; }
+            function addDays(d, n){ const x = new Date(d); x.setDate(x.getDate()+n); return x; }
+            function setStageDate(idx, dateStr, overwrite){
+                const el = document.getElementById('stage_date_'+idx);
+                if(!el) return;
+                if(!overwrite && el.value) return; // keep manual value
+                el.value = dateStr || '';
+            }
+
+            function prefill(overwrite=false){
+                const sub = parseDate(subEl.value);
+                const req = parseDate(reqEl.value);
+
+                let submission = sub;
+                let decision = req;
+
+                // If only required_by is set, back-calc submission
+                if (!submission && decision) submission = addDays(decision, -42);
+                // If only submission is set, forward-calc decision
+                if (submission && !decision) decision = addDays(submission, 42);
+
+                // Guard: nothing to do
+                if (!submission && !decision) return;
+
+                // Intermediates – simple defaults (editable by you)
+                // You can adjust these offsets anytime; they’re just sensible pre-fills.
+                const ack       = submission ? addDays(submission, 2) : null;
+                const fees      = submission ? addDays(submission, 3) : null;
+                const valid     = submission ? addDays(submission, 5) : null; // when the 42-day clock typically starts
+                const adStart   = valid     ? addDays(valid, 1)       : (submission ? addDays(submission, 6) : null);
+                const adEnd     = adStart   ? addDays(adStart, 14)    : null;
+
+                setStageDate(STG.SUBMIT,   fmt(submission), overwrite);
+                setStageDate(STG.ACK,      fmt(ack),        overwrite);
+                setStageDate(STG.FEES,     fmt(fees),       overwrite);
+                setStageDate(STG.VALID,    fmt(valid),      overwrite);
+                setStageDate(STG.AD_START, fmt(adStart),    overwrite);
+                setStageDate(STG.AD_END,   fmt(adEnd),      overwrite);
+                setStageDate(STG.DECISION, fmt(decision),   overwrite);
+            }
+
+            // Auto-prefill (non-destructive) when either anchor date changes
+            subEl?.addEventListener('change', ()=>prefill(false));
+            reqEl?.addEventListener('change', ()=>prefill(false));
+
+            // Buttons
+            document.getElementById('btnPrefill')?.addEventListener('click', ()=>prefill(false));
+            document.getElementById('btnPrefillOverwrite')?.addEventListener('click', ()=>prefill(true));
+
+            // First load: try a gentle prefill
+            prefill(false);
+            })();
+            document.getElementById('confirmSendProgressBtn')?.addEventListener('click', async () => {
+            const email = (document.getElementById('progressEmail')?.value || '').trim();
+            if (!email) { alert('Please enter an email'); return; }
+            const fd = new FormData();
+            fd.append('action', 'send_progress_link');
+            fd.append('email', email);
+            fd.append('csrf', window.CSRF || '');
+            const res = await fetch('?id=<?= (int)$app_id ?>', { method: 'POST', body: fd });
+            let js = {};
+            try { js = await res.json(); }
+            catch(e) {
+                const txt = await res.text();
+                alert('Server error:\n' + txt); // temporary debugging aid
+                return;
+            }
+            if (js.ok) {
+                bootstrap.Modal.getInstance(document.getElementById('sendProgressModal'))?.hide();
+                //alert('Email sent.');
+            } else {
+                alert(js.error || 'Failed to send!');
+            }
+            });
+
+                (function(){
+                const dz = document.getElementById('corrDropZone');
+                const fi = document.getElementById('corrFiles');
+                const list = document.getElementById('corrFileList');
+                if (!dz || !fi || !list) return;
+
+                function refreshList(files) {
+                list.innerHTML = '';
+                if (!files || !files.length) return;
+                for (const f of files) {
+                const ok = (f.type === 'application/pdf') || /\.pdf$/i.test(f.name);
+                const row = document.createElement('div');
+                row.textContent = (ok ? '📄 ' : '⚠️ ') + f.name;
+                list.appendChild(row);
+            }
+            }
+
+            dz.addEventListener('click', () => fi.click());
+            dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.classList.add('dragover'); });
+            dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
+            dz.addEventListener('drop', (e) => {
+                e.preventDefault(); dz.classList.remove('dragover');
+                const files = [...(e.dataTransfer?.files || [])].filter(f =>
+                                   f && ((f.type === 'application/pdf') || /\.pdf$/i.test(f.name))
+                                  );
+                                   const dt = new DataTransfer();
+                                   for (const f of files) dt.items.add(f);
+                                   fi.files = dt.files;
+                                   refreshList(fi.files);
+                                   });
+                                   fi.addEventListener('change', () => refreshList(fi.files));
+                                   })();
+
+
+                                   function wireDropzone(zoneId, inputId, listId) {
+                                   const dz = document.getElementById(zoneId);
+                                   const fi = document.getElementById(inputId);
+                                   const list = document.getElementById(listId);
+                                   if (!dz || !fi || !list) return;
+
+                                   const refreshList = (files) => {
+                                   list.innerHTML = '';
+                                   if (!files || !files.length) return;
+                                   for (const f of files) {
+                                   const ok = (f.type === 'application/pdf') || /\.pdf$/i.test(f.name);
+                                   const row = document.createElement('div');
+                                   row.textContent = (ok ? '📄 ' : '⚠️ ') + f.name;
+                               list.appendChild(row);
+                               }
+                               };
+
+                               dz.addEventListener('click', () => fi.click());
+                               dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.classList.add('dragover'); });
+                               dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
+                               dz.addEventListener('drop', (e) => {
+                               e.preventDefault(); dz.classList.remove('dragover');
+                               const files = [...(e.dataTransfer?.files || [])].filter(f =>
+                                                                                       f && ((f.type === 'application/pdf') || /\.pdf$/i.test(f.name))
+                                                                                      );
+                const dt = new DataTransfer();
+                for (const f of files) dt.items.add(f);
+                fi.files = dt.files;
+                refreshList(fi.files);
+            });
+            fi.addEventListener('change', () => refreshList(fi.files));
+            }
+
+            // add form
+            wireDropzone('corrDropZone','corrFiles','corrFileList');
+            // edit modal
+            wireDropzone('ec_corrDropZone','ec_corrFiles','ec_corrFileList');
+
+            document.getElementById('tryParse')?.addEventListener('click', function(e){
+            e.preventDefault();
+            const body = document.getElementById('corrBody')?.value || '';
+            const subj = /(?:^|\n)Subject:\s*(.+)/i.exec(body);
+            const from = /(?:^|\n)From:\s*(.+)/i.exec(body);
+            const date = /(?:^|\n)Date:\s*(.+)/i.exec(body);
+
+            if (subj) document.getElementById('corrSubject').value = subj[1].trim();
+            if (from) document.getElementById('corrAuthor').value  = from[1].trim();
+
+            if (date) {
+            const guess = new Date(date[1]);
+            if (!isNaN(guess.getTime())) {
+            const pad = n => String(n).padStart(2,'0');
+            const v = guess.getFullYear() + '-' + pad(guess.getMonth()+1) + '-' + pad(guess.getDate())
+                + 'T' + pad(guess.getHours()) + ':' + pad(guess.getMinutes());
+            document.querySelector('input[name="event_at"]').value = v;
+            }
+            }
+            });
+
+            const visAdd = document.querySelector('select[name="visibility"]');
+            const chkAdd = document.getElementById('notify_client');
+            function syncNotifyDisabled(sel, chk){
+            if (!sel || !chk) return;
+            const internal = sel.value === 'internal';
+            chk.disabled = internal;
+            if (internal) chk.checked = false;
+            }
+            visAdd?.addEventListener('change', ()=>syncNotifyDisabled(visAdd, chkAdd));
+            syncNotifyDisabled(visAdd, chkAdd);
+
+            const visEdit = document.getElementById('ec_visibility');
+            const chkEdit = document.getElementById('ec_notify_client');
+            visEdit?.addEventListener('change', ()=>syncNotifyDisabled(visEdit, chkEdit));
+            editModal?.addEventListener('show.bs.modal', ()=>syncNotifyDisabled(visEdit, chkEdit));
+        </script>
+        <script type="text/template" id="stageRowTemplate">
+<tr data-row="__INDEX__">
+  <td>__HUMAN__</td>
+  <td>
+    <input type="hidden" name="stages[__INDEX__][id]" value="">
+    <input type="hidden" name="stages[__INDEX__][position]" value="__INDEX__">
+    <input type="text" class="form-control form-control-sm rounded-0" name="stages[__INDEX__][title]" value="Stage __HUMAN__">
+            </td>
+  <td>
+    <select name="stages[__INDEX__][status]" class="form-select form-select-sm rounded-0">
+      <option value="pending">Pending</option>
+      <option value="current">Current</option>
+      <option value="complete">Complete</option>
+      <option value="paused">Paused (RFI)</option>
+            </select>
+            </td>
+  <td><input type="date" id="stage_date___INDEX__" name="stages[__INDEX__][date]" class="form-control form-control-sm rounded-0"></td>
+  <td><textarea name="stages[__INDEX__][notes]" class="form-control form-control-sm rounded-0" rows="1"></textarea></td>
+  <td class="small"><input type="file" name="stages[__INDEX__][pdf]" class="form-control form-control-sm rounded-0"></td>
+            </tr>
+        </script>
+
+        <script>
+            document.getElementById('btnAddStage')?.addEventListener('click', () => {
+            const tbody = document.getElementById('stagesBody');
+            const tpl = document.getElementById('stageRowTemplate').textContent;
+            const nextIndex = [...tbody.querySelectorAll('tr')].length;
+            const html = tpl
+                .replaceAll('__INDEX__', nextIndex)
+                .replaceAll('__HUMAN__', nextIndex + 1);
+            const temp = document.createElement('tbody');
+            temp.innerHTML = html.trim();
+            tbody.appendChild(temp.firstElementChild);
+            });
+        </script>
+
+    </body>
+</html>

+ 3 - 0
contracts/generator/.gitignore

@@ -0,0 +1,3 @@
+.DS_Store
+node_modules/
+package-lock.json

+ 8 - 0
contracts/generator/.vscode/settings.json

@@ -0,0 +1,8 @@
+{
+  "files.associations": {
+    "*.php.inc": "php",
+    "*.phpsrc": "php",
+    "*.html.xml": "html",
+    "*.html.inc": "html"
+  }
+}

+ 171 - 0
contracts/generator/README.md

@@ -0,0 +1,171 @@
+# Generator Readme
+
+[![Netlify Status](https://api.netlify.com/api/v1/badges/dc7d73d9-c327-4bcd-a33a-657603bc64ab/deploy-status)](https://app.netlify.com/sites/stefanmatei/deploys)
+
+## Running it online
+
+You can run the generator online:
+* [**Generate a contract** online →](https://stefanmatei.com/contract-generator/edit)
+
+## Downloading and running the generator on your own server (4 options)
+
+Alternatively, you can download the generator as a small app written in vanilla Javascript:
+
+* [Download **generator.zip**](https://github.com/nonsalant/contract/releases/)
+
+…and transfer it to your own server.
+
+If running the generator locally, a local server will need to be started (eg: using the <a href="https://marketplace.visualstudio.com/items?itemName=yandeu.five-server" target="_blank">Five Server</a> extension in VS Code) in order for the generator to work (browsers block ES Javascript imports from being used locally).
+
+### Optional build step
+(for bundling styles and using postcss)
+
+Based on the level of control you need over the styles for the generator and the contract itself, you can go with one of the following 3 options:
+<br /><br />
+
+
+### Option 1: No build setp
+
+The generator can be used without any build step, with the existing contract styles (in regular/vanilla css) already compiled in `data/style.min.css`. 
+
+---
+### Option 2: Docker
+
+Build your own docker image or use the latest [image](https://hub.docker.com/r/sarangcr03/nonsalant-contract) from Docker hub
+
+### Deploy with the latest [image](https://hub.docker.com/r/sarangcr03/nonsalant-contract) 
+
+To run the image as a standalone container, use the following commands:
+```bash
+docker pull sarangcr03/nonsalant-contract:latest
+```
+```bash
+docker run -p 9090:80 sarangcr03/nonsalant-contract:latest
+```
+For those who prefer Docker Compose, below is a simple docker-compose.yml example that sets up the service:
+```yaml
+version: '3.8'
+services:
+  web:
+    image: sarangcr03/nonsalant-contract:latest
+    ports:
+      - "9090:80"
+```
+Start the service using:
+```bash
+docker-compose up
+```
+
+### Deploy with nginx proxy manager for reverse proxy and SSL
+
+*With this method you are not mapping an internal port to a host port in docker, so you must add a proxy host pointing to `"http://contract-app:80"` in the nginx proxy manager admin panel which will be located at `http://localhost:81/` (replace `localhost` with the ip address or hostname of the docker host)*
+
+docker-compose.yml example:
+```yaml
+version: '3.8'
+
+services:
+  nginx-proxy-manager:
+    image: jc21/nginx-proxy-manager:latest
+    restart: unless-stopped
+    ports:
+      - '80:80'   # HTTP
+      - '81:81'   # Admin interface
+      - '443:443' # HTTPS
+    volumes:
+      - ./data:/data
+      - ./letsencrypt:/etc/letsencrypt
+    networks:
+      - contract-net
+
+  contract-app:
+    image: sarangcr03/nonsalant-contract:latest
+    restart: unless-stopped
+    networks:
+      - contract-net
+
+networks:
+  contract-net:
+    driver: bridge
+```
+Start the service using:
+```bash
+docker-compose up
+```
+---
+### Option 3: Build step for the contract styles
+(in `📁data/more-data/css`)
+
+The styles for the contract use postcss for:
+* importing multiple CSS files into a single minified file
+* enabling use of selector nesting
+
+To edit the uncompilled postcss, a build step is needed with the following commands to “build” or “watch” (re-build on every change) the contract styles:
+
+```bash
+npm run postcss:build 
+```
+```bash
+npm run postcss:watch
+```
+
+#### FYI
+
+All the CSS for the contract styles (all files in the `📁data/more-data/css` folder, coming in through the `main.css` entrypoint) will be compilled from postcss to regular CSS (in `data/style.min.css`).
+
+All CSS for the generator styles (all files in the `📁styles` folder, coming in through the `main.css` entrypoint) are currently written in regular CSS and loaded using CSS `@import`s. 
+
+The contract's `data/style.min.css` is also imported in the generator's main.css entry point using a regular CSS `@import`.
+
+Postcss configuration can be found in `postcss.config.js`
+
+---
+
+### Option 4: Enabling postcss for the generator styles
+(in `📁styles`)
+
+If you intend to write postcss in the generator's styles too (in addition to the contract styles mentioned above, where postcss is enabled by default), you will need a separate watch command to process the postcss:
+
+```bash
+postcss:watch-generator
+```
+
+...and the following files will need to be edited accordingly:
+
+#### Inside the `package.json` file
+
+replace:
+```json
+  "scripts": {
+    "postcss:watch": "postcss data/more-data/css/main.css -o data/style.min.css -w",
+    "postcss:build": "postcss data/more-data/css/main.css -o data/style.min.css"
+  },
+```
+with:
+```json
+  "scripts": {
+    "postcss:watch": "postcss data/more-data/css/main.css -o data/style.min.css -w",
+    "postcss:watch-generator": "postcss styles/main.css -o styles/style.min.css -w",
+    "postcss:build": "postcss data/more-data/css/main.css -o data/style.min.css & postcss styles/main.css -o style.min.css"
+  },
+```
+#### Inside the `edit.html` file:
+
+replace:
+```html
+    <link rel="stylesheet" href="styles/main.css">
+    <!-- <link rel="stylesheet" href="styles/style.min.css"> -->
+```
+with:
+```html
+    <!-- <link rel="stylesheet" href="/styles/main.css"> -->
+    <link rel="stylesheet" href="/style.min.css">
+```
+
+#### FYI
+
+All the generator styles (all files in the `📁styles` folder, coming in through the `main.css` entrypoint) will now be compiled from postcss to regular CSS (in `styles/style.min.css`).
+
+The styles for the contract itself (`data/style.min.css` mentioned above) are imported in the generator styles using a regular CSS `@import`.
+
+Postcss configuration can be found in `postcss.config.js`

+ 43 - 0
contracts/generator/data/README.md

@@ -0,0 +1,43 @@
+# The data folder contains default content
+
+The generator loads this content in the browser memory when you first open the app or after you click "Reset Data".
+
+## What’s in each file?
+
+### contract-content.html
+Initial HTML for the contract content.
+
+---
+### signature.png
+
+The signature for the 1st party. 
+
+You can get a PNG file for a new signature by drawing it in the 
+<a target="_blank" href="https://stefanmatei.com/contract-generator/edit#below-contract">generator</a>, 
+clicking "Preview", and saving the signature image from the preview.
+
+---
+### contract-data.js
+Initial names and emails for the 1st and 2nd party.
+```javascript
+export const contractData = {
+    client: {
+        name: "",
+        email: ""
+    },
+    dev: {
+        name: "",
+        email: ""
+    }
+}
+```
+
+---
+### style.min.css
+All the styles needed for a contract, concatenated and minified.
+
+---
+### 📁 /more-data/ 
+PostCSS source code, a template to make the downloadable PHP file, 2 HTML partials for the signed and un-signed versions of the contract, a way to control the initial filename (including disabling the default timestamp — in contract-settings.js).
+
+See more info in the [/more-data/README](https://github.com/nonsalant/contract/tree/master/generator/data/more-data#readme) file.

+ 111 - 0
contracts/generator/data/contract-content.html

@@ -0,0 +1,111 @@
+<h1>
+  Contract of work
+</h1>
+<p>
+  This Contract is made and entered into as of the date set forth below by and between 
+  <strong>
+     Alice Williams 
+  </strong>
+   (hereinafter referred to as "Developer") and 
+  <strong>
+     Bob Smith 
+  </strong>
+   (hereinafter referred to as "Client").
+</p>
+<h2>
+  I. Agreement of parties
+</h2>
+<p>
+  Client hereby hires Developer to rebuild the current website 
+  <strong>
+     bobswebsite.com, 
+  </strong>
+   for the estimated total price of 
+  <strong>
+     $PRICE. 
+  </strong>
+   Developer agrees to provide quality service and to answer to Client's requests in a timely manner.
+</p>
+<p>
+  The payment plan is outlined in section VII of this contract.
+</p>
+<h2>
+  II. TERM
+</h2>
+<p>
+  This contract shall commence upon the first payment, as outlined in the payment plan and shall remain effective until the services are completed and delivered.
+</p>
+<h2>
+  III. Legal matters and copyrights
+</h2>
+<p>
+  Client hereby guarantees to Developer that any elements of text, graphics, photos, trademarks or other artwork that Client provides for inclusion in the website are either owned by them or that they have the permission to use them.
+</p>
+<p>
+  Upon receipt of the final payment, the following copyright assignment shall automatically occur:
+</p>
+<ol>
+  <li>
+    Client will own the graphics, virtual elements, text content, photographs, and other data provided, unless someone else owns them.
+  </li>
+  <li>
+    Developer owns the HTML markup, CSS and other code and they license it to Client for use on only this project. Developer can reserve the right to display, with Client's consent, the work as part of the portfolio.
+  </li>
+</ol>
+<h2>
+  IV. Modifications
+</h2>
+<p>
+  This contract may be modified by the parties in writing. All notices under this contract must be transmitted in writing by email and will only be effective upon confirmation of receipt.
+</p>
+<h2>
+  V. Termination
+</h2>
+<p>
+  Either party may terminate this contract at any time, effective immediately upon notice or mutual agreement.
+</p>
+<p>
+  In the event of termination, Developer shall be compensated for services performed through the date of termination in one of the following amounts, whichever is greater, together with any additional costs incurred trough and up to the date of cancellation:
+</p>
+<ol>
+  <li class="ql-indent-1">
+    any advanced payment,
+  </li>
+  <li class="ql-indent-1">
+    a prorated portion of the fees due, or
+  </li>
+  <li class="ql-indent-1">
+    hourly fees for work performed by Developer at the time of the termination.
+  </li>
+</ol>
+<h2>
+  VI. Force majeure
+</h2>
+<p>
+  Developer shall not be deemed in breach of this contract if they are unable to complete the services or any portion of them due to fire, earthquake, war, labor dispute, illness, internet breaches, or any other technical issues that are beyond their control. In the event of a 
+  <em>
+     force majeure, 
+  </em>
+   Developer shall give notice to Client of their inability to perform or of any delay in completing the services and shall propose revisions to the schedule for completion.
+</p>
+<h2>
+  VII. Payment plan
+</h2>
+<p>
+  Payments shall be made as follows:
+</p>
+<ul>
+  <li>
+    50% of total estimated fee will be required to commence work, after this contract has been approved and signed by both parties.
+  </li>
+  <li>
+    50% upon project closure.
+  </li>
+</ul>
+<p>
+  Any extra time required outside the project services mentioned in section I of this contract, will be billed at a rate of 
+  <strong>
+     $FEE 
+  </strong>
+   per hour.
+</p>

+ 16 - 0
contracts/generator/data/contract-data.js

@@ -0,0 +1,16 @@
+export const contractData = {
+    client: {
+        name: "",
+        email: ""
+    },
+    dev: {
+        name: "",
+        email: ""
+    },
+    // shortcodes: [
+    //     { name: '[price]', value: '$1000' },
+    //     { name: '[date]', value: '31/12/2024' },
+    //     { name: '[hourly-rate]', value: '$50' },
+    // ],
+    shortcodes: []
+}

+ 62 - 0
contracts/generator/data/more-data/README.md

@@ -0,0 +1,62 @@
+- **What’s in each file/folder**
+  * [contract-settings.js](#contract-settingsjs)
+  * [css (folder)](#css-folder)
+  * [html-partials (folder)](#html-partials-folder)
+  * [php-partials (folder)](#php-partials-folder)
+  * [scripts (folder)](#scripts-folder)
+
+## What’s in each file/folder
+
+### contract-settings.js
+A way to control the initial filename, including disabling the default timestamp and changing the "-" separator.
+```javascript
+    filename: {
+        name: "contract",
+        has_timestamp: true,
+        timestamp_separator: "-",
+    }
+```
+
+---
+### css (folder)
+
+PostCSS source code with main.css as the entry point. 
+
+If using this see the [readme file](https://github.com/nonsalant/contract/tree/master/generator#option-2-build-step-for-the-contract-styles)
+in the generator folder for info on re-compiling the contract styles into a single minified CSS file.
+
+```css
+/* from css/main.css */
+@layer reset, accessibility, animated-entrances, forms;
+@layer from-quill-editor, from-quill-editor-overrides;
+@layer utility;
+@layer signatures, buttons;
+@layer modal;
+@layer contract-typography;
+```
+The resulting vanilla CSS uses CSS Layers to control the specificity and precedence of the different stylesheets.
+
+---
+### html-partials (folder)
+2 HTML partials for the signed and un-signed versions of the contract:
+* ui-signed.html.xml
+* ui-unsigned.html.xml
+
+These are HTML, but an .xml extension is used to prevent some local servers from injecting code (js for live-reloading) into them when they're being fetched.
+HTML is a subset of XML so the syntax highlighting should work fine.
+
+---
+### php-partials (folder)
+
+A template to make the downloadable PHP file, split in 2 files:
+* contract_header.phpsrc
+* contract_footer.phpsrc
+
+These are to be treated as data files by the server (so they don't use a .php extension), but the .vscode/settings.json file in this repo should tell VS Code to apply the correct syntax highlighting for PHP.
+
+---
+### scripts (folder)
+Javascript code for the signed and un-signed versions of the contract + Javascript to set up the QR code.
+* contract-script-signed.js
+* contract-script-unsigned.js
+* qr-code.js

+ 7 - 0
contracts/generator/data/more-data/contract-settings.js

@@ -0,0 +1,7 @@
+export const contractSettings = {
+    filename: {
+        name: "contract",
+        has_timestamp: true,
+        timestamp_separator: "-",
+    },
+}

+ 59 - 0
contracts/generator/data/more-data/css/accessibility.css

@@ -0,0 +1,59 @@
+:where(*:focus-visible) {
+  outline: 3px solid #000000;
+  outline-offset: 3px;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  /* added line */
+  border: 0;
+}
+
+.skip-to-content {
+  position: fixed;
+  z-index: 9999;
+  border-radius: 8px;
+  background-color: hsl(var(--clr-darker-hsl));
+  color: hsl(var(--clr-light-hsl));
+  padding: 0.5em 1em;
+  margin-inline: auto;
+  text-decoration: none;
+  transform: translateY(calc(-100% - 5rem - 1px));
+  transition: transform 0.25s ease-in;
+  top: 1.5rem;
+  left: 0;
+  right: 0;
+  margin: 0 auto !important; /* override .button styles */
+  max-width: max-content;
+  display: block;
+  opacity: 0;
+  outline-offset: 1px;
+}
+.skip-to-content:hover {
+  background-color: hsl(var(--clr-light-hsl));
+  color: hsl(var(--clr-darker-hsl));
+}
+
+.skip-to-content:focus {
+  transform: translateY(0);
+  opacity: 1;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *, *::before, *::after {
+    animation-duration: 0.1ms !important;
+    animation-iteration-count: 1 !important;
+    transition-duration: auto !important;
+    scroll-behavior: auto !important;
+  }
+  .animate {
+    animation: none !important;
+  }
+}

+ 318 - 0
contracts/generator/data/more-data/css/animated-entrances.css

@@ -0,0 +1,318 @@
+@charset "UTF-8";
+/* from: https://codepen.io/nonsalant/pen/poKdGKN/8cd36d806e3bcae07060ebb80533f88d */
+/* ---------------------------------------- */
+/* Quick SCSS System for Animated Entrances */
+/* ---------------------------------------- */
+/*  Converted to SCSS by Ștefan Matei from: 
+    ---------------------------------------------------------------------------
+    "System for Animated Entrances" by Neale Van Fleet
+    https://css-tricks.com/a-handy-little-system-for-animated-entrances-in-css/ 
+    ---------------------------------------------------------------------------
+*/
+@media (prefers-reduced-motion: reduce) {
+  .animate {
+    animation: none !important;
+  }
+}
+
+/* Animation delay classes. Eg: .delay-1 */
+.delay-1 {
+  animation-delay: calc(0.5s + 1*0.1s);
+}
+
+.delay-2 {
+  animation-delay: calc(0.5s + 2*0.1s);
+}
+
+.delay-3 {
+  animation-delay: calc(0.5s + 3*0.1s);
+}
+
+.delay-4 {
+  animation-delay: calc(0.5s + 4*0.1s);
+}
+
+.delay-5 {
+  animation-delay: calc(0.5s + 5*0.1s);
+}
+
+.delay-6 {
+  animation-delay: calc(0.5s + 6*0.1s);
+}
+
+.delay-7 {
+  animation-delay: calc(0.5s + 7*0.1s);
+}
+
+.delay-8 {
+  animation-delay: calc(0.5s + 8*0.1s);
+}
+
+.delay-9 {
+  animation-delay: calc(0.5s + 9*0.1s);
+}
+
+.delay-10 {
+  animation-delay: calc(0.5s + 10*0.1s);
+}
+
+.delay-11 {
+  animation-delay: calc(0.5s + 11*0.1s);
+}
+
+.delay-12 {
+  animation-delay: calc(0.5s + 12*0.1s);
+}
+
+.delay-13 {
+  animation-delay: calc(0.5s + 13*0.1s);
+}
+
+.delay-14 {
+  animation-delay: calc(0.5s + 14*0.1s);
+}
+
+.delay-15 {
+  animation-delay: calc(0.5s + 15*0.1s);
+}
+
+.delay-16 {
+  animation-delay: calc(0.5s + 16*0.1s);
+}
+
+:where(.animate) {
+  animation-duration: 0.75s;
+  animation-delay: 0.5s;
+  animation-name: animate-fade;
+  animation-timing-function: cubic-bezier(0.26, 0.53, 0.74, 1.48);
+  animation-fill-mode: backwards;
+  /* Fade In */
+  /* Pop In */
+  /* Blur In */
+  /* Glow In */
+  /* Grow In */
+  /* Splat In */
+  /* Roll In */
+  /* Flip In */
+  /* Spin In */
+  /* Slide In */
+  /* Drop In */
+  /* Drop Up */
+}
+:where(.animate).fade {
+  animation-name: animate-fade;
+  animation-timing-function: ease;
+}
+:where(.animate).pop {
+  animation-name: animate-pop;
+}
+:where(.animate).blur {
+  animation-name: animate-blur;
+  animation-timing-function: ease;
+}
+:where(.animate).glow {
+  animation-name: animate-glow;
+  animation-timing-function: ease;
+}
+:where(.animate).grow {
+  animation-name: animate-grow;
+}
+:where(.animate).splat {
+  animation-name: animate-splat;
+}
+:where(.animate).roll {
+  animation-name: animate-roll;
+}
+:where(.animate).flip {
+  animation-name: animate-flip;
+  transform-style: preserve-3d;
+  perspective: 1000px;
+}
+:where(.animate).spin {
+  animation-name: animate-spin;
+  transform-style: preserve-3d;
+  perspective: 1000px;
+}
+:where(.animate).slide {
+  animation-name: animate-slide;
+}
+:where(.animate).drop {
+  animation-name: animate-drop;
+  animation-timing-function: cubic-bezier(0.77, 0.14, 0.91, 1.25);
+}
+:where(.animate).drop-up {
+  animation-name: animate-drop-up;
+  animation-timing-function: cubic-bezier(0.77, 0.14, 0.91, 1.25);
+}
+
+@media screen {
+  @keyframes animate-fade {
+    0% {
+      opacity: 0;
+    }
+    100% {
+      opacity: 1;
+    }
+  }
+  @keyframes animate-pop {
+    0% {
+      opacity: 0;
+      transform: scale(0.5, 0.5);
+    }
+    100% {
+      opacity: 1;
+      transform: scale(1, 1);
+    }
+  }
+  @keyframes animate-blur {
+    0% {
+      opacity: 0;
+      filter: blur(15px);
+    }
+    100% {
+      opacity: 1;
+      filter: blur(0px);
+    }
+  }
+  @keyframes animate-glow {
+    0% {
+      opacity: 0;
+      filter: brightness(3) saturate(3);
+      transform: scale(0.8, 0.8);
+    }
+    100% {
+      opacity: 1;
+      filter: brightness(1) saturate(1);
+      transform: scale(1, 1);
+    }
+  }
+  @keyframes animate-grow {
+    0% {
+      opacity: 0;
+      transform: scale(1, 0);
+      visibility: hidden;
+    }
+    100% {
+      opacity: 1;
+      transform: scale(1, 1);
+    }
+  }
+  @keyframes animate-splat {
+    0% {
+      opacity: 0;
+      transform: scale(0, 0) rotate(20deg) translate(0, -30px);
+    }
+    70% {
+      opacity: 1;
+      transform: scale(1.1, 1.1) rotate(15deg);
+    }
+    85% {
+      opacity: 1;
+      transform: scale(1.1, 1.1) rotate(15deg) translate(0, -10px);
+    }
+    100% {
+      opacity: 1;
+      transform: scale(1, 1) rotate(0) translate(0, 0);
+    }
+  }
+  @keyframes animate-roll {
+    0% {
+      opacity: 0;
+      transform: scale(0, 0) rotate(360deg);
+    }
+    100% {
+      opacity: 1;
+      transform: scale(1, 1) rotate(0deg);
+    }
+  }
+  @keyframes animate-flip {
+    0% {
+      opacity: 0;
+      transform: rotateX(-120deg) scale(0.9, 0.9);
+    }
+    100% {
+      opacity: 1;
+      transform: rotateX(0deg) scale(1, 1);
+    }
+  }
+  @keyframes animate-spin {
+    0% {
+      opacity: 0;
+      transform: rotateY(-120deg) scale(0.9, 0.9);
+    }
+    100% {
+      opacity: 1;
+      transform: rotateY(0deg) scale(1, 1);
+    }
+  }
+  @keyframes animate-slide {
+    0% {
+      opacity: 0;
+      transform: translate(0, 20px);
+    }
+    100% {
+      opacity: 1;
+      transform: translate(0, 0);
+    }
+  }
+  @keyframes animate-drop {
+    0% {
+      opacity: 0;
+      transform: translate(0, -300px) scale(0.9, 1.1);
+    }
+    95% {
+      opacity: 1;
+      transform: translate(0, 0) scale(0.9, 1.1);
+    }
+    96% {
+      opacity: 1;
+      transform: translate(10px, 0) scale(1.2, 0.9);
+    }
+    97% {
+      opacity: 1;
+      transform: translate(-10px, 0) scale(1.2, 0.9);
+    }
+    98% {
+      opacity: 1;
+      transform: translate(5px, 0) scale(1.1, 0.9);
+    }
+    99% {
+      opacity: 1;
+      transform: translate(-5px, 0) scale(1.1, 0.9);
+    }
+    100% {
+      opacity: 1;
+      transform: translate(0, 0) scale(1, 1);
+    }
+  }
+  @keyframes animate-drop-up {
+    0% {
+      opacity: 0;
+      transform: translate(0, 300px) scale(0.9, 1.1);
+    }
+    95% {
+      opacity: 1;
+      transform: translate(0, 0) scale(0.9, 1.1);
+    }
+    96% {
+      opacity: 1;
+      transform: translate(-10px, 0) scale(1.2, 0.9);
+    }
+    97% {
+      opacity: 1;
+      transform: translate(10px, 0) scale(1.2, 0.9);
+    }
+    98% {
+      opacity: 1;
+      transform: translate(-5px, 0) scale(1.1, 0.9);
+    }
+    99% {
+      opacity: 1;
+      transform: translate(5px, 0) scale(1.1, 0.9);
+    }
+    100% {
+      opacity: 1;
+      transform: translate(0, 0) scale(1, 1);
+    }
+  }
+}

+ 212 - 0
contracts/generator/data/more-data/css/buttons.css

@@ -0,0 +1,212 @@
+/* https://codepen.io/nonsalant/pen/mdKJyzJ/f70c96b7e3ec615a0b2fbe4f320855f9?editors=1100 */
+/* design system for action buttons (postcss👆) */
+
+.button,
+.ql-html-buttonCancel, .ql-html-buttonOk {
+    appearance: none;
+    -webkit-appearance: none;
+    -moz-appearance: none;
+    background: var(--clr-500);
+    color: #fff;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 0;
+    border: none;
+    border-radius: 8px;
+    overflow: hidden;
+    text-decoration: none;
+    font-size: 16px;
+    line-height: 26px;
+    cursor: pointer;
+    margin: 0;
+    padding: 10px 35px;
+    padding-inline: clamp(20px, 5vw, 35px);
+    padding-inline: clamp(20px,2vw,35px);
+    max-width: max-content;
+    user-select: none;
+    transition: outline .4s cubic-bezier(0.22, 1, 0.36, 1);
+
+    font-family: "Open Sans",sans-serif;
+    /* font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; */
+    letter-spacing: .025em;
+    font-weight: 400;
+    /* text-transform: uppercase; */
+
+    &:hover:not([disabled]) {
+        background: var(--clr-600);
+    }
+    &:active:not([disabled]) {
+        background: var(--clr-700);
+        transform: translate(2px 3px);
+        transform: scale(.975);
+        transition: transform .3s cubic-bezier(0.22, 1, 0.36, 1),
+                    outline .4s cubic-bezier(0.22, 1, 0.36, 1),
+                    background-color .2s linear;
+    }
+
+    &[disabled] {
+      filter: grayscale(0.75) contrast(0.75) brightness(0.96);
+      cursor: default;
+      transition: all .4s cubic-bezier(0.22, 1, 0.36, 1);
+    }
+
+}
+
+
+/* buttons with icons */
+
+.button:has(.icon) {
+  padding: 0;
+  gap: 0;
+  place-self: center;
+  
+  & > * {
+    display: inline-flex;
+    display: flex;
+    align-items: center;
+    align-items: space-evenly;
+    /*align-self: stretch;*/
+    justify-content: center;
+    gap: 0.5ex;
+    padding: 10px 35px;
+    padding-inline: clamp(20px, 2.5vw, 35px);
+    /* * fix or simplify to max() */
+    padding-inline: clamp(20px, min(2.5vw, 1.5em), 35px);
+    height: 100%;
+    /* background-color: rgba(0, 0, 0, 0.16); */
+  }
+  
+  & .icon {
+    font-size: 1.5em;
+    background-color: transparent;
+    background-color: rgba(0, 0, 0, 0.16);
+    /* * fix or simplify to max() */
+    padding-inline: clamp(20px, min(2.5vw, 1.5em), 24px);
+    /* padding-inline: clamp(20px, min(2.5vw, 1.5em), 35px); */
+
+    &.small-padding {
+      padding-inline: 10px;
+    }
+  }
+
+}
+
+/* button colors */
+
+.button,
+.ql-html-buttonCancel, .ql-html-buttonOk {
+  --clr-500: var(--clr-blue-desaturated-500);
+  --clr-600: var(--clr-blue-desaturated-600);
+  --clr-700: var(--clr-blue-desaturated-700);
+}
+
+.button.primary,
+.ql-html-buttonOk {
+  --clr-500: var(--clr-blue-500);
+  --clr-600: var(--clr-blue-600);
+  --clr-700: var(--clr-blue-700);
+}
+
+.button.danger {
+  --clr-500: var(--clr-red-500);
+  --clr-600:var(--clr-red-600);
+  --clr-700:var(--clr-red-700);
+}
+
+.button.success {
+  /* success (green) */
+  --clr-500: var(--clr-green-desaturated-500);
+  --clr-600: var(--clr-green-desaturated-600);
+  --clr-700: var(--clr-green-desaturated-700);
+}
+
+.button.warning,
+.ql-html-buttonCancel {
+  /* warning (brown/yellow) */
+  --clr-500: var(--clr-red-desaturated-500);
+  --clr-600:var(--clr-red-desaturated-600);
+  --clr-700:var(--clr-red-desaturated-700);
+}
+/* https://paletton.com/#uid=7380u0kllllaFw0g0qFqFg0w0aF */
+/* https://www.peko-step.com/en/tool/hslrgb_en.html */
+
+
+.invert-colors .button {
+  /* filter: invert(1) hue-rotate(180deg); */
+  /* border: solid 1px hsl(0deg 0% 100% / 10%); */
+
+  & {
+    filter: invert(1) hue-rotate(180deg);
+    background: var(--clr-600);
+    border: solid 1px hsl(0deg 0% 100% / 10%);
+    outline-color: #fff;
+  }
+  &:hover:not([disabled]) {
+    background: var(--clr-700);
+  }
+  &:active:not([disabled]) {
+    background: var(--clr-500);
+  }
+  &[disabled] {
+    filter: grayscale(.75) contrast(.75) brightness(.96) invert(1) hue-rotate(180deg);
+    cursor: default;
+  }
+
+  & > * {
+    background-color: rgba(0, 0, 0, 0.16);
+  }
+  & .icon {
+    background-color: transparent; 
+    /* background-color: rgba(0, 0, 0, 0.16); */
+    border-inline-start: solid 1px hsl(0deg 0% 100% / 5%);
+  }
+
+}
+
+/* Button sizes */
+
+.size-300.button {
+    padding: 5px 18px;
+    border-radius: 4px;
+    border-radius: 6px;
+}
+
+.size-300.button:has(.icon) {
+  padding: 0;
+
+  & > * {
+    padding: 5px 175px;
+    padding-inline: clamp(10px, min(1.25vw, 0.75em), 17.5px);
+  }
+  
+  & .icon {
+    padding-inline: clamp(10px, min(1.25vw, 0.75em), 12px);
+    /* font-size: 1rem;
+    padding-block: 10px; */
+    font-size: 1.25rem;
+    padding-block: 8px;
+  }
+}
+
+@media (width<535px) {
+  .button {
+    padding: 5px 18px;
+  }
+  .button:has(.icon) {
+    padding: 0;
+
+    & > * {
+      padding: 5px 175px;
+      padding-inline: clamp(10px, min(1.25vw, 0.75em), 17.5px);
+    }
+    
+    & .icon {
+      padding-inline: clamp(10px, min(1.25vw, 0.75em), 12px);
+      /* font-size: 1rem;
+      padding-block: 10px; */
+      font-size: 1.25rem;
+      padding-block: 8px;
+    }
+  }
+}

+ 52 - 0
contracts/generator/data/more-data/css/colors.css

@@ -0,0 +1,52 @@
+:root {
+
+  /***/
+
+  --clr-light-hsl:     20 80% 98%;
+  --clr-dark-hsl:     200 20% 25%;
+  --clr-darker-hsl:   200 59% 22%;
+
+  /***/
+  
+  --clr-primary-hsl: 200  75% 30%;
+  --clr-danger-hsl:   20 100% 30%;
+  --clr-success-hsl: 165  75% 30%;
+  --clr-warning-hsl:  36 100% 30%;
+
+  /***/
+
+  --clr-blue-500: hsl(200, 75%, 30%);
+  --clr-blue-600: hsl(200, 75%, 25%);
+  --clr-blue-700: hsl(200, 75%, 20%);
+
+  --clr-red-500: hsl(20, 75%, 30%);
+  --clr-red-600: hsl(20, 75%, 25%);
+  --clr-red-700: hsl(20, 75%, 20%);
+
+  --clr-green-500: hsl(165, 75%, 30%);
+  --clr-green-600: hsl(165, 75%, 25%);
+  --clr-green-700: hsl(165, 75%, 20%);
+
+  --clr-brown-500: hsl(36, 75%, 30%);
+  --clr-brown-600: hsl(36, 75%, 25%);
+  --clr-brown-700: hsl(36, 75%, 20%);
+
+  /* desaturated */
+  
+  --clr-blue-desaturated-500: hsl(200, 25%, 30%);
+  --clr-blue-desaturated-600: hsl(200, 25%, 20%);
+  --clr-blue-desaturated-700: hsl(200, 25%, 10%);
+
+  --clr-red-desaturated-500: hsl(20, 25%, 30%);
+  --clr-red-desaturated-600: hsl(20, 25%, 20%);
+  --clr-red-desaturated-700: hsl(20, 25%, 10%);
+
+  --clr-green-desaturated-500: hsl(165, 25%, 30%);
+  --clr-green-desaturated-600: hsl(165, 25%, 20%);
+  --clr-green-desaturated-700: hsl(165, 25%, 10%);
+
+  --clr-brown-desaturated-500: hsl(36, 25%, 30%);
+  --clr-brown-desaturated-600: hsl(36, 25%, 20%);
+  --clr-brown-desaturated-700: hsl(36, 25%, 10%);
+
+}

+ 83 - 0
contracts/generator/data/more-data/css/contract-typography.css

@@ -0,0 +1,83 @@
+/* @link https://utopia.fyi/type/calculator?c=320,16,1.2,735,16,1.333,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l */
+
+:root {
+  --step--2: clamp(0.56rem, calc(0.80rem + -0.51vw), 0.69rem);
+  --step--1: clamp(0.75rem, calc(0.90rem + -0.32vw), 0.83rem);
+  --step-0: clamp(1.00rem, calc(1.00rem + 0.00vw), 1.00rem);
+  --step-1: clamp(1.20rem, calc(1.10rem + 0.51vw), 1.33rem);
+  --step-2: clamp(1.44rem, calc(1.18rem + 1.30vw), 1.78rem);
+  --step-3: clamp(1.73rem, calc(1.23rem + 2.47vw), 2.37rem);
+  --step-4: clamp(2.07rem, calc(1.24rem + 4.18vw), 3.16rem);
+  --step-5: clamp(2.49rem, calc(1.16rem + 6.63vw), 4.21rem);
+}
+
+
+/**/
+#content {
+  background: #fff;
+  margin-bottom: 3em;
+  margin: 2rem 2rem 6rem 2rem;
+  /* max-width: calc(210mm - 2rem); */
+  margin-inline: 0;
+  padding: 0 2em;
+  /* padding-inline: clamp(20px,5vw,35px); */
+  width: 210mm;
+  max-width: calc(100% - 2rem);
+
+  max-width: 100%;
+  margin-inline: auto;
+  padding-inline: 2rem;
+  width: clamp(10rem, 60rem, 80rem);
+
+  width: 52em;
+  width: 210mm;
+} 
+/**/
+
+#main,
+#content {
+    /* line-height: 1.5em; */
+    font-family: "Libre Baskerville", serif;
+    font-size: var(--step-0);
+    line-height: 1.5;
+    /* line-height: 1.3; */
+    line-height: 1.75;
+}
+
+h2,h3,h4,h5,h6 { 
+  margin-block-start: 1.75em; 
+}
+
+h1 {
+    border-width: 3px 0 1px 0;
+    border-style: solid;
+    font-family: "Arapey", serif;
+    /* font-size: 2em; */
+    font-size: var(--step-2);
+    font-weight: normal;
+    letter-spacing: 0.15em;
+    line-height: 1.2em;
+    margin-block-start: 1rem;
+    margin-block-end: 2.5rem;
+    padding: 0.5em 0;
+    position: relative;
+    text-align: center;
+    text-transform: uppercase;
+}
+
+h2 {
+    font-family: "Open Sans Condensed", sans-serif;
+    font-family: "Open Sans", sans-serif;
+    font-variation-settings: "wdth" 75;
+    font-size: var(--step-1);
+    font-weight: 600;
+    letter-spacing: 0.025em;
+    line-height: 1.2em;
+
+    text-transform: uppercase;
+    /* letter-spacing: 0.025em; */
+}
+
+h3, h4, h5, h6 {
+  font-weight: 700;
+}

+ 0 - 0
classes/.htaccess → contracts/generator/data/more-data/css/custom.css


+ 4 - 0
contracts/generator/data/more-data/css/fonts.css

@@ -0,0 +1,4 @@
+@import url(https://fonts.googleapis.com/css?family=Libre+Baskerville:400,700,400italic);
+@import url(https://fonts.googleapis.com/css?family=Arapey);
+/* @import url(https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300,700); */
+@import url(https://fonts.googleapis.com/css2?family=Open+Sans:wdth,wght@75,600;100,400;100,600;100,700;100,800);

+ 77 - 0
contracts/generator/data/more-data/css/forms.css

@@ -0,0 +1,77 @@
+/* https://codepen.io/nonsalant/pen/zYaqjpK/a4a3062d9ebde51f31d0f29fdc276eed */
+/* text-like inputs and textarea styles */
+
+:where(input:is([type="text"], [type="email"], [type="password"]), textarea) {
+    transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
+    font-size: 1rem;
+    font-weight: 400;
+    box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 
+                0 1px 2px 0 rgba(0, 0, 0, 0.06);
+    padding-block: 0.35rem;
+    padding-inline: 0.75rem;
+    border-radius: 0.25rem;
+    appearance: none;
+    position: relative;
+    outline-offset: 0; 
+    outline-width: 1.5px;
+    color: hsl(var(--clr-dark-hsl) / 1);
+    background-color: hsl(var(--clr-light-hsl) / 0.6);
+    background-color: hsl(var(--clr-light-hsl) / 1);
+    /* background-color: red; */
+    border: solid 1.5px hsl(var(--clr-dark-hsl) / 0.5);
+    mix-blend-mode: luminosity;
+
+    &:focus {
+        color: hsl(var(--clr-darker-hsl) / 1);
+        background-color: hsl(var(--clr-light-hsl) / 1);
+        border: solid 1.5px hsl(var(--clr-darker-hsl) / 0.8);
+        /* outline-color: hsl(var(--clr-darker-hsl) / 0.8); */
+    }
+    /* &:focus:not(:focus-visible) {
+        outline: 0 !important;
+    } */
+    &::placeholder {
+        color: hsl(var(--clr-dark-hsl) / 0.75);
+    }
+    &:focus::placeholder {
+        color: hsl(var(--clr-dark-hsl) / 0.75);
+    }
+    /* &:-internal-autofill-selected {
+        filter: invert(1) hue-rotate(180deg);
+        border: solid 1.5px hsl(var(--clr-light-hsl) / 0.1);
+        outline-color: hsl(var(--clr-light-hsl));
+        box-shadow: unset;
+    } */
+}
+
+label {
+    font-weight: 600;
+    letter-spacing: -.0175em;
+    color: var(--clr-blue-desaturated-700);
+}
+
+label:has(svg) {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    column-gap: .25em;
+    /* margin-inline-start: -.9rem; */
+
+    svg {
+      width: 1.5rem;
+      height: 1.5rem;
+      color: var(--clr-blue-700);
+    }
+}
+
+
+
+/**/
+
+/* .panel > small {
+    display: block;
+    opacity: 75%;
+    text-wrap: balance;
+    line-height: 1.75;
+    font-weight: 600;
+} */

+ 39 - 0
contracts/generator/data/more-data/css/from-quill-editor-overrides.css

@@ -0,0 +1,39 @@
+/* :where(.ql-editor, .ql-editor ul, .ql-editor ol)
+ > *:where(:not(:first-child)) {
+    margin-block-start: 1.5rem;
+} */
+
+.ql-editor {
+    padding-block: 1.5rem;
+    padding-inline: 2rem;
+    padding-inline: clamp(.5rem, 2.5vw, 3rem);
+    padding-inline: clamp(20px, 5vw, 35px);
+
+    & > *:where(:not(:first-child)) {
+        margin-block-start: var(--flow-space, 1.5rem)
+    }
+
+    /* list items */
+    /* & *:where(ul, ol) > *:where(:not(:first-child)) {
+        margin-block-start: .25rem;
+    } */
+
+        & *:where(ul, ol) > *:where(:not(:first-child)) {
+        margin-block-start: .25rem;
+    }
+
+    & *:is(ol, ul) { 
+        padding: 0;
+    }
+
+    & > p + :is(ul, ol) {
+        margin-block-start: .25rem;
+    }
+
+    /* empty paragraphs have a smaller margin, unless they follow other empty paragraphs */
+    /* :not(p:has(br:first-child:last-child)) + p:has(br:first-child:last-child) {
+        background-color: red;
+        margin-top:-.5rem;
+    } */
+
+}

+ 272 - 0
contracts/generator/data/more-data/css/from-quill-editor.css

@@ -0,0 +1,272 @@
+/* Trimmed from: https://cdn.quilljs.com/1.3.6/quill.snow.css */
+
+.ql-editor p,
+.ql-editor ol,
+.ql-editor ul,
+.ql-editor pre,
+.ql-editor blockquote,
+.ql-editor h1,
+.ql-editor h2,
+.ql-editor h3,
+.ql-editor h4,
+.ql-editor h5,
+.ql-editor h6 {
+  counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
+}
+.ql-editor ol,
+.ql-editor ul {
+  padding-left: 1.5em;
+}
+.ql-editor ol > li,
+.ql-editor ul > li {
+  list-style-type: none;
+}
+.ql-editor ul > li::before {
+  content: "\2022";
+}
+/* .ql-editor ul[data-checked=true],
+.ql-editor ul[data-checked=false] {
+pointer-events: none;
+}
+.ql-editor ul[data-checked=true] > li *,
+.ql-editor ul[data-checked=false] > li * {
+pointer-events: all;
+}
+.ql-editor ul[data-checked=true] > li::before,
+.ql-editor ul[data-checked=false] > li::before {
+color: #777;
+cursor: pointer;
+pointer-events: all;
+}
+.ql-editor ul[data-checked=true] > li::before {
+content: "\2611";
+}
+.ql-editor ul[data-checked=false] > li::before {
+content: "\2610";
+} */
+.ql-editor li::before {
+  display: inline-block;
+  white-space: nowrap;
+  width: 1.2em;
+}
+.ql-editor li:not(.ql-direction-rtl)::before {
+  margin-left: -1.5em;
+  margin-right: 0.3em;
+  text-align: right;
+}
+.ql-editor li.ql-direction-rtl::before {
+  margin-left: 0.3em;
+  margin-right: -1.5em;
+}
+.ql-editor ol li:not(.ql-direction-rtl),
+.ql-editor ul li:not(.ql-direction-rtl) {
+  padding-left: 1.5em;
+}
+.ql-editor ol li.ql-direction-rtl,
+.ql-editor ul li.ql-direction-rtl {
+  padding-right: 1.5em;
+}
+.ql-editor ol li {
+  counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
+  counter-increment: list-0;
+}
+.ql-editor ol li:before {
+  content: counter(list-0, decimal) ". ";
+}
+
+.ql-editor ol li.ql-indent-1 {
+  counter-increment: list-1;
+}
+.ql-editor ol li.ql-indent-1:before {
+  content: counter(list-1, lower-alpha) ". ";
+}
+.ql-editor ol li.ql-indent-1 {
+  counter-reset: list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
+}
+.ql-editor ol li.ql-indent-2 {
+  counter-increment: list-2;
+}
+.ql-editor ol li.ql-indent-2:before {
+  content: counter(list-2, lower-roman) ". ";
+}
+.ql-editor ol li.ql-indent-2 {
+  counter-reset: list-3 list-4 list-5 list-6 list-7 list-8 list-9;
+}
+.ql-editor ol li.ql-indent-3 {
+  counter-increment: list-3;
+}
+.ql-editor ol li.ql-indent-3:before {
+  content: counter(list-3, decimal) ". ";
+}
+.ql-editor ol li.ql-indent-3 {
+  counter-reset: list-4 list-5 list-6 list-7 list-8 list-9;
+}
+.ql-editor ol li.ql-indent-4 {
+  counter-increment: list-4;
+}
+.ql-editor ol li.ql-indent-4:before {
+  content: counter(list-4, lower-alpha) ". ";
+}
+.ql-editor ol li.ql-indent-4 {
+  counter-reset: list-5 list-6 list-7 list-8 list-9;
+}
+.ql-editor ol li.ql-indent-5 {
+  counter-increment: list-5;
+}
+.ql-editor ol li.ql-indent-5:before {
+  content: counter(list-5, lower-roman) ". ";
+}
+.ql-editor ol li.ql-indent-5 {
+  counter-reset: list-6 list-7 list-8 list-9;
+}
+.ql-editor ol li.ql-indent-6 {
+  counter-increment: list-6;
+}
+.ql-editor ol li.ql-indent-6:before {
+  content: counter(list-6, decimal) ". ";
+}
+.ql-editor ol li.ql-indent-6 {
+  counter-reset: list-7 list-8 list-9;
+}
+.ql-editor ol li.ql-indent-7 {
+  counter-increment: list-7;
+}
+.ql-editor ol li.ql-indent-7:before {
+  content: counter(list-7, lower-alpha) ". ";
+}
+.ql-editor ol li.ql-indent-7 {
+  counter-reset: list-8 list-9;
+}
+.ql-editor ol li.ql-indent-8 {
+  counter-increment: list-8;
+}
+.ql-editor ol li.ql-indent-8:before {
+  content: counter(list-8, lower-roman) ". ";
+}
+.ql-editor ol li.ql-indent-8 {
+  counter-reset: list-9;
+}
+.ql-editor ol li.ql-indent-9 {
+  counter-increment: list-9;
+}
+.ql-editor ol li.ql-indent-9:before {
+  content: counter(list-9, decimal) ". ";
+}
+.ql-editor .ql-indent-1:not(.ql-direction-rtl) {
+  padding-left: 3em;
+}
+.ql-editor li.ql-indent-1:not(.ql-direction-rtl) {
+  padding-left: 4.5em;
+}
+.ql-editor .ql-indent-1.ql-direction-rtl.ql-align-right {
+  padding-right: 3em;
+}
+.ql-editor li.ql-indent-1.ql-direction-rtl.ql-align-right {
+  padding-right: 4.5em;
+}
+.ql-editor .ql-indent-2:not(.ql-direction-rtl) {
+  padding-left: 6em;
+}
+.ql-editor li.ql-indent-2:not(.ql-direction-rtl) {
+  padding-left: 7.5em;
+}
+.ql-editor .ql-indent-2.ql-direction-rtl.ql-align-right {
+  padding-right: 6em;
+}
+.ql-editor li.ql-indent-2.ql-direction-rtl.ql-align-right {
+  padding-right: 7.5em;
+}
+.ql-editor .ql-indent-3:not(.ql-direction-rtl) {
+  padding-left: 9em;
+}
+.ql-editor li.ql-indent-3:not(.ql-direction-rtl) {
+  padding-left: 10.5em;
+}
+.ql-editor .ql-indent-3.ql-direction-rtl.ql-align-right {
+  padding-right: 9em;
+}
+.ql-editor li.ql-indent-3.ql-direction-rtl.ql-align-right {
+  padding-right: 10.5em;
+}
+.ql-editor .ql-indent-4:not(.ql-direction-rtl) {
+  padding-left: 12em;
+}
+.ql-editor li.ql-indent-4:not(.ql-direction-rtl) {
+  padding-left: 13.5em;
+}
+.ql-editor .ql-indent-4.ql-direction-rtl.ql-align-right {
+  padding-right: 12em;
+}
+.ql-editor li.ql-indent-4.ql-direction-rtl.ql-align-right {
+  padding-right: 13.5em;
+}
+.ql-editor .ql-indent-5:not(.ql-direction-rtl) {
+  padding-left: 15em;
+}
+.ql-editor li.ql-indent-5:not(.ql-direction-rtl) {
+  padding-left: 16.5em;
+}
+.ql-editor .ql-indent-5.ql-direction-rtl.ql-align-right {
+  padding-right: 15em;
+}
+.ql-editor li.ql-indent-5.ql-direction-rtl.ql-align-right {
+  padding-right: 16.5em;
+}
+.ql-editor .ql-indent-6:not(.ql-direction-rtl) {
+  padding-left: 18em;
+}
+.ql-editor li.ql-indent-6:not(.ql-direction-rtl) {
+  padding-left: 19.5em;
+}
+.ql-editor .ql-indent-6.ql-direction-rtl.ql-align-right {
+  padding-right: 18em;
+}
+.ql-editor li.ql-indent-6.ql-direction-rtl.ql-align-right {
+  padding-right: 19.5em;
+}
+.ql-editor .ql-indent-7:not(.ql-direction-rtl) {
+  padding-left: 21em;
+}
+.ql-editor li.ql-indent-7:not(.ql-direction-rtl) {
+  padding-left: 22.5em;
+}
+.ql-editor .ql-indent-7.ql-direction-rtl.ql-align-right {
+  padding-right: 21em;
+}
+.ql-editor li.ql-indent-7.ql-direction-rtl.ql-align-right {
+  padding-right: 22.5em;
+}
+.ql-editor .ql-indent-8:not(.ql-direction-rtl) {
+  padding-left: 24em;
+}
+.ql-editor li.ql-indent-8:not(.ql-direction-rtl) {
+  padding-left: 25.5em;
+}
+.ql-editor .ql-indent-8.ql-direction-rtl.ql-align-right {
+  padding-right: 24em;
+}
+.ql-editor li.ql-indent-8.ql-direction-rtl.ql-align-right {
+  padding-right: 25.5em;
+}
+.ql-editor .ql-indent-9:not(.ql-direction-rtl) {
+  padding-left: 27em;
+}
+.ql-editor li.ql-indent-9:not(.ql-direction-rtl) {
+  padding-left: 28.5em;
+}
+.ql-editor .ql-indent-9.ql-direction-rtl.ql-align-right {
+  padding-right: 27em;
+}
+.ql-editor li.ql-indent-9.ql-direction-rtl.ql-align-right {
+  padding-right: 28.5em;
+}
+
+.ql-editor .ql-align-center {
+  text-align: center;
+}
+.ql-editor .ql-align-justify {
+  text-align: justify;
+}
+.ql-editor .ql-align-right {
+  text-align: right;
+}

+ 27 - 0
contracts/generator/data/more-data/css/main.css

@@ -0,0 +1,27 @@
+@layer reset, accessibility, animated-entrances, forms;
+@layer from-quill-editor, from-quill-editor-overrides;
+@layer utility;
+@layer signatures, buttons;
+@layer modal;
+@layer contract-typography;
+
+@import url(reset.css) layer(reset);
+@import url(fonts.css) layer(fonts); /**/
+@import url(colors.css) layer(colors);
+@import url(from-quill-editor.css) layer(from-quill-editor);
+@import url(from-quill-editor-overrides.css) layer(from-quill-editor-overrides);
+@import url(accessibility.css) layer(accessibility);
+
+@import url(buttons.css) layer(buttons);
+@import url(utility.css) layer(utility);
+@import url(forms.css) layer(forms);
+@import url(contract-typography.css) layer(contract-typography);
+@import url(signatures.css) layer(signatures);
+
+@import url(modal.css) layer(modal);
+
+@import url(animated-entrances.css) layer(animated-entrances);
+
+/* @import url(custom.css) layer(custom) */
+
+@import url(panels.css);

+ 83 - 0
contracts/generator/data/more-data/css/modal.css

@@ -0,0 +1,83 @@
+.modal {
+    position: fixed;
+    border-radius: 1.5rem;
+    margin-block-start: auto;
+    z-index: 999;
+    padding: 0;
+    border: solid 3px hsl(var(--shadow-color));
+    max-width: calc(100% - 1rem);
+    box-shadow: var(--shadow-6);
+    /* https://github.com/argyleink/open-props/blob/main/src/props.shadows.css */
+
+    --shadow-color: 200 3% 15%;
+    --shadow-strength: 5%;
+    --shadow-6: 
+        0 -1px 2px 0 hsl(var(--shadow-color) / calc(var(--shadow-strength) + 2%)),
+        0 3px 2px -2px hsl(var(--shadow-color) / calc(var(--shadow-strength) + 3%)),
+        0 7px 5px -2px hsl(var(--shadow-color) / calc(var(--shadow-strength) + 3%)),
+        0 12px 10px -2px hsl(var(--shadow-color) / calc(var(--shadow-strength) + 4%)),
+        0 22px 18px -2px hsl(var(--shadow-color) / calc(var(--shadow-strength) + 5%)),
+        0 41px 33px -2px hsl(var(--shadow-color) / calc(var(--shadow-strength) + 6%)),
+        0 100px 80px -2px hsl(var(--shadow-color) / calc(var(--shadow-strength) + 7%));
+}
+
+.modal::backdrop {
+    background-image: linear-gradient(132deg, 
+        hsl(190deg 14% 14% / 60%), 
+        hsl(210deg 15% 16% / 60%));
+    cursor: pointer;
+    -webkit-backdrop-filter: blur(4px);
+    backdrop-filter: blur(4px);
+}
+
+.close-button {
+    border-radius: 50%;
+    height: 32px;
+    max-width: 32px;
+    margin: 1rem 0 0 0;
+    padding: 0;
+    position: relative;
+    vertical-align: top;
+    width: 32px;
+
+    &:active {
+        background-color: #000;
+        transition: none;
+    }
+
+    /* letter X made out of 2 pseudo-elements */
+    &::before { height: 2px; width: 50%; }
+    &::after  { height: 50%; width: 2px; }
+    &::before,
+    &::after {
+        background-color: currentColor;
+        content: "";
+        display: block;
+        left: 50%;
+        position: absolute;
+        top: 50%;
+        transform: translateX(-50%) translateY(-50%) rotate(45deg);
+        transform-origin: center center;
+    }
+
+}
+
+
+/*  */
+
+.qr-code-container {
+    display: grid;
+    justify-items: end;
+    gap: 1rem;
+    margin-block-end: 3.5rem;
+    margin-inline: 1rem;
+}
+
+#qr-code,
+#generator-qr-code {
+    width: min(500px, 90vw);
+    max-width: 90%;
+    margin: auto;
+    display: block;
+    image-rendering: pixelated;
+}

+ 159 - 0
contracts/generator/data/more-data/css/panels.css

@@ -0,0 +1,159 @@
+details {
+    --clr-500: var(--clr-blue-500);
+    --clr-600: var(--clr-blue-600);
+    --clr-700: var(--clr-blue-700);
+    --clr-500-desaturated: var(--clr-blue-desaturated-500);
+    --clr-600-desaturated: var(--clr-blue-desaturated-600);
+    --clr-700-desaturated: var(--clr-blue-desaturated-700);
+
+	max-height: 1.5em;
+    overflow: hidden;
+    transition: all .4s ease-in-out;
+
+    margin-inline-start: -0.75rem;
+    margin-inline-start: -0.81rem;
+    
+    @media (width < 535px) {
+        & > *:not(summary) {
+            margin-inline-start: 0.75rem;
+            /* margin-inline-start: 0.81rem; */
+        }
+    }
+    & details {
+        margin-inline-start: 0;
+    }
+
+    &[open] {
+        max-height: 90vh;
+        /* max-height: 300vh; */
+        /* overflow: auto; */
+        summary { padding-block-end: .5em; }
+    }
+    /* if the repeater items don't fit the width */
+    @media (width > 360px) {
+        &:has(.repeater-item:nth-child(4)) {
+            max-height: unset;
+        }
+    }
+    @media (width <= 360px) {
+        &:has(.repeater-item:nth-child(2)) {
+            max-height: unset;
+        }
+    }
+
+    /* color variations */
+    &.danger {
+        --clr-primary-hsl: var(--clr-danger-hsl);
+        --clr-500: var(--clr-red-500);
+        --clr-600: var(--clr-red-600);
+        --clr-700: var(--clr-red-700);
+        --clr-500-desaturated: var(--clr-red-desaturated-500);
+        --clr-600-desaturated: var(--clr-red-desaturated-600);
+        --clr-700-desaturated: var(--clr-red-desaturated-700);
+    }
+    &.success {
+        --clr-primary-hsl: var(--clr-success-hsl);
+        --clr-500: var(--clr-green-500);
+        --clr-600: var(--clr-green-600);
+        --clr-700: var(--clr-green-700);
+        --clr-500-desaturated: var(--clr-green-desaturated-500);
+        --clr-600-desaturated: var(--clr-green-desaturated-600);
+        --clr-700-desaturated: var(--clr-green-desaturated-700);
+    }
+    &.warning {
+        --clr-primary-hsl: var(--clr-warning-hsl);
+        --clr-500: var(--clr-brown-500);
+        --clr-600: var(--clr-brown-600);
+        --clr-700: var(--clr-brown-700);
+        --clr-500-desaturated: var(--clr-brown-desaturated-500);
+        --clr-600-desaturated: var(--clr-brown-desaturated-600);
+        --clr-700-desaturated: var(--clr-brown-desaturated-700);
+    }
+    
+    /* button */
+    & > summary {
+        font-size: 16px;
+        line-height: 26px;
+        cursor: pointer;
+        transition: 
+            outline .4s cubic-bezier(0.22, 1, 0.36, 1), 
+            padding .2s ease-in;
+
+        color: var(--clr-500);
+        color: var(--clr-600);
+        font-family: inherit;
+        font-weight: bold;
+        letter-spacing: -.025em;
+        max-width: min-content;
+        user-select: auto;
+        white-space: nowrap;
+        
+        &:hover {
+            background: none;
+            color: var(--clr-700);
+        }
+
+        &:focus-visible {
+            outline: none;
+            text-decoration: underline;
+            /* text-underline-offset: 0.2em; */
+            text-underline-position: under;
+            text-decoration-thickness: 2;
+            color: #000;
+            border-radius: 8px;
+        }
+        &:focus-visible::marker {
+            color: inherit;
+        }
+
+        &::marker {
+            color: var(--clr-600-desaturated);
+            color: hsl(var(--clr-primary-hsl)/.8);
+        }
+        &:hover::marker {
+            color: inherit;
+        }
+
+        &:active {
+            transform: scale(.975);
+            transition: transform .3s cubic-bezier(0.22, 1, 0.36, 1),
+                        outline .4s cubic-bezier(0.22, 1, 0.36, 1),
+                        background-color .2s linear;
+        }
+    }
+    
+    /* contents */
+    /* & > *:nth-child(2):last-child { */
+    & > .panel {
+        background: hsl(var(--clr-primary-hsl)/.1);
+        border-radius: 8px;
+        border: solid 1.5px hsl(var(--clr-primary-hsl)/.075);
+        gap: 1rem;
+        /* margin-block: 1rem 2rem; */
+        padding: 0.75rem 1rem 1rem;
+        position: relative;
+
+        justify-content: flex-start;
+
+        & label {
+            color: var(--clr-700-desaturated);
+            font-weight: 600;
+            letter-spacing: -.0175em;
+        }
+
+        & > p:last-child:not([class]) {
+            color: var(--clr-500-desaturated);
+        }
+    }
+}
+
+.panel > small {
+    display: block;
+    opacity: 75%;
+    text-wrap: balance;
+    line-height: 1.75;
+    font-weight: 600;
+    display: grid;
+    gap: 1em;
+    /* outline: solid 1px red; */
+}

+ 41 - 0
contracts/generator/data/more-data/css/reset.css

@@ -0,0 +1,41 @@
+/* html {
+  scroll-behavior: smooth;
+} */
+
+@media print {
+  .noprint {
+    display: none !important;
+  }
+}
+
+*, *::before, *::after {
+  box-sizing: border-box;
+}
+
+body,
+h1, h2, h3, h4, h5,
+p, figure, picture {
+  margin: 0;
+}
+
+h1, h2, h3, h4, h5, h6 {
+  font-weight: 400;
+}
+
+img, picture {
+  max-width: 100%;
+  display: block;
+  height: auto;
+}
+
+input,
+button,
+textarea,
+select {
+  font: inherit;
+}
+
+/* .icon svg {
+ max-height: 2rem !important;
+} */
+

+ 182 - 0
contracts/generator/data/more-data/css/signatures.css

@@ -0,0 +1,182 @@
+.compiled-signatures {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 2rem;
+    justify-content: space-around;
+    /* padding-block-end: 0.5rem; */
+    /* padding-inline: clamp(0rem,2vw,1rem); */
+}
+
+.compiled-signature {
+    display: grid;
+    /* grid-template-columns: min-content 1fr; */
+    font-size: 0.75em;
+    align-items: start;
+    
+    /* min-width: 50%; */
+    max-width: min(50%, 330px);
+    min-width: 300px;
+
+    max-width: clamp(50% - 2rem, 348px, 100%);
+    min-width: 200px;
+}
+
+.compiled-signature img {
+    background: #fff;
+    border: 1px solid rgba(3,33,48,.25);
+    margin-block: 0.15rem;
+}
+
+#dev_signature, 
+#hk {
+    display: block;
+    max-width: min(333px, 100%);
+    max-width: min(370px,100%);
+}
+
+#dev_signature[src="null"] {
+    display: none;
+}
+
+.date-ip {
+    font-size: 1.2em;
+    line-height: 1.2em;
+    letter-spacing: .025em;
+    font-family: "Open Sans Condensed", sans-serif;
+    font-weight: 400;
+
+    font-family: "Open Sans", sans-serif;
+    font-variation-settings: "wdth" 75;
+    /* font-weight: 600; */
+}
+
+#ui-unsigned {
+    margin: 0;
+    margin-block-start: var(--flow-space,1.5rem);
+}
+
+#ui-signed {
+    clear: both;
+}
+
+#content > *:not(#ui-unsigned, #dev_signature) {
+  transition: opacity .3s ease-out;
+}
+
+/***/
+
+#signature-container {
+  display: grid;
+  place-items: start;
+  gap: 1.5rem;
+  /* padding: clamp(0.25rem, 1.5rem, 2rem); */
+
+  @media (min-width: 40rem) {
+    place-items: end;
+  }
+}
+
+#canvas-container {
+    aspect-ratio: 188/58.66;
+    background: #fff;
+    isolation: isolate;
+    position: relative;
+    user-select: none;
+    width: 100%;
+    max-width: 100%;
+    transition: 
+      max-width .4s cubic-bezier(0.22, 1, 0.36, 1), 
+      margin .6s ease-in-out;
+}
+
+/* Horizontal line in signature */
+#canvas-container::before {
+    content: "";
+    display: block;
+    position: absolute;
+    inset: 70% 7.5% 0 7.5%;
+    height: 0;
+    border-bottom: solid 2px #61594F;
+    z-index: -1;
+    opacity: .95;
+    pointer-events: none;
+}
+
+@media (max-width:40em) {
+  #canvas-container {
+    aspect-ratio: 200/80;
+  }
+  /* #canvas-container::before {
+    inset: 80% 7.5% 0 7.5%;
+  } */
+}
+
+#signature-pad,
+#generator-signature-pad {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    border: dashed 2px hsl(200 90% 10%/0.75);
+    box-shadow: 0 0 5px 1px #ddd inset;
+}
+
+#signature-controls {
+    display: flex;
+    flex-direction: row;
+    gap: 1.5rem;
+    align-items: flex-start;
+    justify-content: center;
+    width: 100%;
+}
+
+/***/
+
+.loading-signed {
+  /* display: flex; */
+  /* display: none; */
+  align-items: center;
+  justify-content: center;
+
+  @media (min-width: 40rem) {
+    justify-content: center;
+  }
+}
+.loading-signed:not(.hidden) {
+  display: flex;
+}
+
+.to-go {
+    opacity: 1;
+    transform: none;
+    transition: 
+      all .2s cubic-bezier(0.26, 0.53, 0.74, 1.48),
+      scale 1s ease-out;
+}
+
+
+/* Client submitted signature */
+
+.to-go.gone {
+    opacity: 0;
+    transform: translate(0, -20px);
+}
+
+#canvas-container.just-signed {
+    max-width: 333px;
+
+    @media (min-width: 40rem) {
+      margin-top: -100%;
+      margin-top: calc(-330px - 2rem);
+    }
+
+    & #signature-pad {
+      border: 1px dashed rgba(3,33,48,.25);
+      box-shadow: inset 0 0 2px 1px hsl(0deg 0% 87% / 25%);
+    }
+
+    &::before {
+      opacity: 0;
+    }
+}

+ 69 - 0
contracts/generator/data/more-data/css/utility.css

@@ -0,0 +1,69 @@
+.flexi {
+    display: flex;
+    gap: clamp(20px, 5vw, 35px);
+    flex-wrap: wrap;
+    /* justify-content: space-evenly; */
+    /* justify-content: center; */
+    justify-content: flex-start;
+    align-items: center;
+}
+
+/* .flexi:not(:has(>*:nth-child(3))) {
+    justify-content: center;
+}
+
+@media (min-width: 40rem) {
+    .flexi:has(>*:nth-child(3)) {
+        justify-content: center;
+    }
+} */
+
+
+
+.flow > *:where(:not(:first-child)) {
+    margin-block-start: var(--flow-space, 1em);
+}
+
+.hidden { 
+    display:none !important; 
+}
+
+.hide-small {
+    @media (max-width: 30em) {
+        display: none !important;
+    }
+}
+
+.hide-medium {
+    @media (max-width: 50em) {
+        display: none !important;
+    }
+}
+
+.sr-only {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    padding: 0;
+    margin: -1px;
+    overflow: hidden;
+    clip: rect(0, 0, 0, 0);
+    white-space: nowrap;
+    border: 0;
+}
+
+.border-top {
+    /* border-top: solid 1px --clr-blue-desaturated-500; */
+    border-top: solid 1px hsl(var(--clr-primary-hsl)/.25);
+    padding-block-start: 2.5rem; 
+    margin-block-start: 2rem;
+    margin-block-start: 6rem;
+}
+
+.margin-top {
+    margin-block-start: 3rem;
+}
+
+.d-block {
+    display: blok;
+}

+ 13 - 0
contracts/generator/data/more-data/html-partials/ui-signed.html.xml

@@ -0,0 +1,13 @@
+<div class="noprint margin-top invert-colors flexi | animate slide delay-3"
+     style="justify-content: center;">
+    <button class="icon-button button" type="button" onclick="printContract()">
+        <span>Print</span>
+        <span class="icon">
+            <svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 1024 1024" height="1em"
+                width="1em" xmlns="http://www.w3.org/2000/svg">
+                <path d="M820 436h-40c-4.4 0-8 3.6-8 8v40c0 4.4 3.6 8 8 8h40c4.4 0 8-3.6 8-8v-40c0-4.4-3.6-8-8-8zm32-104H732V120c0-4.4-3.6-8-8-8H300c-4.4 0-8 3.6-8 8v212H172c-44.2 0-80 35.8-80 80v328c0 17.7 14.3 32 32 32h168v132c0 4.4 3.6 8 8 8h424c4.4 0 8-3.6 8-8V772h168c17.7 0 32-14.3 32-32V412c0-44.2-35.8-80-80-80zM360 180h304v152H360V180zm304 664H360V568h304v276zm200-140H732V500H292v204H160V412c0-6.6 5.4-12 12-12h680c6.6 0 12 5.4 12 12v292z">
+                </path>
+            </svg>
+        </span>
+    </button>
+</div>

+ 62 - 0
contracts/generator/data/more-data/html-partials/ui-unsigned.html.xml

@@ -0,0 +1,62 @@
+<form method="post" class="noprint" id="signature_form">
+    <div id="signature-container">
+        <div id="canvas-container">
+            <canvas id="signature-pad" class="signature-pad" width="188" height="58.66"></canvas>
+        </div>
+    </div>
+
+    <div class="animate slide">
+        <div class="no-print margin-top invert-colors flexi | to-go | animate slide">
+            <button id="reset" title="Clear Signature" type="button" class="icon-button button warning">
+                <span class="hide-medium">Clear</span>
+                <span class="icon">
+                    <svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="1em"
+                        width="1em" xmlns="http://www.w3.org/2000/svg">
+                            <path d="M20.454,19.028h-7.01l6.62-6.63a2.935,2.935,0,0,0,.87-2.09,2.844,2.844,0,0,0-.87-2.05l-3.42-3.44a2.93,2.93,0,0,0-4.13.01L3.934,13.4a2.946,2.946,0,0,0,0,4.14l1.48,1.49H3.554a.5.5,0,0,0,0,1h16.9A.5.5,0,0,0,20.454,19.028Zm-7.24-13.5a1.956,1.956,0,0,1,2.73,0l3.42,3.44a1.868,1.868,0,0,1,.57,1.35,1.93,1.93,0,0,1-.57,1.37l-5.64,5.64-6.15-6.16Zm-1.19,13.5h-5.2l-2.18-2.2a1.931,1.931,0,0,1,0-2.72l2.23-2.23,6.15,6.15Z"></path>
+                    </svg>
+                </span>
+            </button>
+
+            <button id="show-modal-qr" type="button" class="open-button | icon-button button">
+                <span class="hide-small">Sign on mobile</span>
+                <span class="icon">
+                    <svg stroke="currentColor" fill="currentColor" stroke-width="0" version="1.1" viewBox="0 0 16 16" height="1em"
+                        width="1em" xmlns="http://www.w3.org/2000/svg">
+                        <path d="M5 1h-4v4h4v-4zM6 0v0 6h-6v-6h6zM2 2h2v2h-2zM15 1h-4v4h4v-4zM16 0v0 6h-6v-6h6zM12 2h2v2h-2zM5 11h-4v4h4v-4zM6 10v0 6h-6v-6h6zM2 12h2v2h-2zM7 0h1v1h-1zM8 1h1v1h-1zM7 2h1v1h-1zM8 3h1v1h-1zM7 4h1v1h-1zM8 5h1v1h-1zM7 6h1v1h-1zM7 8h1v1h-1zM8 9h1v1h-1zM7 10h1v1h-1zM8 11h1v1h-1zM7 12h1v1h-1zM8 13h1v1h-1zM7 14h1v1h-1zM8 15h1v1h-1zM15 8h1v1h-1zM1 8h1v1h-1zM2 7h1v1h-1zM0 7h1v1h-1zM4 7h1v1h-1zM5 8h1v1h-1zM6 7h1v1h-1zM9 8h1v1h-1zM10 7h1v1h-1zM11 8h1v1h-1zM12 7h1v1h-1zM13 8h1v1h-1zM14 7h1v1h-1zM15 10h1v1h-1zM9 10h1v1h-1zM10 9h1v1h-1zM11 10h1v1h-1zM13 10h1v1h-1zM14 9h1v1h-1zM15 12h1v1h-1zM9 12h1v1h-1zM10 11h1v1h-1zM12 11h1v1h-1zM13 12h1v1h-1zM14 11h1v1h-1zM15 14h1v1h-1zM10 13h1v1h-1zM11 14h1v1h-1zM12 13h1v1h-1zM13 14h1v1h-1zM10 15h1v1h-1zM12 15h1v1h-1zM14 15h1v1h-1z">
+                        </path>
+                    </svg>
+                </span>
+            </button>
+
+            <button id="submit-btn" disabled 
+                style="margin-inline-start: auto;"
+                type="submit" class="icon-button button success">
+                <span>Sign</span>
+                <span class="icon">
+                    <svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="1em"
+                        width="1em" xmlns="http://www.w3.org/2000/svg">
+                        <path fill="none" stroke-width="2" d="M2,12 L22,12 M13,3 L22,12 L13,21"></path>
+                    </svg>
+                </span>
+            </button>
+        </div>
+    </div>
+
+    <div class="flow" style="max-width: 330px; margin-inline-start: auto;">
+        <h2 class="margin-top loading-signed hidden | animate slide" style="color: var(--clr-green-500); font-weight: 700;">Saving contract…</h2>
+        <small class="loading-signed hidden | animate slide delay-16"
+            style="font-family: 'Open Sans'; font-weight: 600; color: var(--clr-blue-700);">
+            This shouldn't take more than a minute.
+        </small>
+    </div>
+
+    <input type="hidden" id="client_signature" name="client_signature" />
+</form>
+
+
+<dialog class="modal flow" id="modal-qr">
+    <div class="qr-code-container">
+        <button id="close-modal-qr" class="close-button button" aria-label="close" type="button"></button>
+        <canvas id="qr-code"></canvas>
+    </div>
+</dialog>

+ 141 - 0
contracts/generator/data/more-data/php-partials/contract_footer.phpsrc

@@ -0,0 +1,141 @@
+<?php die(); /* no direct access */
+
+$DEV_SIGNATURE = '<img id="dev_signature" src="' . $DEV_SIGNATURE . '" >';
+
+$CLIENT_SIGNATURE = isset($_POST['client_signature']) ? $_POST['client_signature'] : null;
+if ($CLIENT_SIGNATURE && substr($CLIENT_SIGNATURE, 0, 22) === 'data:image/png;base64,') {
+  $CLIENT_SIGNATURE = '<img id="hk" src="' . htmlspecialchars($CLIENT_SIGNATURE) . '" >';
+}
+
+/**
+The HTML code (and some PHP) is kept in PHP variables like $CONTRACT_HTML, $FOOTER, $CONTRACT_SIGNED_PHP, and $CLIENT_DATE_IP_COMPILED.
+**/
+
+function headerWithTitle($title) {
+  return '<!DOCTYPE html>
+  <html>
+  <head>
+    <meta charset="UTF-8">
+    <title>' . $title . '</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
+    <meta name="robots" content="noindex">
+    <link rel="preconnect" href="https://cdn.skypack.dev">
+    <link rel="preconnect" href="https://fonts.gstatic.com">
+    <link rel="preconnect" href="https://fonts.googleapis.com">
+    <style></style>
+  </head>
+  <body>
+  <div id="content" class="ql-editor">
+  ';
+}
+
+if($CLIENT_SIGNATURE==null) {
+  /** 
+  ⌛ Waiting for Client to sign: include signature elements and javascript 
+  **/
+
+  $HEADER = headerWithTitle('Unsigned Contract');
+
+  $FOOTER = '
+  <div id="ui-unsigned"></div>
+  </div> <!-- #content -->
+  <script id="contract_script_unsigned" type="module"></script>
+  <script id="qr_code_script" type="module"></script>
+  </body>
+  </html>';
+  
+  if ( $selfDelete && file_exists($htmlName) ) {
+    header('Location: '.$htmlName.'#hk');
+    die();
+  }
+
+  echo $HEADER;
+  echo $CONTRACT_HTML;
+  echo $DEV_SIGNATURE;
+  eval (' ?>'. $FOOTER .'<?php '); // php variables can be used inside
+}
+else {
+  /** 
+  ✅ Contract was just signed: put $CLIENT_SIGNATURE and the other parts in the .html file 
+  **/
+
+  $HEADER = headerWithTitle('Signed Contract');
+
+  $DEV_DATE_IP = '
+    <div class="date-ip">
+      <strong>Signed on:</strong> ' . $devTimestamp . '
+      <br><strong>IP address:</strong> '  . $devIP . ' <br>
+    </div>';
+  $DEV_SIGNATURE .= $DEV_DATE_IP;
+
+  /**
+  $CLIENT_DATE_IP_PHP is a string of php code,
+  that gets compiled below, in $CLIENT_DATE_IP_COMPILED
+  **/
+ 
+  $CLIENT_DATE_IP_PHP = $CONTRACT_SIGNED_PHP. '
+    <div id="date-ip" class="date-ip">
+      <strong>Signed on:</strong> <?php echo get_client_date($devTimeOffset); ?>
+      <br><strong>IP address:</strong> <?php echo get_client_ip_env(); ?><br>
+    </div>
+  ';
+
+  /** 
+  $CLIENT_DATE_IP_COMPILED executes the php code above
+  **/
+  ob_start(); // https://cgd.io/2008/how-to-execute-php-code-in-a-php-string/
+  eval($CLIENT_DATE_IP_PHP);
+  $CLIENT_DATE_IP_COMPILED = ob_get_contents();
+  ob_end_clean();
+
+  $CLIENT_SIGNATURE .= $CLIENT_DATE_IP_COMPILED;
+
+  // Add names above signatures
+  $DEV_SIGNATURE = '<strong>'.$devName.'</strong>' . $DEV_SIGNATURE;
+  $CLIENT_SIGNATURE = '<strong>'.$clientName.'</strong>' . $CLIENT_SIGNATURE;
+
+  $FOOTER = '
+    <div class="compiled-signatures">
+      <div class="compiled-signature">'.$DEV_SIGNATURE. '</div><!--.compiled-signature-->
+      <div class="compiled-signature">'.$CLIENT_SIGNATURE.'</div><!--.compiled-signature-->
+    </div><!--.compiled-signatures-->
+    <div id="ui-signed"></div>
+  </div> <!--#content-->
+  <script id="contract_script_signed"></script>
+  </body>
+  </html>';
+
+  $output = $HEADER . $CONTRACT_HTML . $FOOTER;
+  file_put_contents($htmlName, $output);
+
+  /** 
+  ✉ Email client & dev
+  **/
+  sendEmails($clientEmail, $devEmail);
+
+
+  /** 
+  ➡ Delete php, redirect to html
+  **/
+  if ($selfDelete) unlink(__FILE__);
+  header('Location: '.$htmlName.'#hk');
+  die();
+}
+
+
+// Function to email notifications; gets called when Client signs
+function sendEmails($clientEmail, $devEmail)
+{
+  if ($clientEmail) {
+    $headers = "From: " . $devEmail . "\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=ISO-8859-1\r\n";
+    $msg = 'The contract was signed. You can <a href="' . getHtmlUrl() . '">view or download this contract from here</a>.';
+    mail($clientEmail, 'Contract signed', $msg, $headers);
+  }
+  if ($devEmail) {
+    $headers = "From: " . $clientEmail . "\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=ISO-8859-1\r\n";
+    $msg = '<p>A new contract was signed. You can <a href="' . getHtmlUrl() . '">view or download this contract from here</a>.</p>';
+    $msg .= 'The contract was signed by: ' . $clientEmail;
+    mail($devEmail, 'Contract signed!', $msg, $headers);
+  }
+}
+?>

+ 103 - 0
contracts/generator/data/more-data/php-partials/contract_header.phpsrc

@@ -0,0 +1,103 @@
+<?php /* ##########################
+[client_email]
+[dev_email]
+[dev_signature]
+[dev_ip]
+[dev_timestamp]
+[dev_timestamp_offset]
+[dev_name]
+[client_name]
+###################################
+
+1. The 2nd and 3rd lines above are the emails read by this PHP script from itself (used below as $lines[1] and $lines[2] respectively). 
+
+2. The 4th line ($lines[3] below) is the data for $DEV_SIGNATURE.
+
+3. The $CLIENT_SIGNATURE is received by this script from itself when second person signs the contract.
+*/
+
+$phpName  = basename($_SERVER['PHP_SELF']) ? basename($_SERVER['PHP_SELF']) : 'index.php';
+$fileName = substr($phpName , 0, -4);
+$htmlName = $fileName.'.html';
+
+// If the filename is (or starts with) "test" or "demo" the PHP file won't delete itself, nor will it redirect to the HTML contract (when one exists)
+if ( substr($fileName,0,4) == 'test' || substr($fileName,0,4) == 'demo' ) { 
+  $selfDelete = 0; 
+}
+else { 
+  $selfDelete = 1; 
+}
+
+$lines = file(__FILE__);
+
+$clientEmail   = format($lines[1]);
+$devEmail      = format($lines[2]);
+$DEV_SIGNATURE = format($lines[3]);
+$devIP         = format($lines[4]);
+$devTimestamp  = format($lines[5]);
+$devTimeOffset = format($lines[6]);
+$devName       = format($lines[7]);
+$clientName    = format($lines[8]);
+
+// Trim and ignore [placeholders]
+function format($text) {
+  $text = trim($text);
+  $firstChar = substr($text, 0, 1);
+  $lastChar = substr($text, -1, 1);
+  if ($firstChar == '[' && $lastChar == ']')
+    return '';
+  else
+    return $text;
+}
+
+// Gets the current file URL and replaces the .php extension with .html
+function getHtmlUrl() {
+  $url  = @( $_SERVER["HTTPS"] != 'on' ) ? 'http://'.$_SERVER["SERVER_NAME"] :  'https://'.$_SERVER["SERVER_NAME"];
+  $url .= ( $_SERVER["SERVER_PORT"] !== 80 ) ? ":".$_SERVER["SERVER_PORT"] : "";
+  $url .= $_SERVER["REQUEST_URI"];
+  $url = substr($url,0,-4) . '.html';
+  return $url;
+}
+
+/**
+The HTML code (and some PHP) is kept in PHP variables like $CONTRACT_HTML, $FOOTER, $CONTRACT_SIGNED_PHP, and $CLIENT_DATE_IP_COMPILED.
+**/
+
+// This gets executed when Client signs; 
+// the functions are used in $CLIENT_DATE_IP_PHP
+$CONTRACT_SIGNED_PHP = '
+  $phpName  = basename($_SERVER["PHP_SELF"]) ? basename($_SERVER["PHP_SELF"]) : "index.php";
+  $fileName = substr($phpName , 0, -4);
+  $htmlName = $fileName.".html";
+  $pdfName = $fileName.".pdf";
+
+  // Function to get the client IP address
+  function get_client_ip_env() {
+    $ipaddress = "";
+    if (getenv("HTTP_CLIENT_IP"))
+      $ipaddress = getenv("HTTP_CLIENT_IP");
+    else if(getenv("HTTP_X_FORWARDED_FOR"))
+      $ipaddress = getenv("HTTP_X_FORWARDED_FOR");
+    else if(getenv("HTTP_X_FORWARDED"))
+      $ipaddress = getenv("HTTP_X_FORWARDED");
+    else if(getenv("HTTP_FORWARDED_FOR"))
+      $ipaddress = getenv("HTTP_FORWARDED_FOR");
+    else if(getenv("HTTP_FORWARDED"))
+      $ipaddress = getenv("HTTP_FORWARDED");
+    else if(getenv("REMOTE_ADDR"))
+      $ipaddress = getenv("REMOTE_ADDR");
+    else
+      $ipaddress = "UNKNOWN";
+    return $ipaddress;
+  } 
+  // Function to get the client date converted to the same GMT as the dev date
+  function get_client_date($receivedOffset) {
+      //$receivedOffset comes negative and in minutes, eg: -120 for GMT+2
+      $offset = -1 * $receivedOffset / 60; // GMT offset
+      $is_DST = FALSE; // observing daylight savings?
+      $timezone_name = timezone_name_from_abbr("", $offset * 3600, $is_DST);
+      date_default_timezone_set($timezone_name);
+
+      return date("F j, Y") ." at ". date("g:i:s A") ." GMT" . sprintf("%+d", $offset);
+  }
+  ?>';

+ 3 - 0
contracts/generator/data/more-data/scripts/contract_script_signed.js

@@ -0,0 +1,3 @@
+function printContract() {
+    window.print();
+}

+ 91 - 0
contracts/generator/data/more-data/scripts/contract_script_unsigned.js

@@ -0,0 +1,91 @@
+import SignaturePad from "https://cdn.skypack.dev/pin/signature_pad@v4.1.3-nYxPKR50YjQN4V2vbxta/mode=imports,min/optimized/signature_pad.js"
+// 📙 Package Documentation: https://www.skypack.dev/view/signature_pad
+
+signature("#signature-pad")
+
+function signature(selector) {
+
+    if (!document.querySelector(selector)) return
+
+    const canvas = document.querySelector(selector)
+
+    // https://github.com/szimek/signature_pad#options
+    const clientSignaturePad = new SignaturePad(canvas, {
+        penColor: "hsl(200, 100%, 30%)",
+        minDistance: 2,
+    })
+
+    resizeCanvas()
+
+    if (localStorage.getItem("client_signature")) {
+        document.querySelector("#submit-btn").disabled = false
+        // document.querySelector("#reset").disabled = false
+    }
+
+
+    // event listeners
+
+    // save signature to localStorage on change
+    clientSignaturePad.addEventListener("afterUpdateStroke", () => {
+        let data = clientSignaturePad.toDataURL("image/png")
+
+        document.querySelector("#client_signature").value = data
+        localStorage.setItem("client_signature", data)
+
+        // ! probably remove these:
+        document.querySelector("#submit-btn").disabled = false
+        // document.querySelector("#reset").disabled = false
+    })
+
+    // button to reset signature
+    document.querySelector("#reset")?.addEventListener("click", (e) => {
+        clientSignaturePad.clear()
+        localStorage.removeItem("client_signature")
+        document.querySelector("#client_signature").value = null
+        document.querySelector("#submit-btn").disabled = true
+        // document.querySelector("#reset").disabled = true
+    })
+
+    // form submit
+    document.querySelector("#signature_form").addEventListener("submit", (e) => {
+        // e.preventDefault();
+        e.target.querySelector(".to-go").classList.add("gone")
+
+        e.target.querySelectorAll(".loading-signed").forEach((el) => {
+            el.classList.remove("hidden")
+        })
+
+        e.target.querySelector("#canvas-container").classList.add("just-signed")
+
+        e.target.querySelector(".to-go").addEventListener('transitionend', (e) => {
+            e.target.classList.add("hidden")
+        })
+
+        let otherElements = document.querySelectorAll("#content > *:not(#ui-unsigned, #dev_signature)")
+        otherElements.forEach(element => {
+            // element.style.cssText = `opacity: .5;`
+            element.style.opacity = "0.5"
+        })
+
+    })
+    
+    window.onresize = resizeCanvas
+
+
+    // needed for retina displays
+    function resizeCanvas() {
+        const ratio = Math.max(window.devicePixelRatio || 1, 1)
+        canvas.width = canvas.offsetWidth * ratio
+        canvas.height = canvas.offsetHeight * ratio
+        canvas.getContext("2d").scale(ratio, ratio)
+
+        let data = localStorage.getItem("client_signature");
+        if (data) {
+            // console.log(data)
+            clientSignaturePad.fromDataURL(data)
+            // disableResetButtonIfSignatureIsEmpty(data)
+            document.querySelector("#client_signature").value = data
+        }
+    }
+
+}

+ 47 - 0
contracts/generator/data/more-data/scripts/qr-code.js

@@ -0,0 +1,47 @@
+import QRious from "https://cdn.skypack.dev/pin/qrious@v4.0.2-vbPhILY2CQRjQ1N29BGh/mode=imports,min/optimized/qrious.js";
+// 📙 Package Documentation:  https://www.skypack.dev/view/qrious
+
+qrCode("#qr-code")
+
+export default function qrCode(selector) {
+    
+    if (!document.querySelector(selector)) return
+    
+    const canvas = document.querySelector(selector)
+
+    let qr = new QRious({
+        element: canvas,
+        value: window.location.href,
+        foreground: "hsl(200, 30%, 20%)",
+        padding: 0,
+        size: 500,
+    })
+
+    // event listeners
+
+    const modal = document.querySelector("#modal-qr")
+    const openModal = document.querySelector("#show-modal-qr")
+    const closeModal = document.querySelector("#close-modal-qr")
+
+    openModal?.addEventListener("click", (e) => {
+        if (modal?.open === false)
+            modal.showModal()
+    })
+
+    closeModal?.addEventListener("click", (e) => {
+        modal?.close()
+    })
+
+    // close modal when click events happen outside of it
+    modal?.addEventListener("click", (e) => {
+        const rect = modal.getBoundingClientRect()
+        if (
+            e.clientY < rect.top ||
+            e.clientY > rect.bottom ||
+            e.clientX < rect.left ||
+            e.clientX > rect.right
+        ) {
+            modal.close()
+        }
+    })
+}

binární
contracts/generator/data/more-data/signature-empty.png


binární
contracts/generator/data/more-data/signature-example.png


binární
contracts/generator/data/signature.png


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
contracts/generator/data/style.min.css


+ 2 - 0
contracts/generator/docker/.dockerignore

@@ -0,0 +1,2 @@
+docker/
+.vscode/

+ 11 - 0
contracts/generator/docker/Dockerfile

@@ -0,0 +1,11 @@
+# Use the PHP-Apache base image
+FROM php:8-apache
+
+# Copy project files to the /var/www/html directory
+COPY ../ /var/www/html/
+
+# Expose port 80
+EXPOSE 80
+
+# Start Apache server in the foreground
+CMD ["apache2-foreground"]

+ 25 - 0
contracts/generator/docker/docker-compose-with-nginx.yaml

@@ -0,0 +1,25 @@
+version: '3.8'
+
+services:
+  nginx-proxy-manager:
+    image: jc21/nginx-proxy-manager:latest
+    restart: unless-stopped
+    ports:
+      - '80:80'   # HTTP
+      - '81:81'   # Admin interface
+      - '443:443' # HTTPS
+    volumes:
+      - ./data:/data
+      - ./letsencrypt:/etc/letsencrypt
+    networks:
+      - contract-net
+
+  contract-app:
+    image: sarangcr03/nonsalant-contract:latest
+    restart: unless-stopped
+    networks:
+      - contract-net
+
+networks:
+  contract-net:
+    driver: bridge

+ 6 - 0
contracts/generator/docker/docker-compose.yaml

@@ -0,0 +1,6 @@
+version: '3.8'
+services:
+  web:
+    image: sarangcr03/nonsalant-contract:latest
+    ports:
+      - "9090:80"

+ 434 - 0
contracts/generator/edit.html

@@ -0,0 +1,434 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Contract Generator</title>
+    <base href="./">
+    <link rel="shortcut icon" type="image/x-icon" href="favicon/favicon.ico" />
+    <link rel="apple-touch-icon" sizes="180x180" href="favicon/apple-touch-icon.png">
+    <link rel="icon" type="image/png" sizes="32x32" href="favicon/favicon-32x32.png">
+    <link rel="icon" type="image/png" sizes="16x16" href="favicon/favicon-16x16.png">
+    <link rel="manifest" href="favicon/site.webmanifest">
+
+    <link rel="preconnect" href="https://cdn.skypack.dev">
+    <link rel="preconnect" href="https://fonts.gstatic.com">
+    <link rel="preconnect" href="https://fonts.googleapis.com">
+
+    <link rel="stylesheet" href="styles/main.css">
+    <!-- <link rel="stylesheet" href="styles/style.min.css"> -->
+
+    <link rel="stylesheet" href="styles/highlight.min.css" />
+    <script src="scripts/highlight/highlight.min.js" defer></script>
+    <script charset="UTF-8" src="scripts/highlight/xml.min.js" defer></script>
+
+    <script src="scripts/main.js" type="module"></script>
+</head>
+
+<body>
+
+    <a class="skip-to-content button" href="#main">Skip to contract content</a>
+    <a class="skip-to-content button" href="#below-contract">Skip to signature</a>
+    <main id="wysiwyg-wrap" class="w-medium flex-1">
+        <!-- Toolbar -->
+        <div title="Toolbar" id="toolbar-wrap" class="animate fade">
+            <div id="toolbar">
+                <button title="Scroll down" type="button"
+                    onclick="document.querySelector('#below-contract').scrollIntoView()">
+                    <svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 1024 1024" height="1em"
+                        width="1em" xmlns="http://www.w3.org/2000/svg">
+                        <use href="#down-circle"></use>
+                    </svg>
+                </button>
+                <!-- Bold, italic, and underline buttons -->
+                <span class="ql-formats">
+                    <button aria-label="Bold" type="button" class="ql-bold"></button>
+                    <button aria-label="Italic" type="button" class="ql-italic"></button>
+                    <button aria-label="Underline" type="button" class="ql-underline"></button>
+                </span>
+                <!-- Headings dropdown -->
+                <span class="ql-formats">
+                    <select aria-label="Heading" class="ql-header" style="margin-inline-end: .5rem;">
+                        <option aria-label="Normal" selected></option>
+                        <option aria-label="Heading 1" value="1"></option>
+                        <option aria-label="Heading 2" value="2"></option>
+                        <option aria-label="Heading 3" value="3"></option>
+                    </select>
+                    <!-- <select aria-label="Align" class="ql-align">
+                        <option aria-label="Left" selected></option>
+                        <option aria-label="Center" value="center"></option>
+                        <option aria-label="Right" value="right"></option>
+                        <option aria-label="Justify" value="justify"></option>
+                    </select> -->
+                </span>
+                <!-- Align buttons -->
+                <span class="ql-formats">
+                    <button aria-label="Align Left" type="button" class="ql-align" value=""></button>
+                    <button aria-label="Align Center" type="button" class="ql-align" value="center"></button>
+                    <button aria-label="Align Right" type="button" class="ql-align" value="right"></button>
+                    <button aria-label="Align Justify" type="button" class="ql-align" value="justify"></button>
+                </span>
+                <!-- Subscript and superscript buttons -->
+                <span class="ql-formats">
+                    <button type="button" class="ql-script" value="sub"></button>
+                    <button type="button" class="ql-script" value="super"></button>
+                </span>
+                <!-- Clean button -->
+                <span class="ql-formats">
+                    <button aria-label="Clean formatting" type="button" class="ql-clean"></button>
+                </span>
+
+                <!-- https://codepen.io/nonsalant/pen/YzvBNNg/e0bc9e0853f4de48c02256e5d5de7441https://codepen.io/nonsalant/pen/YzvBNNg/e0bc9e0853f4de48c02256e5d5de7441 -->
+                <!-- <button class="ql-html">HTML</button> -->
+
+                <span class="ql-formats">
+                    <button aria-label="Ordered list" type="button" class="ql-list" value="ordered"></button>
+                    <button aria-label="Bullet list" type="button" class="ql-list" value="bullet"></button>
+                </span>
+
+                <span class="ql-formats">
+                    <button aria-label="Indent +1" type="button" class="ql-indent" value="-1"></button>
+                    <button aria-label="Indent -1" type="button" class="ql-indent" value="+1"></button>
+                </span>
+
+                <span class="ql-formats">
+                    <button title="Insert an image" type="button" class="ql-image"></button>
+                </span>
+            </div>
+        </div>
+        <!-- Editor -->
+        <div id="main" class="editor-container | animate slide">
+            <div
+                style="padding-inline: clamp(20px,5vw,35px); padding-block-start: 2.5rem; color: var(--clr-green-desaturated-500);">
+                Loading contract…
+            </div>
+        </div>
+    </main>
+
+    <footer class="below-contract flow | animate fade delay-3">
+        <div id="below-contract" class="signature-area w-medium padding-clamp">
+            <div id="signature-container">
+
+                <!-- Signature -->
+                <div id="canvas-container">
+                    <canvas aria-label="Input signature" id="generator-signature-pad" class="signature-pad" width="188"
+                        height="58.66"></canvas>
+                </div>
+
+                <!-- Action Buttons -->
+                <div id="signature-controls">
+                    <div class="flexi">
+                        <button id="clear-signature" type="button" class="button warning">
+                            <span>Clear <span class="hide-small">signature</span></span>
+                            <span class="icon">
+                                <svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24"
+                                    height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
+                                    <use href="#icon-erase"></use>
+                                </svg>
+                            </span>
+                        </button>
+
+                        <button id="show-modal-preview" type="button" class="button success preview">
+                            <span>Preview <span class="hide-small">as Client</span></span>
+                            <span class="icon">
+                                <svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 1024 1024"
+                                    height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
+                                    <use href="#icon-preview"></use>
+                                </svg>
+                            </span>
+                        </button>
+
+                        <!-- <button id="show-modal-qr" type="button" class="open-button | button"
+                            title="Show QR: scan this code to open the page on another device">
+                            <span class="icon small-padding">
+                                <svg stroke="currentColor" fill="currentColor" stroke-width="0" version="1.1"
+                                    viewBox="0 0 16 16" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
+                                    <use href="#icon-qr"></use>
+                                </svg>
+                            </span>
+                        </button> -->
+
+                    </div>
+
+                </div>
+
+            </div>
+        </div>
+
+        <div class="generator-options flow padding-clamp w-medium">
+
+            <div class="generator-main-options flow">
+
+                <h3>Optional Contract Settings</h3>
+
+                <!-- Name fields -->
+                <details class="animate slide delay-1">
+                    <summary>
+                        Names
+                    </summary>
+                    <form class="panel flexi">
+                        <p class="grid">
+                            <label for="dev_name">Your name:</label>
+                            <input type="text" name="dev_name" id="dev_name" placeholder="">
+                        </p>
+                        <p class="grid">
+                            <label for="client_name">Your client's name:</label>
+                            <input type="text" name="client_name" id="client_name" placeholder="">
+                        </p>
+
+                        <small>
+                            Names appear above signatures when&nbsp;set.
+                        </small>
+                    </form>
+                </details>
+
+                <!-- Email fields -->
+                <details class="animate slide delay-0">
+                    <summary>
+                        Email addresses
+                    </summary>
+                    <form class="panel flexi">
+                        <p class="grid">
+                            <label for="dev_email">Your email:</label>
+                            <input type="email" name="dev_email" id="dev_email" placeholder="you@example.com">
+                        </p>
+                        <p class="grid">
+                            <label for="client_email">Your client's email:</label>
+                            <input type="email" name="client_email" id="client_email" placeholder="client@example.com">
+                        </p>
+
+                        <small>
+                            Optional. If set, the contract will send a confirmation email to both
+                            addresses when the second
+                            party signs the contract.
+                        </small>
+                    </form>
+                </details>
+
+                <!-- Shortcodes -->
+                <details class="animate slide delay-0">
+                    <summary>
+                        Shortcodes
+                    </summary>
+                    <form class="repeater-form | panel flexi">
+
+                        <template class="repeater-item-template">
+                            <article class="repeater-item">
+                                <div class="repeater-item-field">
+                                    <label class="grid">
+                                        <span>Replace this: </span>
+                                        <input class="repeater-item-name" type="text" placeholder="[shortcode]">
+                                    </label>
+                                </div>
+                                <div class="repeater-item-field">
+                                    <label class="grid">
+                                        <span>With this: </span>
+                                        <input class="repeater-item-value" type="text" placeholder="value">
+                                    </label>
+                                </div>
+                                <button class="remove-btn close-button secondary danger animate delay-1 roll"
+                                    type="button" aria-label="remove item" title="remove">
+                                    <span>❌</span>
+                                </button>
+                                <footer class="repeater-info">
+                                    <small class="">
+                                        Complete both fields above to 
+                                        <strong style="white-space: nowrap;">add a new shortcode</strong>
+                                    </small>
+                                </footer>
+                            </article>
+                        </template>
+
+                        <output>
+                        </output>
+
+                        <footer>
+                            <button class="add-btn | button size-300" type="submit">
+                                <span>
+                                    + Add another one
+                                </span>
+                            </button>
+                            <button class="save-btn | button size-300 | animate fade" type="button">💾 save</button>
+                        </footer>
+
+                        <hr style="width: 100%; opacity: .5; margin: 0;">
+                        <details class="flow panel">
+                            <summary style="color: inherit; padding: 0; opacity: .6;">
+                                <small>
+                                    What is a shortcode?
+                                </small>
+                            </summary>
+                            <div class="flow">
+                                <p>
+                                    Shortcodes are concise snippets,
+                                    conventionally enclosed in square brackets, such as <i>[price]</i>.
+                                </p>
+                                <p>
+                                    They get automatically replaced
+                                    with the actual value, e.g: <i>$1000</i>
+                                    when a contract is generated.
+                                </p>
+                            </div>
+                        </details>
+
+                    </form>
+                </details>
+
+                <!-- Reset data -->
+                <details class="danger | animate slide delay-0">
+                    <summary>
+                        Reset data
+                    </summary>
+                    <div class="panel" style="display: flex;align-items: center;gap: 1rem;">
+                    
+                        <div>
+                            <button id="clear-local-storage" type="button" class="button danger size-300" style="white-space: nowrap;">
+                                <span>Reset <span class="hide-small">data</span></span>
+                                <span class="icon">
+                                    <svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="1em"
+                                        width="1em" xmlns="http://www.w3.org/2000/svg">
+                                        <use href="#icon-delete"></use>
+                                    </svg>
+                                </span>
+                            </button>
+                        </div>
+                        <small>
+                            <span>
+                                <b>
+                                    Local browser
+                                    data will be deleted
+                                </b>
+                                and replaced with default&nbsp;content.
+                            </span>
+                        </small>
+                    
+                    </div>
+                </details>
+
+            </div>
+
+            <form class="generator-download-options sticky-1 flow | animate slide delay-0" id="download-form" name="download-form">
+
+                <h3>Download File</h3>
+
+                <!-- Filename field -->
+                <div class="flexi">
+                    <p class="grid">
+                        <label class="animate slide" for="contract_filename">
+                            <svg aria-label="PHP extension" stroke="currentColor" fill="currentColor" stroke-width="0" role="img"
+                                viewBox="0 0 24 24" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
+                                <use href="#icon-php"></use>
+                            </svg>
+                            Filename:
+                        </label>
+                        <span class="animate slide delay-1">
+                            <input type="text" name="contract_filename" id="contract_filename" value=""
+                                required>&nbsp;.php
+                        </span>
+                    </p>
+                </div>
+                <!-- Download Button -->
+                <div class="grid">
+                    <button id="download" type="submit" 
+                        style="margin-inline-start: 2px; justify-self: start;"
+                        class="button primary size-300 | animate slide delay-4">
+                        <span>Download</span>
+                        <span class="icon">
+                            <svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24"
+                                stroke-linecap="round" stroke-linejoin="round" height="1em" width="1em"
+                                xmlns="http://www.w3.org/2000/svg">
+                                <use href="#icon-download"></use>
+                            </svg>
+                        </span>
+                    </button>
+                </div>
+
+            </form>
+
+
+            <!-- 
+                How it works > You upload this file on your own server or domain. You send your client a link to it. They sign. Optionally, you both get an email notification.
+            -->
+
+            <div class="footer flow border-top">
+                <p style="font-size: 1rem; font-weight: 600; text-wrap: balance;">This template contains general information and should not
+                    be considered legal advice.</p>
+                <p>View on <a href="https://github.com/nonsalant/contract/">GitHub</a>. Built by <a
+                        href="https://stefanmatei.com">Stefan Matei</a>.</p>
+            </div>
+
+        </div>
+
+    </footer>
+
+
+    <!-- svg icon data -->
+    <svg class="hidden">
+        <symbol id="down-circle">
+            <path
+                d="M690 405h-46.9c-10.2 0-19.9 4.9-25.9 13.2L512 563.6 406.8 418.2c-6-8.3-15.6-13.2-25.9-13.2H334c-6.5 0-10.3 7.4-6.5 12.7l178 246c3.2 4.4 9.7 4.4 12.9 0l178-246c3.9-5.3.1-12.7-6.4-12.7z">
+            </path>
+            <path
+                d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z">
+            </path>
+        </symbol>
+        <symbol id="icon-new-tab">
+            <path fill="currentColor"
+                d="M576 24v127.984c0 21.461-25.96 31.98-40.971 16.971l-35.707-35.709-243.523 243.523c-9.373 9.373-24.568 9.373-33.941 0l-22.627-22.627c-9.373-9.373-9.373-24.569 0-33.941L442.756 76.676l-35.703-35.705C391.982 25.9 402.656 0 424.024 0H552c13.255 0 24 10.745 24 24zM407.029 270.794l-16 16A23.999 23.999 0 0 0 384 303.765V448H64V128h264a24.003 24.003 0 0 0 16.97-7.029l16-16C376.089 89.851 365.381 64 344 64H48C21.49 64 0 85.49 0 112v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V287.764c0-21.382-25.852-32.09-40.971-16.97z">
+            </path>
+        </symbol>
+        <symbol id="icon-php">
+            <path
+                d="M7.01 10.207h-.944l-.515 2.648h.838c.556 0 .97-.105 1.242-.314.272-.21.455-.559.55-1.049.092-.47.05-.802-.124-.995-.175-.193-.523-.29-1.047-.29zM12 5.688C5.373 5.688 0 8.514 0 12s5.373 6.313 12 6.313S24 15.486 24 12c0-3.486-5.373-6.312-12-6.312zm-3.26 7.451c-.261.25-.575.438-.917.551-.336.108-.765.164-1.285.164H5.357l-.327 1.681H3.652l1.23-6.326h2.65c.797 0 1.378.209 1.744.628.366.418.476 1.002.33 1.752a2.836 2.836 0 0 1-.305.847c-.143.255-.33.49-.561.703zm4.024.715l.543-2.799c.063-.318.039-.536-.068-.651-.107-.116-.336-.174-.687-.174H11.46l-.704 3.625H9.388l1.23-6.327h1.367l-.327 1.682h1.218c.767 0 1.295.134 1.586.401s.378.7.263 1.299l-.572 2.944h-1.389zm7.597-2.265a2.782 2.782 0 0 1-.305.847c-.143.255-.33.49-.561.703a2.44 2.44 0 0 1-.917.551c-.336.108-.765.164-1.286.164h-1.18l-.327 1.682h-1.378l1.23-6.326h2.649c.797 0 1.378.209 1.744.628.366.417.477 1.001.331 1.751zM17.766 10.207h-.943l-.516 2.648h.838c.557 0 .971-.105 1.242-.314.272-.21.455-.559.551-1.049.092-.47.049-.802-.125-.995s-.524-.29-1.047-.29z">
+            </path>
+        </symbol>
+        <symbol id="icon-download">
+            <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
+            <polyline points="7 10 12 15 17 10"></polyline>
+            <line x1="12" y1="15" x2="12" y2="3"></line>
+        </symbol>
+        <symbol id="icon-delete">
+            <path fill="none" d="M0 0h24v24H0z"></path>
+            <path fill="none" d="M0 0h24v24H0V0z"></path>
+            <path
+                d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zm2.46-7.12l1.41-1.41L12 12.59l2.12-2.12 1.41 1.41L13.41 14l2.12 2.12-1.41 1.41L12 15.41l-2.12 2.12-1.41-1.41L10.59 14l-2.13-2.12zM15.5 4l-1-1h-5l-1 1H5v2h14V4z">
+            </path>
+        </symbol>
+        <!-- <symbol id="icon-qr">
+            <path
+                d="M5 1h-4v4h4v-4zM6 0v0 6h-6v-6h6zM2 2h2v2h-2zM15 1h-4v4h4v-4zM16 0v0 6h-6v-6h6zM12 2h2v2h-2zM5 11h-4v4h4v-4zM6 10v0 6h-6v-6h6zM2 12h2v2h-2zM7 0h1v1h-1zM8 1h1v1h-1zM7 2h1v1h-1zM8 3h1v1h-1zM7 4h1v1h-1zM8 5h1v1h-1zM7 6h1v1h-1zM7 8h1v1h-1zM8 9h1v1h-1zM7 10h1v1h-1zM8 11h1v1h-1zM7 12h1v1h-1zM8 13h1v1h-1zM7 14h1v1h-1zM8 15h1v1h-1zM15 8h1v1h-1zM1 8h1v1h-1zM2 7h1v1h-1zM0 7h1v1h-1zM4 7h1v1h-1zM5 8h1v1h-1zM6 7h1v1h-1zM9 8h1v1h-1zM10 7h1v1h-1zM11 8h1v1h-1zM12 7h1v1h-1zM13 8h1v1h-1zM14 7h1v1h-1zM15 10h1v1h-1zM9 10h1v1h-1zM10 9h1v1h-1zM11 10h1v1h-1zM13 10h1v1h-1zM14 9h1v1h-1zM15 12h1v1h-1zM9 12h1v1h-1zM10 11h1v1h-1zM12 11h1v1h-1zM13 12h1v1h-1zM14 11h1v1h-1zM15 14h1v1h-1zM10 13h1v1h-1zM11 14h1v1h-1zM12 13h1v1h-1zM13 14h1v1h-1zM10 15h1v1h-1zM12 15h1v1h-1zM14 15h1v1h-1z">
+            </path>
+        </symbol> -->
+        <symbol id="icon-preview">
+            <path
+                d="M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 0 0 0 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z">
+            </path>
+        </symbol>
+        <symbol id="icon-erase">
+            <path
+                d="M20.454,19.028h-7.01l6.62-6.63a2.935,2.935,0,0,0,.87-2.09,2.844,2.844,0,0,0-.87-2.05l-3.42-3.44a2.93,2.93,0,0,0-4.13.01L3.934,13.4a2.946,2.946,0,0,0,0,4.14l1.48,1.49H3.554a.5.5,0,0,0,0,1h16.9A.5.5,0,0,0,20.454,19.028Zm-7.24-13.5a1.956,1.956,0,0,1,2.73,0l3.42,3.44a1.868,1.868,0,0,1,.57,1.35,1.93,1.93,0,0,1-.57,1.37l-5.64,5.64-6.15-6.16Zm-1.19,13.5h-5.2l-2.18-2.2a1.931,1.931,0,0,1,0-2.72l2.23-2.23,6.15,6.15Z">
+            </path>
+        </symbol>
+    </svg>
+
+    <!-- Preview modal -->
+    <dialog class="modal" id="modal-preview">
+        <header class="modal-header">
+            <button id="close-modal-preview" class="close-button button" aria-label="close" type="button"></button>
+        </header>
+        <iframe frameborder="0" id="iframe" title="Preview"></iframe>
+    </dialog>
+
+    <!-- QR modal -->
+    <!-- <dialog class="modal flow" id="modal-qr">
+        <div class="qr-code-container">
+            <button id="close-modal-qr" class="close-button button" aria-label="close" type="button"></button>
+            <canvas id="generator-qr-code"></canvas>
+        </div>
+    </dialog> -->
+
+</body>
+
+</html>

binární
contracts/generator/favicon/android-chrome-192x192.png


binární
contracts/generator/favicon/android-chrome-512x512.png


binární
contracts/generator/favicon/apple-touch-icon.png


binární
contracts/generator/favicon/favicon-16x16.png


binární
contracts/generator/favicon/favicon-32x32.png


binární
contracts/generator/favicon/favicon.ico


+ 19 - 0
contracts/generator/favicon/site.webmanifest

@@ -0,0 +1,19 @@
+{
+    "name": "Contract Generator",
+    "short_name": "Generate Contract",
+    "icons": [
+        {
+            "src": "android-chrome-192x192.png",
+            "sizes": "192x192",
+            "type": "image/png"
+        },
+        {
+            "src": "android-chrome-512x512.png",
+            "sizes": "512x512",
+            "type": "image/png"
+        }
+    ],
+    "theme_color": "#ffffff",
+    "background_color": "#ffffff",
+    "display": "standalone"
+}

+ 11 - 0
contracts/generator/index.html

@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta http-equiv="refresh" content="0;URL='edit.html'">
+</head>
+<body>
+</body>
+</html>

+ 28 - 0
contracts/generator/package.json

@@ -0,0 +1,28 @@
+{
+  "name": "signablephp",
+  "version": "1.0.0",
+  "description": "Contract generator.",
+  "main": "index.js",
+  "scripts": {
+    "postcss:watch": "postcss data/more-data/css/main.css -o data/style.min.css -w",
+    "postcss:build": "postcss data/more-data/css/main.css -o data/style.min.css"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/nonsalant/signablephp.git"
+  },
+  "keywords": [],
+  "author": "Stefan Matei",
+  "license": "ISC",
+  "bugs": {
+    "url": "https://github.com/nonsalant/signablephp/issues"
+  },
+  "homepage": "https://github.com/nonsalant/signablephp#readme",
+  "devDependencies": {
+    "cssnano": "^5.1.14",
+    "postcss-cli": "^10.0.0",
+    "postcss-import": "^15.0.0",
+    "postcss-nested": "^6.0.0",
+    "postcss-preset-env": "^7.8.2"
+  }
+}

+ 9 - 0
contracts/generator/postcss.config.js

@@ -0,0 +1,9 @@
+module.exports = {
+    plugins: [
+        require('postcss-import'),
+        require('postcss-nested'),
+        // https://browsersl.ist/#q=%3E%3D+5%25+in+US
+        require('postcss-preset-env')({ browsers: '>= 5% in US' }),
+        require('cssnano'),
+    ]
+}

+ 35 - 0
contracts/generator/scripts/download-preview/activate.js

@@ -0,0 +1,35 @@
+import downloadPreview from "./download-preview.js"
+
+export default () => {
+    
+    const selector = "#download-form > *:last-child"
+
+    const html = `
+    <h3>Generate Preview files</h3>
+    <p>
+        <button id="download-preview" type="button" class="button size-300">
+            <span>contract-demo.html</span>
+            <span class="icon">
+                <svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
+                    <use href="#icon-download"></use>
+                </svg>
+            </span>
+        </button>
+    </p>
+    <p>
+        <button id="download-preview-signed" type="button" class="button size-300">
+            <span>contract-signed.html</span>
+            <span class="icon">
+                <svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
+                    <use href="#icon-download"></use>
+                </svg>
+            </span>
+        </button>
+    </p>`;
+
+    document.querySelector(selector).innerHTML += html
+
+    downloadPreview("#download-preview")
+    downloadPreview("#download-preview-signed", true)
+
+}

+ 40 - 0
contracts/generator/scripts/download-preview/download-preview.js

@@ -0,0 +1,40 @@
+// import generateDownloadPreview from "./generate-download-preview.js"
+import generatePreview from "../preview/generate-preview.js"
+
+// download a signable (html) demo
+
+export default function downloadPreview(selector, signed = false) {
+    selector = document.querySelector(selector)
+    selector?.addEventListener("click", async function (e) {
+
+        e.preventDefault()
+        
+        //localStorage.removeItem("client_signature")
+        let signedFilename = "contract-signed.html"
+        let unsignedFilename = "contract-demo.html"
+        let filename = signed ? signedFilename : unsignedFilename
+        
+        const contractPreview = await generatePreview(signed, true, signedFilename)
+
+        selector.disabled = true
+        
+        downloadFile(filename, contractPreview)
+        setTimeout(() => {
+            selector.disabled = false
+        }, 300)
+
+        // e.preventDefault()
+    }, false)
+
+}
+
+// https://ourcodeworld.com/articles/read/189/how-to-create-a-file-and-generate-a-download-with-javascript-in-the-browser-without-a-server
+function downloadFile(filename, text) {
+    let element = document.createElement('a')
+    element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text))
+    element.setAttribute('download', filename)
+    element.style.display = 'none'
+    document.body.appendChild(element)
+    element.click()
+    document.body.removeChild(element)
+}

+ 27 - 0
contracts/generator/scripts/download/download.js

@@ -0,0 +1,27 @@
+import generate from "./generate.js"
+
+export default function download(selector) {
+    document.querySelector(selector)?.addEventListener("submit", async function (e) {
+        e.preventDefault()
+        document.querySelector("#download").disabled = true
+        const text = await generate()
+        const filename = localStorage.getItem("contract_filename")+".php"
+        downloadFile(filename, text)
+        setTimeout(() => {
+            document.querySelector("#download").disabled = false
+            // e.preventDefault()
+        }, 300)
+    }, false)
+}
+
+
+// https://ourcodeworld.com/articles/read/189/how-to-create-a-file-and-generate-a-download-with-javascript-in-the-browser-without-a-server
+function downloadFile(filename, text) {
+    let element = document.createElement('a')
+    element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text))
+    element.setAttribute('download', filename)
+    element.style.display = 'none'
+    document.body.appendChild(element)
+    element.click()
+    document.body.removeChild(element)
+}

+ 141 - 0
contracts/generator/scripts/download/generate.js

@@ -0,0 +1,141 @@
+import { doShortcodesFromLocalstorage } from "../utils.js";
+
+export default async function generate() {
+
+    // get timestamp
+    // const dev_timestamp = Math.round(+new Date() / 1000);
+    // const timestampOptions = { year: 'numeric', month: 'long', day: 'numeric' }
+    // const timestampOptions = { dateStyle: "medium", timeStyle: "long" }
+    // const dev_timestamp = date.toLocaleDateString("en-US", timestampOptions)
+
+    const date = new Date()
+    const dev_timestamp = new Intl.DateTimeFormat('en',
+        { dateStyle: "long", timeStyle: "long" }
+    ).format(date)
+
+    const dev_timestamp_offset = date.getTimezoneOffset()
+
+    // get IP
+    const dev_ip = await fetch("https://api.ipify.org").then((data) => { return data.text() })
+    // console.log(dev_timestamp)
+    // console.log(dev_ip)
+
+    // generate header
+    let header = localStorage.getItem("contract_header")
+    let lines = header.split("\n")
+    lines[1] = (localStorage.getItem("client_email")    || null ) ?? "[client_email]"
+    lines[2] = (localStorage.getItem("dev_email")       || null ) ?? "[dev_email]"
+    lines[3] = (localStorage.getItem("dev_signature")   || null ) ?? "[dev_signature]"
+    lines[4] = dev_ip
+    lines[5] = dev_timestamp
+    lines[6] = dev_timestamp_offset
+    lines[7] = (localStorage.getItem("dev_name")        || null ) ?? "[dev_name]"
+    lines[8] = (localStorage.getItem("client_name")     || null ) ?? "[client_name]"
+    header = lines.join("\n")
+
+    // generate main (escape single quotes)
+    let main = "\n\n\n\$CONTRACT_HTML='\n  "
+        + addSlashes(document.querySelector(".editor-container .ql-editor").innerHTML)
+        + "\n';\n\n"
+    
+    // replace each shortcode w/ its value
+    main = doShortcodesFromLocalstorage(main)
+
+
+    // generate footer 
+
+    let footer = localStorage.getItem("contract_footer")
+    // remove first line
+    let footerWithoutFirstLine = footer.split("\n")
+    footerWithoutFirstLine = footerWithoutFirstLine.slice(1)
+    footer = footerWithoutFirstLine.join("\n")
+
+    // console.log(footer)
+
+    // put css in footer
+    let contract_css = addSlashes(localStorage.getItem("contract_css"))
+    // console.log(contract_css)
+    footer = footer.replace(
+        `<style></style>`,
+        `<style>${contract_css}</style>`
+    )
+
+    // put html for unsigned contract in footer
+    footer = footer.replace(
+        `<div id="ui-unsigned"></div>`,
+        `<div id="ui-unsigned">`+
+            "  "+indentLinesAndAddSlashes(localStorage.getItem("ui_unsigned"), 4)
+        +"\n  </div><!--.ui-unsigned-->"
+    )
+
+    // put html for unsigned contract in footer
+    footer = footer.replace(
+        `<div id="ui-signed"></div>`,
+        `<div id="ui-signed">`+
+            "  "+indentLinesAndAddSlashes(localStorage.getItem("ui_signed"), 6)
+        +"\n    </div><!--.ui-signed-->"
+    )
+
+
+    // put js for unsigned contract in footer
+    footer = footer.replace(
+        `<script id="contract_script_unsigned" type="module"></script>`,
+        `<script id="contract_script_unsigned" type="module">` 
+        + 
+        	indentLinesAndAddSlashes(localStorage.getItem("contract_script_unsigned", 1)) 
+        + "\n  </script>"
+    )
+
+    // put js for qr code in footer
+    footer = footer.replace(
+        `<script id="qr_code_script" type="module"></script>`,
+        `<script id="qr_code_script" type="module">` 
+        + 
+        	indentLinesAndAddSlashes(localStorage.getItem("qr_code_script", 1)) 
+        + "\n  </script>"
+    )
+    
+
+    // put js for signed contract in footer
+    footer = footer.replace(
+        `<script id="contract_script_signed"></script>`,
+        `<script id="contract_script_signed">` 
+        + 
+        	indentLinesAndAddSlashes(localStorage.getItem("contract_script_signed", 1))
+        + "\n  </script>"
+    )
+
+
+    let output = header + main + footer
+    return (output)
+}
+
+const addSlashes = (str) => {
+    return str
+        .replace(/'/g, "\\'")
+}
+
+const indentLinesAndAddSlashes = (str="", indentationLevel=2) =>{
+    const space = " ".repeat(indentationLevel)
+    str = str.split("\n").join("\n"+space)
+    //console.log(str)
+    return "\n"+space+ str
+        .replace(/'/g, "\\'")
+}
+
+// const stripSlashes = (str) => {
+//     return str
+//         .replace(/\\'/g, "\'")
+//     // .replace(/\"/g, '"')
+//     // .replace(/\\\\/g, '\\')
+//     // .replace(/\\0/g, '\0')
+// }
+
+// function randomID() {
+//     let text = ""
+//     const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+//     for (let i = 0; i < 5; i++)
+//         text += possible.charAt(Math.floor(Math.random() * possible.length))
+//     return text
+// }
+

+ 33 - 0
contracts/generator/scripts/editor/editor-settings.js

@@ -0,0 +1,33 @@
+const editorSettings = {
+    modules: {
+        toolbar: '#toolbar',
+        // [
+        //     [{ header: [1, 2, 3, 4, false] }],
+        //     ["bold", "italic", "underline", { script: "super" }, "clean"],
+        //     [
+        //         { list: "ordered" }, { list: "bullet" }
+        //     ],
+        //     [{ indent: "-1" }, { indent: "+1" }],
+        //     [{ align: ['', 'center', 'right', 'justify'] }],
+        //     ["image"],
+        // ],
+        clipboard: { matchVisual: false }, // https://stackoverflow.com/a/46435236
+        htmlEditButton: { // https://github.com/benwinding/quill-html-edit-button/tree/master#customising-the-html-editor
+            syntax: true,
+            msg: "Edit the content in HTML format",
+            okText: "Apply", // Text to display in the OK button, default: Ok,
+            cancelText: "Discard<span class='hide-small'>&nbsp;changes</span>", // Text to display in the cancel button, default: Cancel
+            // buttonHTML: "<b style='font-size:.65rem; display: block;'>&lt;&nbsp;/&nbsp;&gt;</b>",
+            // buttonHTML: "<span style='font-size:.55rem; display: block;'><b>&lt;</b><span style='font-size:.5rem;'>HTML</span><b>&gt;</b></span>",
+            buttonHTML: "<span aria-label='Edit HTML' role='button' style='font-size:.75rem; display: block; font-weight:600;'>&lt;<span style='font-size:.65rem;'>/</span>&gt;</span>",
+            closeOnClickOverlay: false,
+            debug: false,
+        },
+    },
+    theme: "snow",
+    // scrollingContainer: '#wysiwyg-wrap',
+    scrollingContainer: "html",
+    placeholder: "Contract text goes here…",
+}
+
+export default editorSettings

+ 154 - 0
contracts/generator/scripts/editor/editor.js

@@ -0,0 +1,154 @@
+import Quill from "https://cdn.skypack.dev/pin/quill@v1.3.7-AhkYF0UBjqu955pdu0pJ/mode=imports,min/optimized/quill.js"
+// 📙 Package Documentation: https://www.skypack.dev/view/quill
+// https://quilljs.com/docs/quickstart/
+
+
+Quill.register("modules/htmlEditButton", htmlEditButton)
+// https://github.com/benwinding/quill-html-edit-button/
+import htmlEditButton from "https://cdn.skypack.dev/pin/quill-html-edit-button@v2.2.12-bQePZLc6oeJp4TdDNJk2/mode=imports,min/optimized/quill-html-edit-button.js"
+//  📙 Package Documentation: https://www.skypack.dev/view/quill-html-edit-button
+
+
+import editorSettings from "./editor-settings.js"
+
+import {showToolbar} from "./ios-keyboard-bug.js"
+
+
+export default function editor(selector) {
+
+    const contractHtmlFile = 'data/contract-content.html'
+    const editor = new Quill(selector, editorSettings)
+
+    // ios-keyboard-bug
+    // add an event listener to scroll to check if
+    // toolbar position has moved off the page
+    window.addEventListener("scroll", showToolbar);
+    // add an event listener to blur as iOS keyboard may have closed
+    // and toolbar position needs to be checked again  //editor.addEventListener("blur", showToolbar);
+    // https://codepen.io/DmitrySkripkin/pen/eeXpZB
+    editor.on('selection-change', function (range, oldRange) {
+        if (range === null && oldRange !== null) {
+            showToolbar()
+            // console.log('editor blur')
+        }
+    });
+
+    // Quill Autosave
+    // https://codepen.io/quill/pen/RRYBEP
+
+    const Delta = Quill.import('delta');
+
+
+    // Store accumulated changes
+    let change = new Delta();
+    editor.on('text-change', function (delta) {
+        change = change.compose(delta);
+        // console.log(change)
+        
+        // // if is empty
+        // if (isQuillEmpty(editor)) {
+        //     console.log("The editor is empty")
+        //     localStorage.setItem("contract_html","")
+        // }
+    });
+
+    // Save periodically
+    setInterval(function () {
+        if (change.length() > 0) {
+            // console.log('Saving changes', change);
+            /* 
+            Partial changes: { partial: JSON.stringify(change) }
+            Entire document: { doc: JSON.stringify(editor.getContents()) }
+            */
+            localStorage.setItem("contract_html", JSON.stringify(editor.getContents()))
+            change = new Delta();
+        }
+    }, 10 * 1000);
+
+    // save when Ctrl|CMD + S key is pressed (useful for debugging when saving setInterval is large)
+    document.addEventListener('keydown', (e) => {
+        if (e.key?.toLowerCase() === 's' && e.metaKey) {
+            e.preventDefault();
+
+            if (change.length() > 0) {
+                localStorage.setItem("contract_html", JSON.stringify(editor.getContents()))
+                change = new Delta();
+            }
+        }
+    });
+
+    // Check for unsaved data
+    window.onbeforeunload = function () {
+        if (change.length() > 0) {
+            localStorage.setItem("contract_html", JSON.stringify(editor.getContents()))
+            change = new Delta();
+        }
+    }
+
+
+    // ✅ contract_html init
+
+    if (!localStorage.getItem("contract_html") || isEmpty(localStorage.getItem("contract_html"))) {
+    // if (!localStorage.getItem("contract_html") || isQuillEmpty(editor) ) {
+
+        getHtmlFromFile(editor, contractHtmlFile) 
+
+    } else {
+        let data = localStorage.getItem("contract_html")
+        // data = stripSlashes(data)
+
+        // const delta = editor.clipboard.convert(data)
+        const delta = JSON.parse(data)
+        
+        editor.setContents(delta, "silent")
+
+        if (isQuillEmpty(editor)) {
+            console.log("The editor is empty -- initializing from file.")
+            localStorage.setItem("contract_html", "")
+
+            getHtmlFromFile(editor, contractHtmlFile) 
+        }
+
+    }
+
+}
+
+
+function getHtmlFromFile(editor, contractHtmlFile) {
+    // init contract_html from file
+    fetch(contractHtmlFile).then((response) => response.text()).then((data) => {
+        // data = stripSlashes(data)
+        const delta = editor.clipboard.convert(data)
+        // const delta = JSON.parse(data)
+        // console.log(data)
+        // console.log(delta)
+
+        editor.setContents(delta, "silent")
+        localStorage.setItem("contract_html", JSON.stringify(delta))
+
+    })
+}
+
+// Helper functions
+
+// this is needed because contract.php needs single quotes escaped (\')
+const stripSlashes = (str) => {
+    return str
+        .replace(/\\'/g, '\'')
+        // .replace(/\"/g, '"')
+        // .replace(/\\\\/g, '\\')
+        // .replace(/\\0/g, '\0')
+}
+
+// detect empty html tags (eg: <p><br></p>)
+const isEmpty = (htmlString) => {
+    const parser = new DOMParser();
+    const { textContent } = parser.parseFromString(htmlString, "text/html").documentElement;
+    return !textContent.trim();
+}
+
+// https://github.com/quilljs/quill/issues/163#issuecomment-561341501
+function isQuillEmpty(quill) {
+    if ((quill.getContents()['ops'] || []).length !== 1) { return false }
+    return quill.getText().trim().length === 0
+}

+ 55 - 0
contracts/generator/scripts/editor/ios-keyboard-bug.js

@@ -0,0 +1,55 @@
+// from https://www.codemzy.com/blog/sticky-fixed-header-ios-keyboard-fix
+// replaced lodash debounce w/ vanilla js debounce
+
+let fixPosition = 0; // the fix
+let lastScrollY = window.pageYOffset; // the last scroll position
+let toolbarWrap = document.getElementById('toolbar-wrap'); // the toolbar wrap
+let toolbar = document.getElementById('toolbar'); // the toolbar
+// let editor = document.getElementById('main'); // the editor
+
+
+
+// from: https://www.joshwcomeau.com/snippets/javascript/debounce/
+const debounce = (callback, wait) => {
+    let timeoutId = null;
+    return (...args) => {
+        window.clearTimeout(timeoutId);
+        timeoutId = window.setTimeout(() => {
+            callback.apply(null, args);
+        }, wait);
+    };
+}
+
+// function to set the margin to show the toolbar if hidden
+const setMargin = function () {
+    // if toolbar wrap is hidden
+    const newPosition = toolbarWrap.getBoundingClientRect().top;
+    if (newPosition < -1) {
+        // add a margin to show the toolbar
+        toolbar.classList.add("down"); // add class so toolbar can be animated
+        fixPosition = Math.abs(newPosition); // this is new position we need to fix the toolbar in the display
+        // if at the bottom of the page take a couple of pixels off due to gap
+        if ((window.innerHeight + window.pageYOffset) >= document.body.offsetHeight) {
+            fixPosition -= 2;
+        }
+        // set the margin to the new fixed position
+        toolbar.style["margin-top"] = fixPosition + "px";
+    }
+}
+
+
+
+// use debounce to stop flicker
+const debounceMargin = debounce(setMargin, 150);
+
+// function to run on scroll and blur
+export const showToolbar = () => {
+    // remove animation and put toolbar back in default position
+    if (fixPosition > 0) {
+        toolbar.classList.remove("down");
+        fixPosition = 0;
+        toolbar.style["margin-top"] = 0 + "px";
+    }
+    // will check if toolbar needs to be fixed
+    debounceMargin();
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 5 - 0
contracts/generator/scripts/highlight/highlight.min.js


+ 1 - 0
contracts/generator/scripts/highlight/xml.min.js

@@ -0,0 +1 @@
+hljs.registerLanguage("xml", function () { "use strict"; return function (e) { var n = { className: "symbol", begin: "&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;" }, a = { begin: "\\s", contains: [{ className: "meta-keyword", begin: "#?[a-z_][a-z1-9_-]+", illegal: "\\n" }] }, s = e.inherit(a, { begin: "\\(", end: "\\)" }), t = e.inherit(e.APOS_STRING_MODE, { className: "meta-string" }), i = e.inherit(e.QUOTE_STRING_MODE, { className: "meta-string" }), c = { endsWithParent: !0, illegal: /</, relevance: 0, contains: [{ className: "attr", begin: "[A-Za-z0-9\\._:-]+", relevance: 0 }, { begin: /=\s*/, relevance: 0, contains: [{ className: "string", endsParent: !0, variants: [{ begin: /"/, end: /"/, contains: [n] }, { begin: /'/, end: /'/, contains: [n] }, { begin: /[^\s"'=<>`]+/ }] }] }] }; return { name: "HTML, XML", aliases: ["html", "xhtml", "rss", "atom", "xjb", "xsd", "xsl", "plist", "wsf", "svg"], case_insensitive: !0, contains: [{ className: "meta", begin: "<![a-z]", end: ">", relevance: 10, contains: [a, i, t, s, { begin: "\\[", end: "\\]", contains: [{ className: "meta", begin: "<![a-z]", end: ">", contains: [a, s, i, t] }] }] }, e.COMMENT("\x3c!--", "--\x3e", { relevance: 10 }), { begin: "<\\!\\[CDATA\\[", end: "\\]\\]>", relevance: 10 }, n, { className: "meta", begin: /<\?xml/, end: /\?>/, relevance: 10 }, { className: "tag", begin: "<style(?=\\s|>)", end: ">", keywords: { name: "style" }, contains: [c], starts: { end: "</style>", returnEnd: !0, subLanguage: ["css", "xml"] } }, { className: "tag", begin: "<script(?=\\s|>)", end: ">", keywords: { name: "script" }, contains: [c], starts: { end: "<\/script>", returnEnd: !0, subLanguage: ["javascript", "handlebars", "xml"] } }, { className: "tag", begin: "</?", end: "/?>", contains: [{ className: "name", begin: /[^\/><\s]+/, relevance: 0 }, c] }] } } }());

+ 7 - 0
contracts/generator/scripts/init/clear-data.js

@@ -0,0 +1,7 @@
+export default function clearData(selector) {
+    // clear localStorage and refresh page
+    document.querySelector(selector)?.addEventListener("click", () => {
+        localStorage.clear()
+        location.reload()
+    })
+}

+ 37 - 0
contracts/generator/scripts/init/init-fields.js

@@ -0,0 +1,37 @@
+import { contractData } from "../../data/contract-data.js"
+import initFilenameField from "./init-filename-field.js"
+
+
+export default function initFields() {
+    
+    // init names and emails
+    initField("client_name", contractData.client.name)
+    initField("client_email", contractData.client.email)
+    initField("dev_name", contractData.dev.name)
+    initField("dev_email", contractData.dev.email)
+    
+    // init file name
+    initFilenameField()
+}
+
+
+
+function initField(fieldName, fieldValue) {
+    if (!localStorage.getItem(fieldName)) {
+        localStorage.setItem(fieldName, fieldValue)
+    }
+    const val = localStorage.getItem(fieldName)
+    const el = document.querySelector("#" + fieldName)
+    // if (!el) return
+    handleDom(el, fieldName, val)
+}
+
+
+
+// set field value and event listener
+const handleDom = (el, itemName, val) => {
+    el.value = val
+    el.addEventListener("change", (e) => {
+        localStorage.setItem(itemName, e.target.value)
+    })
+}

+ 70 - 0
contracts/generator/scripts/init/init-filename-field.js

@@ -0,0 +1,70 @@
+import { contractSettings } from "../../data/more-data/contract-settings.js"
+
+
+
+export default function initFilenameField() {
+    const unix = Math.round(+new Date() / 1000)
+
+    let filenameSettings = contractSettings.filename
+    
+    let filename_prefix = contractSettings.filename
+    let filename_value = contractSettings.filename
+
+    if (filenameSettings.has_timestamp) {
+        if (filenameSettings.name) {
+            // !
+            filename_prefix = filenameSettings.name + filenameSettings.timestamp_separator
+        }
+        else {
+            // ! doesn't re-init when filenameSettings.name is empty
+            filename_prefix = ""
+        }
+        filename_value = filename_prefix + unix
+    }
+
+    const fieldName = "contract_filename"
+    const fieldValue = filename_value
+
+    if (!localStorage.getItem(fieldName)) {
+        localStorage.setItem(fieldName, fieldValue)
+    }
+
+    let val = localStorage.getItem(fieldName)
+    val = addTimestampIfNeeded(val, filenameSettings.has_timestamp, filename_prefix, unix)
+
+    const el = document.querySelector("#" + fieldName)
+    // if (!el) return
+    handleDom(el, fieldName, val)
+}
+
+
+
+function addTimestampIfNeeded(val, filename_timestamp, filename_prefix, unix) {
+    // if it looks like a timestamp reset it:
+    if (isTimestamp(filename_prefix, val)) {
+        val = filename_prefix + unix
+        localStorage.setItem("contract_filename", val)
+    }
+    return val
+}
+
+
+
+// check if like: contract-0123456789 (prefix, separator, 10 digits)
+const isTimestamp = (prefix, str) => {
+    let matches = str.split(prefix)
+    if (matches[1] && matches[1].match(/^[0-9]{10}$/) && !matches[2])
+        return true
+    else
+        return false
+}
+
+
+
+// set field value and event listener
+const handleDom = (el, itemName, val) => {
+    el.value = val
+    el.addEventListener("change", (e) => {
+        localStorage.setItem(itemName, e.target.value)
+    })
+}

+ 204 - 0
contracts/generator/scripts/init/init-repeater.js

@@ -0,0 +1,204 @@
+import { contractData } from "../../data/contract-data.js"
+import { addEvent, debounce } from "../utils.js"
+
+export default function repeater(repeater = ".repeater-form", output = "output", addBtn = ".add-btn") {
+    // Cache frequently used elements
+    const repeaterForm = document.querySelector(repeater);
+    const repeaterOutput = repeaterForm.querySelector(output);
+    const addItemButton = repeaterForm.querySelector(addBtn);
+
+    //  Save data + form submit Event Listener
+    const saveData = () => {
+        const items = [];
+        const seenNames = new Set();
+
+        const repeaterItems = repeaterForm.querySelectorAll(".repeater-item");
+        repeaterItems.forEach((item) => {
+            const nameInput = item.querySelector(".repeater-item-name");
+            const valueInput = item.querySelector(".repeater-item-value");
+            const name = nameInput.value.trim();
+            const value = valueInput.value.trim();
+            if (name !== "" && value !== "") {
+                if (seenNames.add(name)) {
+                    items.push({ name, value });
+                }
+            }
+        });
+
+        localStorage.setItem("repeaterData", JSON.stringify(items));
+        console.log("Saved.");
+        console.log(JSON.stringify(items));
+    };
+    addEvent(repeaterForm, (event) => {
+        console.log("Form submitted");
+        saveData();
+        event.preventDefault();
+    }, "submit");
+
+    // Add new item + Event Listener
+    const insertAddNewInputs = () => {
+        const template = repeaterForm.querySelector(".repeater-item-template");
+        const clone = template.content.cloneNode(true);
+        repeaterOutput.appendChild(clone);
+    };
+    const addItemHandler = (event) => {
+        // remove empty last-child (only if both fields are empty)
+        repeaterForm.querySelector(".repeater-item:last-child:has(.repeater-item-name:placeholder-shown):has(.repeater-item-value:placeholder-shown)")?.remove()
+        insertAddNewInputs();
+        event.preventDefault();
+        // console.log(event.target)
+        repeaterForm.querySelector(".repeater-item:last-child input").focus();
+    };
+    addEvent(addItemButton, addItemHandler);
+    addEvent(addItemButton, addItemHandler, "mousedown");
+
+    // Remove item + Event Listener
+    const removeItem = (item) => {
+        item.remove();
+        saveData();
+        if (repeaterOutput.children.length === 0) {
+            insertAddNewInputs();
+        }
+    };
+    addEvent(repeaterForm, (event) => {
+        if (event.target.classList.contains("remove-btn")) {
+            const removedItem = event.target.closest(".repeater-item");
+            removeItem(removedItem);
+            event.preventDefault();
+        }
+    }, "click");
+
+    // Debounced save + Event Listener
+    const debouncedSave = debounce(saveData, 200);
+    addEvent(repeaterForm, (event) => {
+        const input = event.target;
+        if (
+            input.classList.contains("repeater-item-name") ||
+            input.classList.contains("repeater-item-value")
+        ) {
+            debouncedSave();
+        }
+    }, "input");
+
+    // init
+    const init = () => {
+
+        console.clear();
+
+        repeaterForm.querySelector(".save-btn").style.display = "none";
+        // repeaterForm.querySelector(".add-btn").style.cssText = "opacity: 0; overflow: hidden; pointer-events: none;";
+
+        // ! BUG: doesn't default from json if ls empty
+
+        // Load data from localStorage on page load
+        const savedData = localStorage.getItem("repeaterData");
+        if (savedData) {
+            const parsedData = JSON.parse(savedData) ?? [];
+
+            if (parsedData.length > 0) {
+                repeaterOutput.innerHTML = ""; // Clear existing content
+            } else {
+                const shortcodes = contractData.shortcodes || [];
+                shortcodes.forEach((shortcode) => {
+                    const template = repeaterForm.querySelector(".repeater-item-template");
+                    const clone = template.content.cloneNode(true);
+                    clone.querySelector(".repeater-item-name").setAttribute("value", shortcode.name);
+                    clone.querySelector(".repeater-item-value").setAttribute("value", shortcode.value);
+                    repeaterOutput.appendChild(clone);
+                    saveData();
+                });
+            }
+
+            parsedData.forEach((item) => {
+                const template = repeaterForm.querySelector(".repeater-item-template");
+                const clone = template.content.cloneNode(true);
+                const nameInput = clone.querySelector(".repeater-item-name");
+                const valueInput = clone.querySelector(".repeater-item-value");
+                nameInput.value = item.name;
+                valueInput.value = item.value;
+                repeaterOutput.appendChild(clone);
+            });
+        }
+
+        // display input if empty
+        if (repeaterOutput.children.length === 0) {
+            insertAddNewInputs(); // Call the function to add a new item on page load
+        }
+
+    };
+    init();
+}
+
+///
+
+// Toggle overflow:visible when details finishes opening
+const initialOpenDetailsElements = document.querySelectorAll("details[open]");
+initialOpenDetailsElements.forEach(item => { item.style.overflow = "visible"; });
+const detailsElements = document.querySelectorAll("details");
+detailsElements.forEach(item => {
+    item.addEventListener("transitionend", (event) => {
+        const element = event.target;
+        if (element.open === true) {
+            element.style.overflow = "visible";
+            // element.style.maxHeight = "300vh";
+        } else {
+            // if (!element.querySelector("&:is(summary)"))
+            element.style.overflow = "hidden";
+            // element.style.maxHeight = "90vh";
+            // element.querySelector("& > *:not(summary)").style.height = "0";
+            // alert("hidden")
+        }
+    });
+});
+
+///
+
+// repeater(".repeater-form", "output", ".add-btn");
+repeater();
+
+///
+
+
+// // Utils
+
+// // wrapper around querySelector
+// // select() usage: const [sel1, sel2 ] = select(".class1", ".class2")
+// // [repeaterForm, repeaterOutput, addItemButton] = select( ".repeater-form", "output", ".add-btn");
+// function select(...selectors) {
+// 	return selectors.map(selector => document.querySelector(selector));
+// };
+// function selectAll(...selectors) {
+// 	return selectors.map(selector => document.querySelectorAll(selector));
+// };
+// // wrapper around addEventListener with optional event type at the end
+// function addEvent(element, handler, eventType="click") {
+// 	element.addEventListener(eventType, handler);
+// }
+// // debounce() usage: const debouncedMyFunc = debounce(myFunc, 200);
+// // or function debouncedMyFunc() { return debounce(myFunc, 200); }
+// function debounce(callback, wait=200) {
+//   let timeoutId = null;
+//   return function (...args) {
+//     window.clearTimeout(timeoutId);
+//     timeoutId = window.setTimeout(() => {
+//       callback.apply(null, args);
+//     }, wait);
+//   };
+// };
+// function addBrackets(element){
+// 		// Trim the value
+// 		let trimmedValue = element.value.trim();
+
+// 		// Check if it begins with "[", add if not
+// 		if (!trimmedValue.startsWith('[')) {
+// 				trimmedValue = '[' + trimmedValue;
+// 		}
+
+// 		// Check if it ends with "]", add if not
+// 		if (!trimmedValue.endsWith(']')) {
+// 				trimmedValue = trimmedValue + ']';
+// 		}
+
+// 		// Update the element value
+// 		element.value = trimmedValue;
+// }

+ 12 - 0
contracts/generator/scripts/init/set-data.js

@@ -0,0 +1,12 @@
+import setHeaderData from "./set-header.js"
+import initFields from "./init-fields.js"
+import initRepeater from "./init-repeater.js"
+import setFooterData from "./set-footer.js"
+
+export default function setData() {
+    // init from data files or localStorage
+    setHeaderData()
+    initFields()
+    initRepeater()
+    setFooterData()
+}

+ 55 - 0
contracts/generator/scripts/init/set-footer.js

@@ -0,0 +1,55 @@
+export default function setFooterData() {
+
+    const dataPath = "data"
+
+    const contractFooterFile = dataPath + "/more-data/php-partials/contract_footer.phpsrc"
+    const contractCssFile = dataPath + "/style.min.css"
+    const uiUnsigned = dataPath + "/more-data/html-partials/ui-unsigned.html.xml"
+    const contractScriptUnsignedFile = dataPath + "/more-data/scripts/contract_script_unsigned.js"
+    const uiSigned = dataPath + "/more-data/html-partials/ui-signed.html.xml"
+    const contractScriptSignedFile = dataPath + "/more-data/scripts/contract_script_signed.js"
+    const qrCodeScriptFile = dataPath + "/more-data/scripts/qr-code.js" // qrCode
+
+
+
+
+    if (localStorage.getItem("contract_footer") === null) {
+        fetch(contractFooterFile).then((response) => response.text()).then((data) => {
+            localStorage.setItem("contract_footer", data)
+        })
+    }
+
+
+    if (!localStorage.getItem("contract_css")) {
+        fetch(contractCssFile).then((response) => response.text()).then((data) => {
+            data = addSlashes(data)
+            localStorage.setItem("contract_css", data)
+        })
+    }
+
+
+    fetch(uiUnsigned).then((response) => response.text()).then((data) => {
+        localStorage.setItem("ui_unsigned", data)
+    })
+    fetch(uiSigned).then((response) => response.text()).then((data) => {
+        localStorage.setItem("ui_signed", data)
+    })
+
+
+    fetch(contractScriptUnsignedFile).then((response) => response.text()).then((data) => {
+        localStorage.setItem("contract_script_unsigned", data)
+    })
+    fetch(contractScriptSignedFile).then((response) => response.text()).then((data) => {
+        localStorage.setItem("contract_script_signed", data)
+    })
+
+    fetch(qrCodeScriptFile).then((response) => response.text()).then((data) => {
+        localStorage.setItem("qr_code_script", data)
+    })
+
+}
+
+const addSlashes = (str) => {
+    return str
+        .replace(/'/g, "\\'")
+}

+ 24 - 0
contracts/generator/scripts/init/set-header.js

@@ -0,0 +1,24 @@
+export default function setHeaderData() {    
+    const contractHeaderFile = "data/more-data/php-partials/contract_header.phpsrc"
+    if (!localStorage.getItem("contract_header")) {
+        fetch(contractHeaderFile).then((r)=>r.text()).then((data) => {
+            localStorage.setItem("contract_header", data)
+        })
+    }
+}
+
+// trim spaces and ignore [placeholder-text]
+const trimPlaceholders = (fieldValue) => {
+    let val = fieldValue.trim()
+    if (val.startsWith("[") && val.endsWith("]"))
+        return ""
+    else
+        return val
+}
+
+
+
+const addSlashes = (str) => {
+    return str
+        .replace(/'/g, "\\'")
+}

+ 35 - 0
contracts/generator/scripts/main.js

@@ -0,0 +1,35 @@
+// init data
+
+import initFromLocalStorageOrDataFiles from "./init/set-data.js"
+initFromLocalStorageOrDataFiles()
+
+
+// components
+
+import editor from "./editor/editor.js"
+editor("#main")
+
+import signature from "./signature/signature.js"
+signature("#generator-signature-pad")
+
+
+// action buttons
+
+import download from "./download/download.js"
+download("#download-form")
+
+import preview from "./preview/preview.js"
+preview("#show-modal-preview")
+// preview("#preview-signed", true)
+
+// // static preview generation
+// // for demo and demo-signed external html files
+// import staticPreviewGeneration from "./download-preview/activate.js"
+// staticPreviewGeneration()
+
+import clearData from "./init/clear-data.js"
+clearData("#clear-local-storage")
+
+// import qrCode from "../data/more-data/scripts/qr-code.js"
+// // qrCode("#qr-code") // is already called inside the file
+// qrCode("#generator-qr-code")

+ 137 - 0
contracts/generator/scripts/preview/generate-preview.js

@@ -0,0 +1,137 @@
+import { doShortcodesFromLocalstorage } from "../utils.js";
+import previewOnly from "./preview-only.js";
+
+export default async function generatePreview(signed=false, forDownload=false, filename="contract-signed.html") {
+
+    const signatureFileEmpty = "data/more-data/signature-empty.png"
+
+    let clientSignatureData
+    if (localStorage.getItem("client_signature") !== null) { 
+        clientSignatureData = localStorage.getItem("client_signature")
+    }
+    else {
+        // todo: instantiate signature and get data; get rid of empty signature png file
+        clientSignatureData = await toDataURL(signatureFileEmpty)
+        localStorage.setItem("client_signature", clientSignatureData)
+    }
+    
+    // get css
+    let contract_css = localStorage.getItem("contract_css")
+
+    // get main from editor (don't escape single quotes)
+    let main = document.querySelector(".editor-container .ql-editor").innerHTML
+    
+    // replace each shortcode w/ its value
+    main = doShortcodesFromLocalstorage(main)
+
+    // js for unsigned contract
+    let contract_script_unsigned = `
+        <script id="contract_script_unsigned" type="module">
+            ${localStorage.getItem("contract_script_unsigned")}
+        </script>
+
+        <script id="qr_code_script" type="module">
+            ${localStorage.getItem("qr_code_script")}
+        </script>`
+
+    // js for signed contract
+    let contract_script_signed = `
+        <script id="contract_script_signed">
+            ${localStorage.getItem("contract_script_signed")}
+        </script>`
+
+    let contract_script, clientSignature_html, ui_html, compiled_signatures, previewOverrides
+
+
+    if (signed) {
+        const client_timestamp = getTimestamp()
+        const dev_timestamp = localStorage.getItem("dev_timestamp") ?? client_timestamp
+        // get IP
+        const client_ip = await fetch("https://api.ipify.org").then((data) => { return data.text() })
+        
+        contract_script = contract_script_signed
+        clientSignature_html = `<img id="hk" src="${clientSignatureData}" />`
+        ui_html = localStorage.getItem("ui_signed")
+        ui_html = `<div id="ui-unsigned">${ui_html}</div>`
+        compiled_signatures = `
+        <div class="compiled-signatures">
+            <div class="compiled-signature">
+                <strong>${localStorage.getItem("dev_name")}</strong>
+                <img id="dev_signature" src="${localStorage.getItem("dev_signature")}" />
+                <div class="date-ip">
+                    <strong>Signed on:</strong> ${dev_timestamp}
+                    <br><strong>IP address:</strong> ${client_ip} <br>
+                </div>
+            </div>
+            <div class="compiled-signature">
+                <strong>${localStorage.getItem("client_name")}</strong>
+                ${clientSignature_html}
+                <div id="date-ip" class="date-ip">
+                    <strong>Signed on:</strong> ${client_timestamp}
+                    <br><strong>IP address:</strong> ${client_ip}<br>
+                </div>
+            </div>
+        </div>`
+    }
+    else {
+        localStorage.setItem("dev_timestamp", getTimestamp())
+
+        contract_script = contract_script_unsigned
+        clientSignature_html = ""
+        ui_html = localStorage.getItem("ui_unsigned")
+        ui_html = `<div id="ui-unsigned">${ui_html}</div>`
+        compiled_signatures = `<img id="dev_signature" src="${localStorage.getItem("dev_signature")}" />`
+    }
+
+    previewOverrides = previewOnly(signed, forDownload, filename)
+
+
+    // to do: move to external file; make function w/ props
+    const output = `<!DOCTYPE html>
+    <html lang="en">
+    <head>
+        <meta charset="UTF-8">
+        <meta http-equiv="X-UA-Compatible" content="IE=edge">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <title>Signed Contract</title>
+        <style>${contract_css}</style>
+    </head>
+    <body style="overscroll-behavior: contain;">
+        <div id="content" class="ql-editor">
+
+            ${main}
+
+            ${compiled_signatures}
+
+            ${ui_html}
+
+        </div> <!-- #content -->
+
+        ${contract_script}
+        <!-- ! PREVIEW ONLY -->
+        ${previewOverrides}
+    </body>
+    </html>
+`
+
+
+    return (output)
+}
+
+const getTimestamp = () => {
+    const date = new Date()
+    let timestamp = new Intl.DateTimeFormat("en",
+        { dateStyle: "long", timeStyle: "long" }
+    ).format(date)
+    return timestamp
+}
+
+const toDataURL = url => fetch(url)
+    .then(response => response.blob())
+    .then(blob => new Promise((resolve, reject) => {
+        const reader = new FileReader()
+        reader.onloadend = () => resolve(reader.result)
+        reader.onerror = reject
+        reader.readAsDataURL(blob)
+    }))
+// from https://stackoverflow.com/a/20285053

+ 47 - 0
contracts/generator/scripts/preview/preview-only.js

@@ -0,0 +1,47 @@
+export default (signed = false, forDownload = false, filename="contract-signed.html") => {    
+    if (!forDownload) {
+
+        if (signed) return ''
+        
+        if (!signed) return `
+            <script type="module">
+                // ! PREVIEW ONLY
+                // Handle submit button in preview
+                //document.querySelector("#submit-btn")?.addEventListener("click", (e) => {
+                document.querySelector("#signature_form").addEventListener("submit", (e) => {
+                    setTimeout(function() { 
+                        // 📡 Send message to notify the parent window (received in preview.js)
+                        window.top.postMessage('previewAfterClientSigned', '*')
+                    }, 1500)
+                    
+                    e.preventDefault()
+                })
+            </script>`
+        
+    } else {
+
+        if (signed) return `
+        <script type="module">
+            // ! PREVIEW ONLY
+            let el = document.querySelector("#hk")
+            let client_signature = localStorage.getItem("client_signature")
+            el.src = client_signature
+            // update timestamp and IP here
+            // for both dev and client
+        </script>`
+
+        if (!signed) return `
+        <script type="module">
+            // ! PREVIEW ONLY
+            // Handle submit button in preview
+            //document.querySelector("#submit-btn")?.addEventListener("click", (e) => {
+            document.querySelector("#signature_form").addEventListener("submit", (e) => {
+                setTimeout(function() { 
+                    window.location.assign("${filename}#hk")
+                 }, 1500)
+                e.preventDefault()
+            })
+        </script>`
+
+    }
+}

+ 79 - 0
contracts/generator/scripts/preview/preview.js

@@ -0,0 +1,79 @@
+import generatePreview from "./generate-preview.js"
+
+
+export default function preview(selector, signed = false) {
+
+    document.querySelector(selector)?.addEventListener("click", async function (e) {
+        const contractPreview = await generatePreview(signed)
+        let iframe = document.getElementById('iframe').contentWindow.document
+
+        localStorage.removeItem("client_signature")
+
+        iframe.open()
+        iframe.write(contractPreview)
+        iframe.close()
+        
+        e.preventDefault()
+    }, false)
+
+}
+
+
+// previewAfterClientSigned
+// generate-preview.js triggers this when submit is clicked in preview iframe 📡
+
+window.onmessage = function (e) {
+    if (e.data == 'previewAfterClientSigned') {
+        previewAfterClientSigned()
+    }
+};
+
+async function previewAfterClientSigned() {
+    const contractPreview = await generatePreview(true)
+    let iframe = document.getElementById('iframe').contentWindow.document
+
+    iframe.open()
+    iframe.write(contractPreview)
+    iframe.close()
+}
+
+
+// event listeners
+// modal open and close buttons
+
+const modal = document.querySelector("#modal-preview")
+const openModal = document.querySelector("#show-modal-preview")
+const closeModal = document.querySelector("#close-modal-preview")
+
+openModal?.addEventListener("click", (e) => {
+    if (modal?.open === false)
+        modal.showModal()
+})
+
+closeModal?.addEventListener("click", (e) => {
+    modal?.close()
+})
+
+// close modal when click events happen outside of it
+modal?.addEventListener("click", (e) => {
+    const rect = modal.getBoundingClientRect()
+    if (
+        e.clientY < rect.top ||
+        e.clientY > rect.bottom ||
+        e.clientX < rect.left ||
+        e.clientX > rect.right
+    ) {
+        modal.close()
+    }
+})
+
+// // old selectors used event delegation with el.matches()
+// document.addEventListener("click", (e) => {
+//     const el = e.target
+//     if (el.matches(".preview, .preview *")) {
+//         document.querySelector('.modal-preview').classList.add('is-active');
+//     }
+//     if (el.matches(".modal-preview :is(.modal-background,.delete)")) {
+//         el.closest(".modal-preview.is-active").classList.remove("is-active")
+//     }
+// })

+ 112 - 0
contracts/generator/scripts/signature/signature.js

@@ -0,0 +1,112 @@
+import SignaturePad from "https://cdn.skypack.dev/pin/signature_pad@v4.1.3-nYxPKR50YjQN4V2vbxta/mode=imports,min/optimized/signature_pad.js"
+// 📙 Package Documentation: https://www.skypack.dev/view/signature_pad
+
+
+export default function signature(selector) {
+
+    const signatureFile = "data/signature.png"
+    const signatureFileEmpty = "data/more-data/signature-empty.png"
+
+    const canvas = document.querySelector(selector)
+
+    // https://github.com/szimek/signature_pad#options
+    const signaturePad = new SignaturePad(canvas, {
+        penColor: "hsl(200, 100%, 30%)",
+        minDistance: 2,
+    })
+
+    /**
+    if (signaturePad.isEmpty()) {
+        document.querySelector("#clear-signature").disabled = true
+    }
+    else {
+        document.querySelector("#clear-signature").disabled = false
+    }
+    /**/
+
+    resizeCanvas()
+
+
+    // event listeners
+
+    // save signature to localStorage on change
+    signaturePad.addEventListener("afterUpdateStroke", () => {
+        // console.log(signaturePad.toData())
+        setSignatureToLocalStorage()
+        document.querySelector("#clear-signature").disabled = false
+    })
+
+    // button to reset signature
+    document.querySelector("#clear-signature")?.addEventListener("click", () => {
+        signaturePad.clear()
+        // setSignatureToLocalStorage()
+        localStorage.removeItem("dev_signature")
+        document.querySelector("#clear-signature").disabled = true
+    })
+
+    window.onresize = resizeCanvas
+
+
+
+    function setSignatureToLocalStorage() {
+        let data = signaturePad.toDataURL("image/png")
+        localStorage.setItem("dev_signature", data)
+    }
+
+    function getSignatureFromLocalStorageOrFile() {
+        let data = localStorage.getItem("dev_signature");
+        if (data) {
+            // console.log(data)
+            signaturePad.fromDataURL(data)
+            disableResetButtonIfSignatureIsEmpty(data)
+        }
+        else {
+            toDataURL(signatureFile).then(data => {
+                // console.log(data)
+                localStorage.setItem("dev_signature", data)
+                signaturePad.fromDataURL(data)
+                disableResetButtonIfSignatureIsEmpty(data)
+            })
+        }
+
+        // console.log(data)
+    }
+
+    
+
+    // needed for retina displays
+    function resizeCanvas() {
+        const ratio = Math.max(window.devicePixelRatio || 1, 1)
+        canvas.width = canvas.offsetWidth * ratio
+        canvas.height = canvas.offsetHeight * ratio
+        canvas.getContext("2d").scale(ratio, ratio)
+
+        getSignatureFromLocalStorageOrFile()
+    }
+
+    function disableResetButtonIfSignatureIsEmpty(testData) {
+        toDataURL(signatureFileEmpty).then(data => {
+            // console.log(data)
+            // localStorage.setItem("dev_signature", data)
+            // signaturePad.fromDataURL(data)
+            if (testData === data) {
+                document.querySelector("#clear-signature").disabled = true
+            }
+            else {
+                document.querySelector("#clear-signature").disabled = false
+            }
+        })
+    }
+
+}
+
+
+const toDataURL = url => fetch(url)
+    .then(response => response.blob())
+    .then(blob => new Promise((resolve, reject) => {
+        const reader = new FileReader()
+        reader.onloadend = () => resolve(reader.result)
+        reader.onerror = reject
+        reader.readAsDataURL(blob)
+    }))
+// from https://stackoverflow.com/a/20285053

+ 69 - 0
contracts/generator/scripts/utils.js

@@ -0,0 +1,69 @@
+// wrapper around addEventListener with optional event type at the end
+// addEvent() usage: addEvent(element, handler, eventType)
+export const addEvent = (element, handler, eventType = "click") => {
+    element.addEventListener(eventType, handler);
+};
+
+// debounce() usage: const debouncedMyFunc = debounce(myFunc, 200);
+// or function debouncedMyFunc() { return debounce(myFunc, 200); }
+export const debounce = (callback, wait = 200) => {
+    let timeoutId = null;
+    return function (...args) {
+        window.clearTimeout(timeoutId);
+        timeoutId = window.setTimeout(() => {
+            callback.apply(null, args);
+        }, wait);
+    };
+};
+
+// addBrackets() usage: addBrackets(element)
+export const addBrackets = (element) => {
+    let trimmedValue = element.value.trim();
+
+    if (!trimmedValue.startsWith('[')) {
+        trimmedValue = '[' + trimmedValue;
+    }
+
+    if (!trimmedValue.endsWith(']')) {
+        trimmedValue = trimmedValue + ']';
+    }
+
+    element.value = trimmedValue;
+};
+
+// wrapper around querySelector
+// select() usage: const [sel1, sel2] = select(".class1", ".class2")
+// [repeaterForm, repeaterOutput, addItemButton] = select( ".repeater-form", "output", ".add-btn");
+export const select = (...selectors) => selectors.map(selector => document.querySelector(selector));
+
+// selectAll() usage: const [sel1, sel2] = selectAll(".class1", ".class2")
+// wrapper around querySelectorAll
+export const selectAll = (...selectors) => selectors.map(selector => document.querySelectorAll(selector));
+
+// Shortcodes
+export function doShortcodes(shortcodes, contentString) {
+    // Replace names with values in the provided string
+    shortcodes.forEach(item => {
+        const namePattern = new RegExp(escapeRegExp(item.name), 'g');
+        contentString = contentString.replace(namePattern, item.value);
+    });
+
+    return contentString;
+}
+export function doShortcodesFromLocalstorage(main) {
+    // localStorage.setItem('repeaterData', jsonString);
+    // localStorage.removeItem('repeaterData');
+    const shortcodesJsonString = localStorage.getItem('repeaterData');
+
+    if (shortcodesJsonString) {
+        const shortcodes = JSON.parse(shortcodesJsonString);
+        if (shortcodes[0]) { // console.log(shortcodes[0].name);
+            return doShortcodes(shortcodes, main)
+        }
+    }
+    return main;
+}
+// Function to escape special characters in a string for regex
+export function escapeRegExp(string) {
+    return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 0
contracts/generator/styles/abstracts.css


+ 67 - 0
contracts/generator/styles/button-animations.css

@@ -0,0 +1,67 @@
+/* Animation System */
+
+/* ! bug w/ grid or flex buttons */
+/* .button:where(.primary):hover:not(:active) {
+    animation: rubberband 4s ease-in-out 1;
+} */
+
+/* .button:where(.warning,.danger):hover:not(:active) {
+    animation: headshake 2.5s cubic-bezier(.445,.05,.55,.95) 50;
+} */
+
+
+@keyframes rubberband {
+    0% {
+        transform: scaleX(1)
+    }
+
+    9.9% {
+        transform: scale3d(1.125,.875,1)
+    }
+
+    13.2% {
+        transform: scale3d(.875,1.125,1)
+    }
+
+    16.5% {
+        transform: scale3d(1.075,.925,1)
+    }
+
+    21.45% {
+        transform: scale3d(.975,1.025,1)
+    }
+
+    24.75% {
+        transform: scale3d(1.025,.975,1)
+    }
+
+    33% {
+        transform: scaleX(1)
+    }
+}
+
+@keyframes headshake {
+    0% {
+        transform: translateX(0)
+    }
+
+    3.25% {
+        transform: translateX(-6px) rotateY(-9deg)
+    }
+
+    9.25% {
+        transform: translateX(5px) rotateY(7deg)
+    }
+
+    15.75% {
+        transform: translateX(-3px) rotateY(-5deg)
+    }
+
+    21.75% {
+        transform: translateX(2px) rotateY(3deg)
+    }
+
+    25% {
+        transform: translateX(0)
+    }
+}

+ 143 - 0
contracts/generator/styles/editor-ui-overrides.css

@@ -0,0 +1,143 @@
+/* editor */
+
+.editor-container {
+    height: auto;
+    min-height: 100%;
+    /* padding: 50px; */
+    /* padding-top: calc(50px + 2rem); */
+    /* padding-top: 3rem; */
+    padding-block-start: 2.75rem;
+}
+
+.editor-container .ql-editor {
+    /* font-size: 18px; */
+    overflow-y: visible; 
+    min-height: 10rem;
+}
+
+#wysiwyg-wrap {
+    height: 100%;
+    min-height: 100%;
+    overflow-y: auto;
+    padding-inline: 0;
+    overflow: hidden;
+}
+
+#toolbar-wrap {
+    position: sticky;
+    position: fixed;
+    inset: 0;
+    margin-inline: auto;
+    max-width: 100%;
+    width: inherit;
+    z-index: 1;
+    height: min-content;
+    animation-delay: 0s;
+}
+
+#toolbar {
+    min-height: 2rem;
+    position: absolute;
+    left: 0px;
+    right: 0px;
+}
+
+/* to keep it visible on iOS when the keyboard is open (js) */
+#toolbar.down {
+  transition-property: all;
+  transition-timing-function: 
+    cubic-bezier(0.4, 0, 0.2, 1);
+  transition-duration: 500ms;
+}
+
+/* .ql-toolbar.ql-snow { */
+#toolbar {
+    background-color: hsl(200deg 15% 90%);
+    background-image: var(--grain-pattern);
+    border: 1px solid hsl(36deg 20% 50% / 25%);
+    box-sizing: border-box;
+    padding: 8px;
+    /* position: fixed; */
+
+    /* color: #fff; */
+    /* z-index: 1; */
+    width: inherit;
+    max-width: 100%;
+    box-shadow: 0 2px 2px -2px hsl(200deg 3% 10% / 25%);
+
+    display: flex;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+    column-gap: 1rem;
+    padding-inline: 1rem;
+    padding-inline: clamp(20px,5vw,35px);
+    padding-inline: 2rem;
+}
+
+.ql-snow.ql-toolbar::after, 
+.ql-snow .ql-toolbar::after,
+.ql-snow .ql-formats::after {
+    display: none;
+}
+
+.ql-toolbar.ql-snow .ql-formats {
+    margin: 0;
+    /* display: flex; */
+    /* gap: .25rem; */
+}
+.ql-toolbar.ql-snow .ql-formats:nth-child(4) {
+    margin-inline-end: auto;
+}
+
+
+.ql-toolbar.ql-snow .ql-picker-label {
+    border: solid 1px;
+    border-radius: 3px;
+    border-color: #88888888;
+    /* margin-inline-start: 8px; */
+}
+
+.ql-container {
+    /* font-family: inherit;
+    font-size: inherit; */
+    /* font-family: "Libre Baskerville", serif; */
+    /* font-size:16px; */
+    /* line-height:1.5em; */
+    color:#000;
+    margin:0;
+    background:#fff;
+}
+
+.ql-editor.ql-blank::before {
+    color: rgba(0,0,0,0.6);
+    color: hsl(var(--clr-dark-hsl)/.6);
+    content: attr(data-placeholder);
+    font-style: italic;
+    margin-inline-start: 2rem;
+}
+
+
+
+
+
+/*  */
+
+
+#toolbar button,
+#toolbar select {
+    background: none;
+    border: none;
+    cursor: pointer;
+    display: inline-block;
+    float: left;
+    height: 24px;
+    padding: 3px 5px;
+    width: 28px;
+}
+
+.editor-container {
+    border: 1px solid rgb(204, 204, 204);
+    min-height: 10rem;
+    background: #fff;
+}

+ 562 - 0
contracts/generator/styles/editor-ui.css

@@ -0,0 +1,562 @@
+/* from: https://cdn.quilljs.com/1.3.6/quill.snow.css */
+
+.ql-toolbar  {
+  --clr-primary: hsl(210deg 100% 40%);
+  --clr-primary: hsl(var(--clr-primary-hsl));
+  /* --clr-primary: #990000; */
+}
+
+.ql-container.ql-disabled .ql-tooltip {
+  visibility: hidden;
+}
+.ql-container.ql-disabled .ql-editor ul[data-checked] > li::before {
+  pointer-events: none;
+}
+.ql-clipboard {
+  left: -100000px;
+  height: 1px;
+  overflow-y: hidden;
+  position: absolute;
+  top: 50%;
+}
+.ql-clipboard p {
+  margin: 0;
+  padding: 0;
+}
+
+.ql-editor {
+  outline: none;
+  tab-size: 4;
+  -moz-tab-size: 4;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+  position: relative;
+}
+
+.ql-editor > * {
+  cursor: text;
+}
+
+
+
+.ql-editor.ql-blank::before {
+  color: rgba(0,0,0,0.6);
+  content: attr(data-placeholder);
+  font-style: italic;
+  pointer-events: none;
+  left: 0;
+  right: 0;
+  position: absolute;
+}
+
+
+
+.ql-snow.ql-toolbar:after,
+.ql-snow .ql-toolbar:after {
+  clear: both;
+  content: '';
+  display: table;
+}
+.ql-snow.ql-toolbar button,
+.ql-snow .ql-toolbar button {
+  background: none;
+  border: none;
+  cursor: pointer;
+  display: inline-block;
+  float: left;
+  height: 24px;
+  padding: 3px 5px;
+  width: 28px;
+}
+.ql-snow.ql-toolbar button svg,
+.ql-snow .ql-toolbar button svg {
+  float: left;
+  height: 100%;
+}
+.ql-snow.ql-toolbar button:active:hover,
+.ql-snow .ql-toolbar button:active:hover {
+  outline: none;
+}
+.ql-snow.ql-toolbar input.ql-image[type=file],
+.ql-snow .ql-toolbar input.ql-image[type=file] {
+  display: none;
+}
+.ql-snow.ql-toolbar button:hover,
+.ql-snow .ql-toolbar button:hover,
+.ql-snow.ql-toolbar button:focus,
+.ql-snow .ql-toolbar button:focus,
+.ql-snow.ql-toolbar button.ql-active,
+.ql-snow .ql-toolbar button.ql-active,
+.ql-snow.ql-toolbar .ql-picker-label:hover,
+.ql-snow .ql-toolbar .ql-picker-label:hover,
+.ql-snow.ql-toolbar .ql-picker-label.ql-active,
+.ql-snow .ql-toolbar .ql-picker-label.ql-active,
+.ql-snow.ql-toolbar .ql-picker-item:hover,
+.ql-snow .ql-toolbar .ql-picker-item:hover,
+.ql-snow.ql-toolbar .ql-picker-item.ql-selected,
+.ql-snow .ql-toolbar .ql-picker-item.ql-selected {
+  color: var(--clr-primary, #06c);
+}
+.ql-snow.ql-toolbar button:hover .ql-fill,
+.ql-snow .ql-toolbar button:hover .ql-fill,
+.ql-snow.ql-toolbar button:focus .ql-fill,
+.ql-snow .ql-toolbar button:focus .ql-fill,
+.ql-snow.ql-toolbar button.ql-active .ql-fill,
+.ql-snow .ql-toolbar button.ql-active .ql-fill,
+.ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill,
+.ql-snow .ql-toolbar .ql-picker-label:hover .ql-fill,
+.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill,
+.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-fill,
+.ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill,
+.ql-snow .ql-toolbar .ql-picker-item:hover .ql-fill,
+.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill,
+.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-fill,
+.ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill,
+.ql-snow .ql-toolbar button:hover .ql-stroke.ql-fill,
+.ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill,
+.ql-snow .ql-toolbar button:focus .ql-stroke.ql-fill,
+.ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill,
+.ql-snow .ql-toolbar button.ql-active .ql-stroke.ql-fill,
+.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill,
+.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill,
+.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill,
+.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill,
+.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill,
+.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill,
+.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill,
+.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill {
+  fill: var(--clr-primary, #06c);
+}
+.ql-snow.ql-toolbar button:hover .ql-stroke,
+.ql-snow .ql-toolbar button:hover .ql-stroke,
+.ql-snow.ql-toolbar button:focus .ql-stroke,
+.ql-snow .ql-toolbar button:focus .ql-stroke,
+.ql-snow.ql-toolbar button.ql-active .ql-stroke,
+.ql-snow .ql-toolbar button.ql-active .ql-stroke,
+.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
+.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke,
+.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke,
+.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke,
+.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke,
+.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke,
+.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke,
+.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke,
+.ql-snow.ql-toolbar button:hover .ql-stroke-miter,
+.ql-snow .ql-toolbar button:hover .ql-stroke-miter,
+.ql-snow.ql-toolbar button:focus .ql-stroke-miter,
+.ql-snow .ql-toolbar button:focus .ql-stroke-miter,
+.ql-snow.ql-toolbar button.ql-active .ql-stroke-miter,
+.ql-snow .ql-toolbar button.ql-active .ql-stroke-miter,
+.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke-miter,
+.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke-miter,
+.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter,
+.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter,
+.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke-miter,
+.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke-miter,
+.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter,
+.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter {
+  stroke: var(--clr-primary, #06c);
+}
+@media (pointer: coarse) {
+  .ql-snow.ql-toolbar button:hover:not(.ql-active),
+  .ql-snow .ql-toolbar button:hover:not(.ql-active) {
+    color: #444;
+  }
+  .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-fill,
+  .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-fill,
+  .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill,
+  .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill {
+    fill: #444;
+  }
+  .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke,
+  .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke,
+  .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter,
+  .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter {
+    stroke: #444;
+  }
+}
+
+.ql-snow {
+  box-sizing: border-box;
+}
+.ql-snow * {
+  box-sizing: border-box;
+}
+.ql-snow .ql-hidden {
+  display: none;
+}
+.ql-snow .ql-out-bottom,
+.ql-snow .ql-out-top {
+  visibility: hidden;
+}
+.ql-snow .ql-tooltip {
+  position: absolute;
+  transform: translateY(10px);
+}
+.ql-snow .ql-tooltip a {
+  cursor: pointer;
+  text-decoration: none;
+}
+.ql-snow .ql-tooltip.ql-flip {
+  transform: translateY(-10px);
+}
+.ql-snow .ql-formats {
+  display: inline-block;
+  vertical-align: middle;
+}
+.ql-snow .ql-formats:after {
+  clear: both;
+  content: '';
+  display: table;
+}
+.ql-snow .ql-stroke {
+  fill: none;
+  stroke: #444;
+  stroke-linecap: round;
+  stroke-linejoin: round;
+  stroke-width: 2;
+}
+.ql-snow .ql-stroke-miter {
+  fill: none;
+  stroke: #444;
+  stroke-miterlimit: 10;
+  stroke-width: 2;
+}
+.ql-snow .ql-fill,
+.ql-snow .ql-stroke.ql-fill {
+  fill: #444;
+}
+.ql-snow .ql-empty {
+  fill: none;
+}
+.ql-snow .ql-even {
+  fill-rule: evenodd;
+}
+.ql-snow .ql-thin,
+.ql-snow .ql-stroke.ql-thin {
+  stroke-width: 1;
+}
+.ql-snow .ql-transparent {
+  opacity: 0.4;
+}
+.ql-snow .ql-direction svg:last-child {
+  display: none;
+}
+.ql-snow .ql-direction.ql-active svg:last-child {
+  display: inline;
+}
+.ql-snow .ql-direction.ql-active svg:first-child {
+  display: none;
+}
+
+
+
+
+
+
+
+
+
+
+.ql-snow .ql-picker {
+  color: #444;
+  display: inline-block;
+  float: left;
+  font-size: 14px;
+  font-weight: 500;
+  height: 24px;
+  position: relative;
+  vertical-align: middle;
+}
+.ql-snow .ql-picker-label {
+  cursor: pointer;
+  display: inline-block;
+  height: 100%;
+  padding-left: 8px;
+  padding-right: 2px;
+  position: relative;
+  width: 100%;
+}
+.ql-snow .ql-picker-label::before {
+  display: inline-block;
+  line-height: 22px;
+}
+.ql-snow .ql-picker-options {
+  background-color: #fff;
+  display: none;
+  min-width: 100%;
+  padding: 4px 8px;
+  position: absolute;
+  white-space: nowrap;
+}
+.ql-snow .ql-picker-options .ql-picker-item {
+  cursor: pointer;
+  display: block;
+  padding-bottom: 5px;
+  padding-top: 5px;
+}
+.ql-snow .ql-picker.ql-expanded .ql-picker-label {
+  color: #ccc;
+  z-index: 2;
+}
+.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-fill {
+  fill: #ccc;
+}
+.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke {
+  stroke: #ccc;
+}
+.ql-snow .ql-picker.ql-expanded .ql-picker-options {
+  display: block;
+  margin-top: -1px;
+  top: 100%;
+  z-index: 1;
+}
+.ql-snow .ql-color-picker,
+.ql-snow .ql-icon-picker {
+  width: 28px;
+}
+.ql-snow .ql-color-picker .ql-picker-label,
+.ql-snow .ql-icon-picker .ql-picker-label {
+  padding: 2px 4px;
+}
+.ql-snow .ql-color-picker .ql-picker-label svg,
+.ql-snow .ql-icon-picker .ql-picker-label svg {
+  right: 4px;
+}
+.ql-snow .ql-icon-picker .ql-picker-options {
+  padding: 4px 0px;
+}
+.ql-snow .ql-icon-picker .ql-picker-item {
+  height: 24px;
+  width: 24px;
+  padding: 2px 4px;
+}
+.ql-snow .ql-color-picker .ql-picker-options {
+  padding: 3px 5px;
+  width: 152px;
+}
+.ql-snow .ql-color-picker .ql-picker-item {
+  border: 1px solid transparent;
+  float: left;
+  height: 16px;
+  margin: 2px;
+  padding: 0px;
+  width: 16px;
+}
+.ql-snow .ql-picker:not(.ql-color-picker):not(.ql-icon-picker) svg {
+  position: absolute;
+  margin-top: -9px;
+  right: 0;
+  top: 50%;
+  width: 18px;
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-label]:not([data-label=''])::before,
+.ql-snow .ql-picker.ql-font .ql-picker-label[data-label]:not([data-label=''])::before,
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-label]:not([data-label=''])::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-label]:not([data-label=''])::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-label]:not([data-label=''])::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-label]:not([data-label=''])::before {
+  content: attr(data-label);
+}
+.ql-snow .ql-picker.ql-header {
+  width: 98px;
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item::before {
+  content: 'Normal';
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
+  content: 'Heading 1';
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
+  content: 'Heading 2';
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
+  content: 'Heading 3';
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
+  content: 'Heading 4';
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
+  content: 'Heading 5';
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
+  content: 'Heading 6';
+}
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
+  font-size: 2em;
+}
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
+  font-size: 1.5em;
+}
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
+  font-size: 1.17em;
+}
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
+  font-size: 1em;
+}
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
+  font-size: 0.83em;
+}
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
+  font-size: 0.67em;
+}
+.ql-snow .ql-picker.ql-font {
+  width: 108px;
+}
+.ql-snow .ql-picker.ql-font .ql-picker-label::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item::before {
+  content: 'Sans Serif';
+}
+.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=serif]::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before {
+  content: 'Serif';
+}
+.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=monospace]::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before {
+  content: 'Monospace';
+}
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before {
+  font-family: Georgia, Times New Roman, serif;
+}
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before {
+  font-family: Monaco, Courier New, monospace;
+}
+.ql-snow .ql-picker.ql-size {
+  width: 98px;
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item::before {
+  content: 'Normal';
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=small]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before {
+  content: 'Small';
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=large]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before {
+  content: 'Large';
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=huge]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before {
+  content: 'Huge';
+}
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before {
+  font-size: 10px;
+}
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before {
+  font-size: 18px;
+}
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before {
+  font-size: 32px;
+}
+.ql-snow .ql-color-picker.ql-background .ql-picker-item {
+  background-color: #fff;
+}
+.ql-snow .ql-color-picker.ql-color .ql-picker-item {
+  background-color: #000;
+}
+.ql-toolbar.ql-snow {
+  border: 1px solid #ccc;
+  box-sizing: border-box;
+  font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
+  padding: 8px;
+}
+.ql-toolbar.ql-snow .ql-formats {
+  margin-right: 15px;
+}
+.ql-toolbar.ql-snow .ql-picker-label {
+  border: 1px solid transparent;
+}
+.ql-toolbar.ql-snow .ql-picker-options {
+  border: 1px solid transparent;
+  box-shadow: rgba(0,0,0,0.2) 0 2px 8px;
+}
+.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label {
+  border-color: #ccc;
+}
+.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options {
+  border-color: #ccc;
+}
+.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item.ql-selected,
+.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item:hover {
+  border-color: #000;
+}
+.ql-toolbar.ql-snow + .ql-container.ql-snow {
+  border-top: 0px;
+}
+.ql-snow .ql-tooltip {
+  background-color: #fff;
+  border: 1px solid #ccc;
+  box-shadow: 0px 0px 5px #ddd;
+  color: #444;
+  padding: 5px 12px;
+  white-space: nowrap;
+}
+.ql-snow .ql-tooltip::before {
+  content: "Visit URL:";
+  line-height: 26px;
+  margin-right: 8px;
+}
+.ql-snow .ql-tooltip input[type=text] {
+  display: none;
+  border: 1px solid #ccc;
+  font-size: 13px;
+  height: 26px;
+  margin: 0px;
+  padding: 3px 5px;
+  width: 170px;
+}
+.ql-snow .ql-tooltip a.ql-preview {
+  display: inline-block;
+  max-width: 200px;
+  overflow-x: hidden;
+  text-overflow: ellipsis;
+  vertical-align: top;
+}
+.ql-snow .ql-tooltip a.ql-action::after {
+  border-right: 1px solid #ccc;
+  content: 'Edit';
+  margin-left: 16px;
+  padding-right: 8px;
+}
+.ql-snow .ql-tooltip a.ql-remove::before {
+  content: 'Remove';
+  margin-left: 8px;
+}
+.ql-snow .ql-tooltip a {
+  line-height: 26px;
+}
+.ql-snow .ql-tooltip.ql-editing a.ql-preview,
+.ql-snow .ql-tooltip.ql-editing a.ql-remove {
+  display: none;
+}
+.ql-snow .ql-tooltip.ql-editing input[type=text] {
+  display: inline-block;
+}
+.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
+  border-right: 0px;
+  content: 'Save';
+  padding-right: 0px;
+}
+.ql-snow .ql-tooltip[data-mode=link]::before {
+  content: "Enter link:";
+}
+.ql-snow .ql-tooltip[data-mode=formula]::before {
+  content: "Enter formula:";
+}
+.ql-snow .ql-tooltip[data-mode=video]::before {
+  content: "Enter video:";
+}
+/* .ql-snow a {
+  color: var(--clr-primary, #06c);
+} */
+.ql-container.ql-snow {
+  border: 1px solid #ccc;
+}

+ 136 - 0
contracts/generator/styles/generator.css

@@ -0,0 +1,136 @@
+/* general  */
+
+html {
+  scroll-behavior: smooth;
+}
+
+body {
+    background: hsl(36deg 10% 95%);
+    font-family:'Open Sans', sans-serif;
+    margin:0;
+    padding:1.2em 0;
+    padding-top:0;
+    padding-bottom:0;
+    padding-bottom:0;
+    font-size:16px;
+
+    display: flex;
+    flex-direction: column;
+    min-height: 100vh;
+}
+
+.below-contract {
+    background: inherit;
+    position: relative;
+    z-index: 3;
+}
+
+.signature-area {
+    /* width: 52em; */
+    /* width: 210mm; */
+    max-width: 100%;
+    padding: 2.5rem 1.75rem 2.5rem 1.75rem;
+    box-sizing: border-box;
+    margin: auto;
+    margin-bottom: 1.5em;
+    margin-top: -1px;
+    background: hsl(36deg 10% 90%);
+    background-color: hsl(165.32deg 15% 90%);
+    background-color: hsl(200deg 15% 90%);
+
+    border: solid 1px hsl(200deg 10% 50% / 25%);
+    border-top: solid 2px hsl(200deg 10% 40%);
+    box-shadow: 0 2px 1px -1px hsl(200deg 3% 5% / 35%);
+
+    border: 0;
+    border-inline: solid 1px hsl(200deg 10% 50% / 25%);
+    border-block-start: solid 2px hsl(200deg 10% 40%);
+    box-shadow: 0 2px 1px -1px hsl(200deg 10% 75%);
+    background-image: var(--grain-pattern);
+
+    border-radius: max(0px, min(1em, calc((100vw - 4px - 100%) * 9999)));
+    border-top-left-radius: 0;
+    border-top-right-radius: 0;
+    position: relative;
+}
+@media (min-width:800px) {
+    .signature-area::before, .signature-area::after {
+        position: absolute;
+        z-index: -1;
+        content: "";
+        width: 40%;
+        height: 10px;
+        bottom: 10px;
+        background: transparent;
+        box-shadow: 0 8px 12px rgb(0 0 0 / 15%);
+    }
+    .signature-area::before {
+        left: 20px;
+        transform: skew(-3deg) rotate(-3deg);
+    }
+    .signature-area::after {
+        right: 20px;
+        transform: skew(3deg) rotate(3deg);
+    }
+}
+
+.generator-options {
+    margin-bottom: 3em;
+    background: inherit;
+    display: flex;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: 5rem;
+    row-gap: 0rem;
+    width: 50rem;
+    padding-inline: 1rem;
+    /* margin-block-start: 0;
+    padding-block-start: var(--flow-space, 1em); */
+
+    width: 52rem;
+    padding-inline: 1.5rem;
+    width: 53rem;
+
+    h1, h2, h3, h4, h5, h6 {
+        color: var(--clr-blue-desaturated-600);
+    }
+}
+
+.generator-main-options {
+    width: clamp(20rem, 28rem, 100%);
+    width: clamp(20rem, 29rem, calc(100% + 2rem));
+    /* margin-inline: -.75rem; */
+    margin-block-start: 1rem;
+}
+
+.sticky-1 {
+    position: sticky;
+    top: 1rem;
+    align-self: start;
+}
+
+/* .generator-download-options {
+    display: grid; 
+    height: min-content;
+} */
+
+.flexi:has(#download) {
+    margin-inline-start: -0.05rem;
+}
+
+.grid:has(#contract_filename) label {
+        margin-inline-start: -1rem;
+
+    @media (width >= 836px) {
+        margin-inline-start: -1.7rem;
+    }
+}
+
+
+.footer {
+    --flow-space: .5rem;
+
+    width: 100%;
+    text-align: center;
+    font-size: .9rem;
+}

+ 2 - 0
contracts/generator/styles/highlight.min.css

@@ -0,0 +1,2 @@
+/* from https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.2/styles/github.min.css */
+.hljs{display:block;overflow-x:auto;padding:.5em;color:#333;background:#f8f8f8}.hljs-comment,.hljs-quote{color:#998;font-style:italic}.hljs-keyword,.hljs-selector-tag,.hljs-subst{color:#333;font-weight:700}.hljs-literal,.hljs-number,.hljs-tag .hljs-attr,.hljs-template-variable,.hljs-variable{color:teal}.hljs-doctag,.hljs-string{color:#d14}.hljs-section,.hljs-selector-id,.hljs-title{color:#900;font-weight:700}.hljs-subst{font-weight:400}.hljs-class .hljs-title,.hljs-type{color:#458;font-weight:700}.hljs-attribute,.hljs-name,.hljs-tag{color:navy;font-weight:400}.hljs-link,.hljs-regexp{color:#009926}.hljs-bullet,.hljs-symbol{color:#990073}.hljs-built_in,.hljs-builtin-name{color:#0086b3}.hljs-meta{color:#999;font-weight:700}.hljs-deletion{background:#fdd}.hljs-addition{background:#dfd}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů