contact page
This commit is contained in:
@@ -12,5 +12,8 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="/links">Links</a>
|
<a href="/links">Links</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/contact">Contact</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
96
website/src/pages/contact.astro
Normal file
96
website/src/pages/contact.astro
Normal 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>
|
||||||
30
website/src/scripts/contact/post-error-message.ts
Normal file
30
website/src/scripts/contact/post-error-message.ts
Normal 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 = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
29
website/src/scripts/contact/resend-otp.ts
Normal file
29
website/src/scripts/contact/resend-otp.ts
Normal 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;
|
||||||
|
};
|
||||||
32
website/src/scripts/contact/reset-resend-button.ts
Normal file
32
website/src/scripts/contact/reset-resend-button.ts
Normal 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;
|
||||||
|
};
|
||||||
27
website/src/scripts/contact/selectors.ts
Normal file
27
website/src/scripts/contact/selectors.ts
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
53
website/src/scripts/contact/submit-contact-form.ts
Normal file
53
website/src/scripts/contact/submit-contact-form.ts
Normal 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;
|
||||||
|
};
|
||||||
72
website/src/scripts/contact/submit-otp-form.ts
Normal file
72
website/src/scripts/contact/submit-otp-form.ts
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user