diff options
Diffstat (limited to 'website/src')
| -rw-r--r-- | website/src/components/Navbar.astro | 3 | ||||
| -rw-r--r-- | website/src/pages/contact.astro | 96 | ||||
| -rw-r--r-- | website/src/scripts/contact/post-error-message.ts | 30 | ||||
| -rw-r--r-- | website/src/scripts/contact/resend-otp.ts | 29 | ||||
| -rw-r--r-- | website/src/scripts/contact/reset-resend-button.ts | 32 | ||||
| -rw-r--r-- | website/src/scripts/contact/selectors.ts | 27 | ||||
| -rw-r--r-- | website/src/scripts/contact/submit-contact-form.ts | 53 | ||||
| -rw-r--r-- | website/src/scripts/contact/submit-otp-form.ts | 72 |
8 files changed, 342 insertions, 0 deletions
diff --git a/website/src/components/Navbar.astro b/website/src/components/Navbar.astro index 479f8b5..75945af 100644 --- a/website/src/components/Navbar.astro +++ b/website/src/components/Navbar.astro @@ -12,5 +12,8 @@ <li> <a href="/links">Links</a> </li> + <li> + <a href="/contact">Contact</a> + </li> </ul> </nav> diff --git a/website/src/pages/contact.astro b/website/src/pages/contact.astro new file mode 100644 index 0000000..b03436c --- /dev/null +++ b/website/src/pages/contact.astro @@ -0,0 +1,96 @@ +--- +import OtpDialog from "../components/OtpDialog.astro"; +import Page from "../layouts/Page.astro"; +--- + +<Page title="Contact" description="Contact Joe Carstairs"> + <OtpDialog /> + + <form class="contact-form"> + <h1>Contact me</h1> + <p hidden class="error"/> + <label for="name">Name</label> + <input id="name" name="name" type="text" required> + <label for="email">Email</label> + <input id="email" name="email" type="email" required> + <label for="message">Message</label> + <textarea id="message" name="message" required></textarea> + <input type="submit" value="Send"> + </form> + + <section class="success" hidden="true"> + <h1>You sent me a message!</h1> + <p>Thanks for that. I may be in touch.</p> + <p>In case you forgot, your message was this:</p> + <hr> + <dl> + <dt>Name</dt> <dd class="sentname">???</dd> + <dt>Email</dt> <dd class="sentemail">???</dd> + <dt>Message</dt> <dd class="sentmessage">???</dd> + </dl> + </section> + + <script> + import { resendOtp } from '../scripts/contact/resend-otp'; + import { submitContactForm } from '../scripts/contact/submit-contact-form'; + import { submitOtpForm } from '../scripts/contact/submit-otp-form'; + import type { Selectors } from '../scripts/contact/selectors'; + + function locateOrPanic<T extends Element>(selector: string, desc: string, root?: Element): T { + const elem = (root ?? document).querySelector<T>(selector); + if (!elem) { + alert(`Technical error: could not locate ${desc}. Please let Joe know if you have another means of contacting him.`); + throw new Error(`Could not locate ${desc}`); + } + return elem; + } + + const contactForm = locateOrPanic<HTMLFormElement>('form.contact-form', 'contact form'); + const otpDialog = locateOrPanic<HTMLDialogElement>('dialog.otp-dialog', 'OTP dialog'); + const otpForm = locateOrPanic<HTMLFormElement>('form.otp-form', 'OTP form'); + const successSection = document.querySelector('section.success'); + let resendButtonInterval: undefined | NodeJS.Timeout = undefined; + + const selectors: Selectors = { + contactForm: { + emailElem: () => locateOrPanic<HTMLInputElement>('input[name="email"]', 'email input', contactForm), + errorElem: () => contactForm.querySelector('.error'), + nameElem: () => locateOrPanic<HTMLInputElement>('input[name="name"]', 'name input', contactForm), + messageElem: () => locateOrPanic<HTMLTextAreaElement>('textarea[name="message"]', 'message textarea', contactForm), + self: () => contactForm, + submitButton: () => contactForm.querySelector('input[type="submit"]'), + }, + otpDialog: { + allOtpInputs: () => otpForm.querySelectorAll('input:not([type="submit"])'), + errorElem: () => otpDialog.querySelector('.error'), + firstOtpInput: () => otpForm.querySelector('input:not([type="submit"]):first-child'), + otpForm: () => otpForm, + otpRecipient: () => otpDialog.querySelector('.otp-recipient'), + otpValidUntil: () => otpDialog.querySelector('.otp-valid-until'), + resendButton: () => otpDialog.querySelector<HTMLButtonElement>('button.resend-button'), + self: () => otpDialog, + submitButton: () => otpForm.querySelector('input[type="submit"]'), + }, + successSection: { + email: () => successSection?.querySelector('.sentemail') ?? null, + name: () => successSection?.querySelector('.sentname') ?? null, + message: () => successSection?.querySelector('.sentmessage') ?? null, + self: () => successSection, + } + }; + + contactForm.addEventListener('submit', async (event) => { + event.preventDefault(); + ({ resendButtonInterval } = await submitContactForm(selectors, resendButtonInterval)); + }); + + selectors.otpDialog.resendButton()?.addEventListener('click', async () => { + ({ resendButtonInterval } = await resendOtp(selectors, resendButtonInterval)); + }); + + otpForm.addEventListener('submit', async (event) => { + event.preventDefault(); + await submitOtpForm(selectors); + }); + </script> +</Page> diff --git a/website/src/scripts/contact/post-error-message.ts b/website/src/scripts/contact/post-error-message.ts new file mode 100644 index 0000000..9846635 --- /dev/null +++ b/website/src/scripts/contact/post-error-message.ts @@ -0,0 +1,30 @@ +import type { Selectors } from "./selectors"; + +export function postErrorMessageOnContactForm( + { contactForm }: Selectors, + errorMsg: string, +) { + const errorElem = contactForm.errorElem(); + if (errorElem) { + errorElem.textContent = errorMsg; + errorElem.removeAttribute("hidden"); + } else { + alert(errorMsg); + } +} + +export function postErrorMessageOnOtpForm( + { otpDialog }: Selectors, + errorMsg: string, +) { + const errorElem = otpDialog.errorElem(); + if (errorElem) { + errorElem.textContent = errorMsg; + errorElem.removeAttribute("hidden"); + } else { + alert(errorMsg); + } + for (const input of otpDialog.allOtpInputs()) { + (input as HTMLInputElement).value = ""; + } +} diff --git a/website/src/scripts/contact/resend-otp.ts b/website/src/scripts/contact/resend-otp.ts new file mode 100644 index 0000000..3f3a4f3 --- /dev/null +++ b/website/src/scripts/contact/resend-otp.ts @@ -0,0 +1,29 @@ +import { actions } from "astro:actions"; +import { resetResendButton } from "./reset-resend-button"; +import { postErrorMessageOnOtpForm } from "./post-error-message"; +import type { Selectors } from "./selectors"; + +const fallbackErrorMsg = + "No can do. I'm afraid joeac.net is a bit broken right now - sorry about that."; + +export async function resendOtp( + selectors: Selectors, + resetButtonInterval: NodeJS.Timeout | undefined, +): Promise<Result> { + const result = resetResendButton(selectors, resetButtonInterval); + const name = selectors.contactForm.nameElem()?.value; + const email = selectors.contactForm.emailElem().value; + + const sendOtpResult = await actions.otp.send({ type: "email", name, email }); + if (sendOtpResult.error) { + const errorMsg = sendOtpResult.error?.toString() ?? fallbackErrorMsg; + postErrorMessageOnOtpForm(selectors, errorMsg); + throw sendOtpResult.error; + } + + return result; +} + +type Result = { + resendButtonInterval: NodeJS.Timeout | undefined; +}; diff --git a/website/src/scripts/contact/reset-resend-button.ts b/website/src/scripts/contact/reset-resend-button.ts new file mode 100644 index 0000000..21b0adc --- /dev/null +++ b/website/src/scripts/contact/reset-resend-button.ts @@ -0,0 +1,32 @@ +import type { Selectors } from "./selectors"; + +export function resetResendButton( + { otpDialog }: Selectors, + resendButtonInterval: NodeJS.Timeout | undefined, +): Result { + 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 }; +} + +type Result = { + resendButtonInterval: NodeJS.Timeout | undefined; +}; diff --git a/website/src/scripts/contact/selectors.ts b/website/src/scripts/contact/selectors.ts new file mode 100644 index 0000000..5c30f9c --- /dev/null +++ b/website/src/scripts/contact/selectors.ts @@ -0,0 +1,27 @@ +export type Selectors = { + contactForm: { + emailElem: () => HTMLInputElement; + errorElem: () => Element | null; + nameElem: () => HTMLInputElement; + messageElem: () => HTMLTextAreaElement; + self: () => HTMLFormElement | null; + submitButton: () => HTMLInputElement | null; + }; + otpDialog: { + allOtpInputs: () => NodeListOf<HTMLInputElement>; + errorElem: () => Element | null; + firstOtpInput: () => HTMLInputElement | null; + otpForm: () => HTMLFormElement | null; + otpRecipient: () => Element | null; + otpValidUntil: () => Element | null; + resendButton: () => HTMLButtonElement | null; + self: () => HTMLDialogElement; + submitButton: () => HTMLInputElement | null; + }; + successSection: { + email: () => Element | null; + message: () => Element | null; + name: () => Element | null; + self: () => Element | null; + }; +}; diff --git a/website/src/scripts/contact/submit-contact-form.ts b/website/src/scripts/contact/submit-contact-form.ts new file mode 100644 index 0000000..0d47b8b --- /dev/null +++ b/website/src/scripts/contact/submit-contact-form.ts @@ -0,0 +1,53 @@ +import { actions } from "astro:actions"; +import { postErrorMessageOnContactForm } from "./post-error-message"; +import { resetResendButton } from "./reset-resend-button"; +import type { Selectors } from "./selectors"; + +const fallbackErrorMsg = + "No can do. I'm afraid joeac.net is a bit broken right now - sorry about that."; + +export async function submitContactForm( + selectors: Selectors, + resendButtonInterval: NodeJS.Timeout | undefined, +): Promise<Result> { + const { contactForm, otpDialog } = selectors; + + const name = contactForm.nameElem()?.value; + const email = contactForm.emailElem().value; + + contactForm.submitButton()?.setAttribute("disabled", ""); + const sendOtpResult = await actions.otp.send({ type: "email", name, email }); + if (sendOtpResult.error) { + const errorMsg = sendOtpResult.error?.toString() ?? fallbackErrorMsg; + postErrorMessageOnContactForm(selectors, errorMsg); + throw sendOtpResult.error; + } + + 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); +} + +type Result = { + resendButtonInterval: NodeJS.Timeout | undefined; +}; diff --git a/website/src/scripts/contact/submit-otp-form.ts b/website/src/scripts/contact/submit-otp-form.ts new file mode 100644 index 0000000..7fe83a8 --- /dev/null +++ b/website/src/scripts/contact/submit-otp-form.ts @@ -0,0 +1,72 @@ +import { actions } from "astro:actions"; +import type { Selectors } from "./selectors"; +import { + postErrorMessageOnContactForm, + postErrorMessageOnOtpForm, +} from "./post-error-message"; + +const fallbackErrorMsg = + "No can do. I'm afraid joeac.net is a bit broken right now - sorry about that."; + +export async function submitOtpForm(selectors: 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; + + const verifyResult = await actions.otp.verify({ guess, userId: email }); + if (verifyResult.error) { + otpDialog.submitButton()?.removeAttribute("disabled"); + postErrorMessageOnOtpForm( + selectors, + verifyResult.error?.toString() ?? fallbackErrorMsg, + ); + return; + } + if (!verifyResult.data) { + otpDialog.submitButton()?.removeAttribute("disabled"); + postErrorMessageOnOtpForm(selectors, "Incorrect OTP. Check your email?"); + otpDialog.firstOtpInput()?.focus(); + return; + } + const sendmailToken = verifyResult.data; + + const sendmailResult = await actions.sendmail({ + email, + message: message, + name: name, + userId: email, + token: sendmailToken, + }); + if (sendmailResult.error) { + const errorMsg = sendmailResult.error?.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(); +} |
