Get contact form working again with new style form and API
authorNick Downing <nick@ndcode.org>
Tue, 18 Jan 2022 02:36:50 +0000 (13:36 +1100)
committerNick Downing <nick@ndcode.org>
Tue, 18 Jan 2022 02:36:50 +0000 (13:36 +1100)
_lib/navbar.jst
api/contact/get_draft.json.jst [new file with mode: 0644]
api/contact/send_enquiry.json.jst [new file with mode: 0644]
api/contact/set_draft.json.jst [new file with mode: 0644]
contact/index.html.jst

index 7a96065..32cce07 100644 (file)
@@ -254,7 +254,7 @@ return async (env, head, body, scripts) => {
                 div.col-md-12 {
                   div.form-group {
                     label.form-label(for="feedback-message") {'Message'}
-                    textarea.form-control#feedback-message(placeholder="Please tell us your thoughts" rows="4" required="required") {}
+                    textarea.form-control#feedback-message(placeholder="Please tell us your thoughts" required="required" rows=4 maxlength=65536) {}
                   }
                 }
               }
diff --git a/api/contact/get_draft.json.jst b/api/contact/get_draft.json.jst
new file mode 100644 (file)
index 0000000..138c55a
--- /dev/null
@@ -0,0 +1,51 @@
+let logjson = (await import('@ndcode/logjson')).default
+let XDate = require('xdate')
+
+return async env => {
+  let post_request = await _require('/_lib/post_request.jst')
+  let session_cookie = await _require('/_lib/session_cookie.jst')
+  let Problem = await _require('/_lib/Problem.jst')
+
+  await post_request(
+    // env
+    env,
+    // handler
+    async () => {
+      let transaction = await env.site.database.Transaction()
+      try {
+        // initialize env.session_key, set cookie in env.response
+        let session = await session_cookie(env, transaction)
+
+        let contact_draft = await session.get('contact_draft')
+        let details =
+          contact_draft !== undefined &&
+            XDate.now() < await logjson.logjson_to_json(
+              await contact_draft.get('expires')
+            ) ? {
+              given_names: await logjson.logjson_to_json(
+                await contact_draft.get('given_names')
+              ),
+              family_name: await logjson.logjson_to_json(
+                await contact_draft.get('family_name')
+              ),
+              company: await logjson.logjson_to_json(
+                await contact_draft.get('company')
+              ),
+              email: await logjson.logjson_to_json(
+                await contact_draft.get('email')
+              ),
+              message: await logjson.logjson_to_json(
+                await contact_draft.get('message')
+              )
+            } : null
+
+        await transaction.commit()
+        return details
+      }
+      catch (error) {
+        transaction.rollback()
+        throw error
+      }
+    }
+  )
+}
diff --git a/api/contact/send_enquiry.json.jst b/api/contact/send_enquiry.json.jst
new file mode 100644 (file)
index 0000000..b26531f
--- /dev/null
@@ -0,0 +1,68 @@
+let crypto = require('crypto')
+let logjson = (await import('@ndcode/logjson')).default
+let XDate = require('xdate')
+
+return async env => {
+  let globals = await env.site.get_json('/_config/globals.json')
+  let nodemailer_contact = await env.site.get_nodemailer(
+    '/_config/nodemailer_contact.json'
+  )
+  let post_request = await _require('/_lib/post_request.jst')
+  let session_cookie = await _require('/_lib/session_cookie.jst')
+  let Problem = await _require('/_lib/Problem.jst')
+
+  await post_request(
+    // env
+    env,
+    // handler
+    async details => {
+      // coerce and/or validate
+      details = {
+        given_names: details.given_names.slice(0, 256),
+        family_name: details.family_name.slice(0, 256),
+        company: details.company.slice(0, 256),
+        email: details.email.slice(0, 256).toLowerCase(),
+        message: details.message.slice(0, 65536),
+      }
+      if (
+        details.given_names.length === 0 ||
+          details.email.length === 0 ||
+          details.message.length === 0
+      )
+        throw new Problem(
+          'Bad request',
+          'Minimum length check failed',
+          400
+        )
+
+      let transaction = await env.site.database.Transaction()
+      try {
+        // initialize env.session_key, set cookie in env.response
+        session_cookie(env, transaction)
+        await transaction.commit()
+      }
+      catch (error) {
+        transaction.rollback()
+        throw error
+      }
+
+      let name =
+        details.family_name.length ?
+          `${details.given_names} ${details.family_name}` :
+          details.given_names
+      let subject =
+        details.company.length ?
+          `Enquiry: ${details.company}` :
+          'Enquiry'
+      await nodemailer_contact.sendMail(
+        {
+          from: globals.contact_from,
+          to: globals.contact_to,
+          replyTo: `${name} <${details.email}>`,
+          subject: subject,
+          text: details.message
+        }
+      )
+    }
+  )
+}
diff --git a/api/contact/set_draft.json.jst b/api/contact/set_draft.json.jst
new file mode 100644 (file)
index 0000000..e6facb7
--- /dev/null
@@ -0,0 +1,56 @@
+let XDate = require('xdate')
+
+return async env => {
+  let post_request = await _require('/_lib/post_request.jst')
+  let session_cookie = await _require('/_lib/session_cookie.jst')
+  let Problem = await _require('/_lib/Problem.jst')
+
+  await post_request(
+    // env
+    env,
+    // handler
+    async details => {
+      // coerce and/or validate
+      if (details !== null)
+        details = {
+          given_names: details.given_names.slice(0, 256),
+          family_name: details.family_name.slice(0, 256),
+          company: details.company.slice(0, 256),
+          email: details.email.slice(0, 256).toLowerCase(),
+          message: details.message.slice(0, 65536),
+        }
+
+      let transaction = await env.site.database.Transaction()
+      try {
+        // initialize env.session_key, set cookie in env.response
+        let session = await session_cookie(env, transaction)
+
+        if (details) {
+          let expires = new XDate()
+          expires.addDays(1)
+          session.set(
+            'contact_draft',
+            transaction.json_to_logjson(
+              {
+                given_names: details.given_names,
+                family_name: details.family_name,
+                company: details.company,
+                email: details.email,
+                message: details.message,
+                expires: expires.getTime()
+              }
+            )
+          )
+        }
+        else
+          session.delete('contact_draft')
+
+        await transaction.commit()
+      }
+      catch (error) {
+        transaction.rollback()
+        throw error
+      }
+    }
+  )
+}
index d74ae78..bb28ef7 100644 (file)
-let querystring = require('querystring')
-let stream_buffers = require('stream-buffers')
+let logjson = (await import('@ndcode/logjson')).default
 let XDate = require('xdate')
 
 return async env => {
   let breadcrumbs = await _require('/_lib/breadcrumbs.jst')
-  let globals = await env.site.get_json('/_config/globals.json')
+  let icon_cross = await env.site.get_min_svg('/_svg/icon_cross.svg')
+  let icon_tick = await env.site.get_min_svg('/_svg/icon_tick.svg')
   let navbar = await _require('/_lib/navbar.jst')
+  let session_cookie = await _require('/_lib/session_cookie.jst')
+
+  // preload draft details if any
+  let transaction = await env.site.database.Transaction(), draft_details
+  try {
+    // initialize env.session_key, set cookie in env.response
+    let session = await session_cookie(env, transaction)
+
+    let contact_draft = await session.get('contact_draft')
+    draft_details =
+      contact_draft !== undefined &&
+        XDate.now() < await logjson.logjson_to_json(
+          await contact_draft.get('expires')
+        ) ? {
+          email: await logjson.logjson_to_json(
+            await contact_draft.get('email')
+          ),
+          given_names: await logjson.logjson_to_json(
+            await contact_draft.get('given_names')
+          ),
+          family_name: await logjson.logjson_to_json(
+            await contact_draft.get('family_name')
+          ),
+          company: await logjson.logjson_to_json(
+            await contact_draft.get('company')
+          ),
+          email: await logjson.logjson_to_json(
+            await contact_draft.get('email')
+          ),
+          message: await logjson.logjson_to_json(
+            await contact_draft.get('message')
+          )
+        } : null
+
+    await transaction.commit()
+  }
+  catch (error) {
+    transaction.rollback()
+    throw error
+  }
+  console.log('draft_details', JSON.stringify(draft_details))
 
   await navbar(
     env,
+    // head
     async _out => {},
+    // body
     async _out => {
       await breadcrumbs(env, _out)
 
-      if (env.request.method == 'POST') {
-        let write_stream = new stream_buffers.WritableStreamBuffer()
-        let data = new Promise(
-          (resolve, reject) => {
-            write_stream.
-            on('finish', () => {resolve(write_stream.getContents())}).
-            on('error', () => {reject()})
-          }
-        )
-        env.request.pipe(write_stream)
-        let query = querystring.parse((await data).toString())
-        console.log('received contact form:', query.email)
-
-        // save the form contents in a dated logfile, so that we can
-        // recover manually if the email doesn't send for some reason
-        date = new XDate()
-        query.date = date.toUTCString()
-
-        let transaction = await env.site.database.Transaction()
-        ;(
-          await (
-            await (
-              await (
-                await transaction.get({})
-              ).get('logs', {})
-            ).get(date.toUTCString('yyyyMMdd'), {})
-          ).get('contact', [])
-        ).push(transaction.json_to_logjson(query))
-        transaction.commit()
-
-        // send email (asynchronously)
-        let emailjs_contact = await env.site.get_emailjs(
-          '/_config/email_contact.json'
-        )
-        emailjs_contact.send(
-          {
-            from: globals.contact_from,
-            'reply-to':
-              `${query.first_name} ${query.last_name} <${query.email}>`,
-            to: globals.contact_to,
-            subject:
-              Object.prototype.hasOwnProperty.call(query, 'company') ?
-              'Enquiry: ' + query.company :
-              'Enquiry',
-            text: query.message
-          },
-          (err, message) => {
-            if (err)
-              console.error(err.stack || err.message)
-            else
-              console.log('sent contact email:', query.email)
-          }
-        ) // ignore returned promise
+      p {'Do you require more information, or assistance with integrating the projects on this site? We’d love to hear from you.'}
 
-        p {'Thanks! We\'ll be in touch as soon as we can.'}
-      }
-      else {
-        p {'Do you require more information or consulting assistance with integrating the projects on this site? We\'d love to hear from you.'}
-
-        form#contact-form(method="post" action="index.html" role="form") {
-          div.row {
-            div.col-md-6 {
-              div.form-group {
-                label(for="contact_form_first_name") {'First name *'}
-                input.form-control#contact_form_first_name(type="text" name="first_name" placeholder="Please enter your first name" required="required" data-error="First name is required.") {}
-                div.help-block.with-errors {}
-              }
+      div.accordion#accordion.mb-5(role="tablist" aria-multiselectable="true") {
+        div.card#step-1 {
+          div.card-header#step-1-heading(role="tab") {
+            span#step-1-tick(style="display: none;") {
+              span.icon-color.pr-3 {_out.push(icon_tick)}
             }
-            div.col-md-6 {
-              div.form-group {
-                label(for="contact_form_last_name") {'Last name *'}
-                input.form-control#contact_form_last_name(type="text" name="last_name" placeholder="Please enter your last name" required="required" data-error="Last name is required.") {}
-                div.help-block.with-errors {}
-              }
+            span#step-1-cross(style="display: none;") {
+              span.icon-color.pr-3 {_out.push(icon_cross)}
+            }
+            //span#step-1-spinner(style="display: none;") {
+            //  span.icon-color.pr-3 {
+            //    div.spinner-border(role="status") {
+            //      span.sr-only {'Loading...'}
+            //    }
+            //  }
+            //}
+            a.h5(data-toggle="collapse" data-parent="#accordion" href="#step-1-collapse" aria-expanded="true" aria-controls="step-1-collapse") {
+              'Enquiry details'
             }
           }
-          div.row {
-            div.col-md-6 {
-              div.form-group {
-                label(for="contact_form_company") {'Company'}
-                input.form-control#contact_form_company(type="text" name="company" placeholder="Please enter your company") {}
-                div.help-block.with-errors {}
+          div#step-1-collapse.collapse.show(role="tabpanel" aria-labelledby="step-1-heading" data-parent="#accordion") {
+            div.card-body {
+              div.row {
+                div.col-md-6 {
+                  div.form-group {
+                    label.form-label(for="given-names") {'Given names *'}
+                    input.form-control#given-names(type="text" value=draft_details ? draft_details.given_names : '' placeholder="Your given names" required="required" maxlength=256) {}
+                  }
+                }
+                div.col-md-6 {
+                  div.form-group {
+                    label.form-label(for="family-name") {'Family name'}
+                    input.form-control#family-name(type="text" value=draft_details ? draft_details.family_name : '' placeholder="Your family name" maxlength=256) {}
+                  }
+                }
               }
-            }
-            div.col-md-6 {
-              div.form-group {
-                label(for="contact_form_email") {'Email *'}
-                input.form-control#contact_form_email(type="email" name="email" placeholder="Please enter your email" required="required" data-error="Valid email is required.") {}
-                div.help-block.with-errors {}
+              div.row {
+                div.col-md-6 {
+                  div.form-group {
+                   label.form-label(for="company") {'Company'}
+                    input.form-control#company(type="company" value=draft_details ? draft_details.company : '' placeholder="Your company" maxlength=256) {}
+                  }
+                }
+                div.col-md-6 {
+                  div.form-group {
+                   label.form-label(for="email") {'Email *'}
+                    input.form-control#email(type="email" value=draft_details ? draft_details.email : '' placeholder="Your email address" required="required" maxlength=256) {}
+                  }
+                }
+              }
+              div.row {
+                div.col-md-12 {
+                  div.form-group {
+                    label.form-label(for="message") {'Message *'}
+                    textarea.form-control#message(placeholder="Your message" required="required" rows=6 maxlength=65536) {
+                      if (draft_details)
+                        `${draft_details.message}`
+                    }
+                  }
+                }
               }
+
+              button.btn.btn-success#step-1-continue(type="button") {'Continue'}
+              p.'mt-3'.mb-0 {'* These fields are required.'}
             }
           }
-          div.row {
-            div.col-md-12 {
-              div.form-group {
-                label(for="contact_form_message") {'Message *'}
-                textarea.form-control#contact_form_message(name="message" placeholder="Please explain your application" rows="4" required="required" data-error="Please, leave us a message.") {}
-                div.help-block.with-errors {}
+        }
+        div.card#step-2 {
+          div.card-header#step-2-heading(role="tab") {
+            span#step-2-tick(style="display: none;") {
+              span.icon-color.pr-3 {_out.push(icon_tick)}
+            }
+            span#step-2-cross(style="display: none;") {
+              span.icon-color.pr-3 {_out.push(icon_cross)}
+            }
+            span#step-2-spinner(style="display: none;") {
+              span.icon-color.pr-3 {
+                div.spinner-border(role="status") {
+                  span.sr-only {'Loading...'}
+                }
               }
             }
-          }
-          p {} // fix this later
-          div.row {
-            div.col-md-12 {
-              button.btn.btn-success.btn-send(type="submit") {'Send message'}
+            a.h5.collapsed(data-toggle="collapse" data-parent="#accordion" href="#step-2-collapse" aria-expanded="false" aria-controls="step-2-collapse") {
+              'Send enquiry'
             }
           }
-          p {} // fix this later
-          div.row {
-            div.col-md-12 {
-              p.text-muted {
-                strong {'*'}
-                'These fields are required.'
-                //'Contact form template by '
-                //a(href="https://bootstrapious.com/p/how-to-build-a-working-bootstrap-contact-form" target="_blank") {'Bootstrapious'}
-                //'.'
-              }
+          div#step-2-collapse.collapse(role="tabpanel" aria-labelledby="step-2-heading" data-parent="#accordion") {
+            div.card-body {
+              p#step-2-message {'Please enter enquiry details first.'}
+
+              button.btn.btn-outline-secondary#step-2-back(type="button") {'Back'}
+              button.btn.btn-outline-secondary.ml-3#step-2-resend-enquiry(type="button") {'Re-send enquiry'}
             }
           }
         }
       }
     },
-    async _out => {}
+    // scripts
+    async _out => {
+      //script(src="/js/api_call.js") {}
+
+      script {
+        //let api_contact_get_draft = async (...arguments) => api_call(
+        //  '/api/contact/get_draft.json',
+        //  ...arguments
+        //)
+        let api_contact_set_draft = async (...arguments) => api_call(
+          '/api/contact/set_draft.json',
+          ...arguments
+        )
+        let api_contact_send_enquiry = async (...arguments) => api_call(
+          '/api/contact/send_enquiry.json',
+          ...arguments
+        )
+
+        let draft_timeout_running = false
+        let draft_timeout_handler = async () => {
+          draft_timeout_running = false
+          await api_contact_set_draft(
+            {
+              given_names: document.getElementById('given-names').value.slice(0, 256),
+              family_name: document.getElementById('family-name').value.slice(0, 256),
+              company: document.getElementById('company').value.slice(0, 256),
+              email: document.getElementById('email').value.slice(0, 256).toLowerCase(),
+              message: document.getElementById('message').value.slice(0, 65536)
+            }
+          )
+          //console.log('draft', await api_contact_get_draft())
+        }
+        let draft_change_handler = () => {
+          if (!draft_timeout_running) {
+            draft_timeout_running = true
+            setTimeout(draft_timeout_handler, 5000)
+          }
+        }
+
+        let details
+        let step_1 = async () => {
+          if (
+            !document.getElementById('given-names').reportValidity() ||
+              !document.getElementById('family-name').reportValidity() ||
+              !document.getElementById('company').reportValidity() ||
+              !document.getElementById('email').reportValidity() ||
+              !document.getElementById('message').reportValidity()
+          ) {
+            $('#step-1-tick').hide()
+            $('#step-1-cross').show()
+            //$('#step-1-spinner').hide()
+            return false
+          }
+          $('#step-1-tick').show()
+          $('#step-1-cross').hide()
+          //$('#step-1-spinner').hide()
+
+          details = {
+            given_names: document.getElementById('given-names').value.slice(0, 256),
+            family_name: document.getElementById('family-name').value.slice(0, 256),
+            company: document.getElementById('company').value.slice(0, 256),
+            email: document.getElementById('email').value.slice(0, 256).toLowerCase(),
+            message: document.getElementById('message').value.slice(0, 65536)
+          }
+          return true
+        }
+
+        let step_2 = async () => {
+          $('#step-2-tick').hide()
+          $('#step-2-cross').hide()
+          $('#step-2-spinner').show()
+          document.getElementById('step-2').scrollIntoView()
+
+          try {
+            await api_contact_send_enquiry(details)
+          }
+          catch (error) {
+            let problem =
+              error instanceof Problem ?
+                error :
+                new Problem(
+                  // title
+                  'Bad request',
+                  // detail
+                  (error.stack || error.message).toString()
+                  // status
+                  400
+                )
+
+            $('#step-2-tick').hide()
+            $('#step-2-cross').show()
+            $('#step-2-spinner').hide()
+
+            document.getElementById('step-2-message').textContent = problem.detail
+            $('#step-2-collapse').collapse('show')
+            return false
+          }
+          $('#step-2-tick').show()
+          $('#step-2-cross').hide()
+          $('#step-2-spinner').hide()
+
+          document.getElementById('step-2-message').textContent = 'We have received your enquiry. We will be in touch as soon as possible.'
+          return true
+        }
+
+        document.addEventListener(
+          'DOMContentLoaded',
+          () => {
+            document.getElementById('given-names').addEventListener(
+              'change',
+              draft_change_handler
+            )
+            document.getElementById('family-name').addEventListener(
+              'change',
+              draft_change_handler
+            )
+            document.getElementById('company').addEventListener(
+              'change',
+              draft_change_handler
+            )
+            document.getElementById('email').addEventListener(
+              'change',
+              draft_change_handler
+            )
+            document.getElementById('message').addEventListener(
+              'change',
+              draft_change_handler
+            )
+
+            document.getElementById('step-1-continue').addEventListener(
+              'click',
+              async () => {
+                if (await step_1() && await step_2())
+                  $('#step-2-collapse').collapse('show')
+              }
+            )
+
+            document.getElementById('step-2-back').addEventListener(
+              'click',
+              () => {$('#step-1-collapse').collapse('show')}
+            )
+
+            document.getElementById('step-2-resend-enquiry').addEventListener(
+              'click',
+              async () => {
+                if (await step_2())
+                  $('#step-2-collapse').collapse('show')
+              }
+            )
+          }
+        )
+      }
+    }
   )
 }