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";
+---
+
+
+
+
+
+
+
+ You sent me a message!
+ Thanks for that. I may be in touch.
+ In case you forgot, your message was this:
+
+
+ - Name
- ???
+ - Email
- ???
+ - Message
- ???
+
+
+
+
+
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();
+}