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 @@
  • Links
  • +
  • + Contact +
  • 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"; +--- + + + + +
    +

    Contact me

    +
    + + + + +
    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 { + 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; + 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 { + 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(); +}