contact page

This commit is contained in:
2025-12-18 11:04:03 +00:00
parent 40d6c7f248
commit 46c9b77316
8 changed files with 342 additions and 0 deletions

View File

@@ -12,5 +12,8 @@
<li>
<a href="/links">Links</a>
</li>
<li>
<a href="/contact">Contact</a>
</li>
</ul>
</nav>

View File

@@ -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>

View File

@@ -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 = "";
}
}

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};
};

View File

@@ -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;
};

View File

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