diff --git a/http/public/scripts/contact/post-error-message.js b/http/public/scripts/contact/post-error-message.js new file mode 100644 index 0000000..121cbb6 --- /dev/null +++ b/http/public/scripts/contact/post-error-message.js @@ -0,0 +1,34 @@ +/** @typedef {import("./selectors.js").Selectors} Selectors */ + +/** + * @param {{contactForm: Selectors}} contactForm + * @param {string} errorMsg + */ +export function postErrorMessageOnContactForm({ contactForm }, errorMsg) { + const errorElem = contactForm.errorElem(); + if (errorElem) { + errorElem.textContent = errorMsg; + errorElem.removeAttribute("hidden"); + } else { + alert(errorMsg); + } +} + +/** + * @param {{otpDialog: Selectors}} otpDialog + * @param {string} errorMsg + */ +export function postErrorMessageOnOtpForm({ otpDialog }, errorMsg) { + const errorElem = otpDialog.errorElem(); + if (errorElem) { + errorElem.textContent = errorMsg; + errorElem.removeAttribute("hidden"); + } else { + alert(errorMsg); + } + /** @type NodeListOf */ + const inputs = otpDialog.allOtpInputs(); + for (const input of inputs) { + input.value = ""; + } +} diff --git a/http/public/scripts/contact/resend-otp.js b/http/public/scripts/contact/resend-otp.js new file mode 100644 index 0000000..e083d95 --- /dev/null +++ b/http/public/scripts/contact/resend-otp.js @@ -0,0 +1,31 @@ +/** @typedef {import("./selectors.js").Selectors} Selectors */ + +import { actions } from "./actions.js"; +import { resetResendButton } from "./reset-resend-button.js"; +import { postErrorMessageOnOtpForm } from "./post-error-message.js"; + +/** @typedef {{ resendButtonInterval: NodeJS.Timeout | undefined }} Result */ + +const fallbackErrorMsg = + "No can do. I'm afraid joeac.net is a bit broken right now - sorry about that."; + +/** + * @param {Selectors} selectors + * @param {NodeJS.Timeout | undefined} resetButtonInterval + * @returns {Promise} + */ +export async function resendOtp(selectors, resetButtonInterval) { + const result = resetResendButton(selectors, resetButtonInterval); + const name = selectors.contactForm.nameElem()?.value; + const email = selectors.contactForm.emailElem().value; + + try { + await actions.sendOtp({ type: "email", name, email }); + } catch (sendOtpError) { + const errorMsg = sendOtpError?.toString() ?? fallbackErrorMsg; + postErrorMessageOnOtpForm(selectors, errorMsg); + throw sendOtpError; + } + + return result; +} diff --git a/http/public/scripts/contact/reset-resend-button.js b/http/public/scripts/contact/reset-resend-button.js new file mode 100644 index 0000000..47bc9ac --- /dev/null +++ b/http/public/scripts/contact/reset-resend-button.js @@ -0,0 +1,32 @@ +/** @typedef {import("./selectors.js").Selectors} Selectors */ + +/** @typedef {{ resendButtonInterval: NodeJS.Timeout | undefined }} Result */ + +/** + * @param {Selectors} selectors + * @param {NodeJS.Timeout | undefined} resetButtonInterval + * @returns {Result} + */ +export function resetResendButton({ otpDialog }, resendButtonInterval) { + clearInterval(resendButtonInterval); + + const resendButton = otpDialog.resendButton(); + if (resendButton) { + resendButton.setAttribute("data-countdown", "60"); + resendButton.setAttribute("disabled", ""); + + resendButtonInterval = setInterval(() => { + const countdown = +(resendButton.getAttribute("data-countdown") ?? 1) - 1; + resendButton.setAttribute("data-countdown", countdown.toString()); + resendButton.textContent = `Resend (${countdown}s)`; + }, 1000); + + setTimeout(() => { + clearInterval(resendButtonInterval); + resendButton.textContent = "Resend"; + resendButton.removeAttribute("disabled"); + }, 1000 * 60); + } + + return { resendButtonInterval }; +} diff --git a/http/public/scripts/contact/selectors.js b/http/public/scripts/contact/selectors.js new file mode 100644 index 0000000..7676414 --- /dev/null +++ b/http/public/scripts/contact/selectors.js @@ -0,0 +1,25 @@ +/** + * @typedef {Object} Selectors + * @property {Object} contactForm + * @property {() => HTMLInputElement} contactForm.emailElem + * @property {() => Element | null} contactForm.errorElem + * @property {() => HTMLInputElement} contactForm.nameElem + * @property {() => HTMLTextAreaElement} contactForm.messageElem + * @property {() => HTMLFormElement | null} contactForm.self + * @property {() => HTMLInputElement | null} contactForm.submitButton + * @property {Object} otpDialog + * @property {() => NodeListOf} otpDialog.allOtpInputs + * @property {() => Element | null} otpDialog.errorElem + * @property {() => HTMLInputElement | null} otpDialog.firstOtpInput + * @property {() => HTMLFormElement | null} otpDialog.otpForm + * @property {() => Element | null} otpDialog.otpRecipient + * @property {() => Element | null} otpDialog.otpValidUntil + * @property {() => HTMLButtonElement | null} otpDialog.resendButton + * @property {() => HTMLDialogElement} otpDialog.self + * @property {() => HTMLInputElement | null} otpDialog.submitButton + * @property {Object} successSection + * @property {() => Element | null} successSection.email + * @property {() => Element | null} successSection.message + * @property {() => Element | null} successSection.name + * @property {() => Element | null} successSection.self + */ diff --git a/http/public/scripts/contact/submit-contact-form.js b/http/public/scripts/contact/submit-contact-form.js new file mode 100644 index 0000000..a90f420 --- /dev/null +++ b/http/public/scripts/contact/submit-contact-form.js @@ -0,0 +1,53 @@ +import { actions } from "./actions.js"; +import { postErrorMessageOnContactForm } from "./post-error-message.js"; +import { resetResendButton } from "./reset-resend-button.js"; + +/** @typedef {import("./selectors.js").Selectors} Selectors */ + +const fallbackErrorMsg = + "No can do. I'm afraid joeac.net is a bit broken right now - sorry about that."; + +/** + * @param {Selectors} selectors + * @param {NodeJS.Timeout | undefined} resetButtonInterval + * @returns {Promise} + */ +export async function submitContactForm(selectors, resendButtonInterval) { + const { contactForm, otpDialog } = selectors; + + const name = contactForm.nameElem()?.value; + const email = contactForm.emailElem().value; + + contactForm.submitButton()?.setAttribute("disabled", ""); + try { + await actions.sendOtp({ type: "email", name, email }); + } catch (sendOtpError) { + const errorMsg = sendOtpError?.toString() ?? fallbackErrorMsg; + postErrorMessageOnContactForm(selectors, errorMsg); + throw sendOtpError; + } + + const otpRecipient = otpDialog.otpRecipient(); + email && otpRecipient && (otpRecipient.textContent = `<${email}>`); + const otpValidUntil = otpDialog.otpValidUntil(); + const validUntil = new Date(Date.now() + 1000 * 60 * 5); + otpValidUntil && + (otpValidUntil.textContent = `until ${validUntil.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`); + + const dialog = otpDialog.self(); + dialog.showModal(); + contactForm.submitButton()?.removeAttribute("disabled"); + dialog.addEventListener("click", function (event) { + const rect = dialog.getBoundingClientRect(); + const isInDialog = + rect.top <= event.clientY && + event.clientY <= rect.top + rect.height && + rect.left <= event.clientX && + event.clientX <= rect.left + rect.width; + if (!isInDialog) { + dialog.close(); + } + }); + + return resetResendButton(selectors, resendButtonInterval); +} diff --git a/http/public/scripts/contact/submit-otp-form.js b/http/public/scripts/contact/submit-otp-form.js new file mode 100644 index 0000000..8c09314 --- /dev/null +++ b/http/public/scripts/contact/submit-otp-form.js @@ -0,0 +1,77 @@ +import { actions } from "./actions.js"; +import { postErrorMessageOnOtpForm } from "./post-error-message.js"; + +/** @typedef {import("./selectors.js").Selectors} Selectors */ + +const fallbackErrorMsg = + "No can do. I'm afraid joeac.net is a bit broken right now - sorry about that."; + +/** + * @param {Selectors} selectors + */ +export async function submitOtpForm(selectors) { + const { contactForm, otpDialog, successSection } = selectors; + const otpForm = otpDialog.otpForm(); + otpDialog.submitButton()?.setAttribute("disabled", ""); + + const otpFormData = new FormData(otpForm ?? undefined); + const guess = [ + otpFormData.get("1"), + otpFormData.get("2"), + otpFormData.get("3"), + otpFormData.get("4"), + otpFormData.get("5"), + otpFormData.get("6"), + ].join(""); + + const name = contactForm.nameElem()?.value; + const email = contactForm.emailElem().value; + const message = contactForm.messageElem()?.value; + + let verifyResult; + try { + verifyResult = await actions.verifyOtp({ guess, userId: email }); + } catch (verifyError) { + otpDialog.submitButton()?.removeAttribute("disabled"); + postErrorMessageOnOtpForm( + selectors, + verifyError?.toString() ?? fallbackErrorMsg, + ); + return; + } + + if (verifyResult === false) { + otpDialog.submitButton()?.removeAttribute("disabled"); + postErrorMessageOnOtpForm(selectors, "Incorrect OTP. Check your email?"); + otpDialog.firstOtpInput()?.focus(); + return; + } + const sendmailToken = verifyResult; + + try { + await actions.sendEmail({ + email, + message: message, + name: name, + userId: email, + token: sendmailToken, + }); + } catch (sendEmailError) { + const errorMsg = sendEmailError?.toString() ?? fallbackErrorMsg; + postErrorMessageOnOtpForm(selectors, errorMsg); + otpDialog.submitButton()?.removeAttribute("disabled"); + return; + } + + const sentName = successSection.name(); + const sentEmail = successSection.email(); + const sentMessage = successSection.message(); + sentName && (sentName.textContent = name ?? "???"); + sentEmail && (sentEmail.textContent = email ?? "???"); + sentMessage && (sentMessage.textContent = message ?? "???"); + + contactForm.self()?.remove(); + successSection.self()?.removeAttribute("hidden"); + otpDialog.submitButton()?.removeAttribute("disabled"); + otpDialog.self().close(); +} diff --git a/http/public/scripts/otp-form-wc.js b/http/public/scripts/otp-form-wc.js new file mode 100644 index 0000000..49e1b02 --- /dev/null +++ b/http/public/scripts/otp-form-wc.js @@ -0,0 +1,68 @@ +class OtpForm extends HTMLElement { + /** @type MutationObserver? */ + observer; + + constructor() { + super(); + } + + connectedCallback() { + this.observer = new MutationObserver(() => { + this.clearInputs(); + this.configureInputs(); + }); + this.observer.observe(this, { childList: true, subtree: true }); + } + + disconnectedCallback() { + this.observer?.disconnect(); + } + + clearInputs() { + console.log("clearing all inputs"); + /** @type NodeListOf */ + const inputs = this.querySelectorAll('input:not([type="submit"])'); + for (const input of inputs) { + input.value = ""; + } + } + + configureInputs() { + this.observer?.disconnect(); + /** @type NodeListOf */ + const inputs = this.querySelectorAll('input:not([type="submit"])'); + /** @type NodeListOf */ + const form = this.querySelector("form"); + + for (const input of inputs) { + input.addEventListener("focus", () => { + input.select(); + }); + + input.addEventListener("input", () => { + if (input.value.length > 0) { + input.value = input.value.slice(0, 1).toLocaleUpperCase(); + /** @type HTMLInputElement */ + const nextInput = input.nextElementSibling; + if (nextInput) { + nextInput.focus(); + return; + } + + /** @type HTMLInputElement */ + const submitButton = form.querySelector('input[type="submit"]'); + submitButton.focus(); + let areAllInputsEntered = true; + inputs.forEach((input) => { + areAllInputsEntered = areAllInputsEntered && input.value.length > 0; + }); + if (areAllInputsEntered) { + form.requestSubmit(submitButton); + } + } + }); + } + } +} + +customElements.define("otp-form", OtpForm);