Handle reset and OTP errors in contact form

Add a "reset" action to the form and clear session data when invoked.
Return a structured OTP error (nextAction: send_msg, field: otp, error: ...)
instead
of throwing so the UI can stay on the verification step. Remove noisy
console logs and include SMS provider message in service errors. Update
contact.astro to surface field-level errors, split the form into
fieldsets, add a "Go Back" reset button, and make small UI/layout tweaks.
This commit is contained in:
badblocks 2026-01-27 10:47:04 -08:00
parent 8e35387841
commit e67e72ea66
No known key found for this signature in database
2 changed files with 115 additions and 106 deletions

View file

@ -61,16 +61,20 @@ const sendMsgAction = z.object({
captcha: captcha_input, captcha: captcha_input,
}); });
const resetAction = z.object({
action: z.literal("reset"),
});
const formAction = z.discriminatedUnion("action", [ const formAction = z.discriminatedUnion("action", [
sendOtpAction, sendOtpAction,
sendMsgAction, sendMsgAction,
resetAction,
]); ]);
const submitActionDefinition = { const submitActionDefinition = {
input: formAction, input: formAction,
handler: async (input: any, context: ActionAPIContext) => { handler: async (input: any, context: ActionAPIContext) => {
if (!OTP_SUPER_SECRET_SALT || !ANDROID_SMS_GATEWAY_RECIPIENT_PHONE) { if (!OTP_SUPER_SECRET_SALT || !ANDROID_SMS_GATEWAY_RECIPIENT_PHONE) {
console.log("Server variables are missing.");
throw new ActionError({ throw new ActionError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Server variables are missing.", message: "Server variables are missing.",
@ -83,10 +87,9 @@ const submitActionDefinition = {
(await CapServer.validateToken(input.captcha)) (await CapServer.validateToken(input.captcha))
) )
) { ) {
console.log("Invalid Captcha Token");
throw new ActionError({ throw new ActionError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Invalid Captcha Token", message: "Invalid Captcha Token.",
}); });
} }
@ -103,7 +106,7 @@ const submitActionDefinition = {
}`; }`;
const result = await new SmsClient().sendSMS(phone, message); const result = await new SmsClient().sendSMS(phone, message);
console.log(JSON.stringify(result));
if (result.success) { if (result.success) {
context.session?.set("phone", phone); context.session?.set("phone", phone);
context.session?.set("name", name); context.session?.set("name", name);
@ -113,12 +116,9 @@ const submitActionDefinition = {
nextAction: "send_msg", nextAction: "send_msg",
}; };
} else { } else {
console.log(
"Verification code failed to send. Please try again later.",
);
throw new ActionError({ throw new ActionError({
code: "SERVICE_UNAVAILABLE", code: "SERVICE_UNAVAILABLE",
message: "Verification code failed to send. Please try again later.", message: "Verification code failed to send: " + result.message,
}); });
} }
} else if (input.action === "send_msg") { } else if (input.action === "send_msg") {
@ -128,7 +128,6 @@ const submitActionDefinition = {
const msg = await context.session?.get("msg"); const msg = await context.session?.get("msg");
if (!name || !otp || !msg || !phone) { if (!name || !otp || !msg || !phone) {
console.log("Missing required fields.");
throw new ActionError({ throw new ActionError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Missing required fields.", message: "Missing required fields.",
@ -137,11 +136,15 @@ const submitActionDefinition = {
const isVerified = verifyOtp(phone, OTP_SUPER_SECRET_SALT, otp); const isVerified = verifyOtp(phone, OTP_SUPER_SECRET_SALT, otp);
if (!isVerified) { if (!isVerified) {
console.log("Invalid or expired verification code."); return {
throw new ActionError({ nextAction: "send_msg",
code: "BAD_REQUEST", error: "Invalid or expired verification code.",
message: "Invalid or expired verification code.", field: "otp",
}); };
// throw new ActionError({
// code: "BAD_REQUEST",
// message: "Invalid or expired verification code.",
// });
} }
const message = `Web message from ${name} ( ${phone} ):\n\n${msg}`; const message = `Web message from ${name} ( ${phone} ):\n\n${msg}`;
@ -162,9 +165,16 @@ const submitActionDefinition = {
return { return {
nextAction: "complete", nextAction: "complete",
}; };
} else if (input.action === "reset") {
context.session?.delete("phone");
context.session?.delete("name");
context.session?.delete("msg");
return {
nextAction: "send_otp",
};
} }
console.log("Message failed to send.");
throw new ActionError({ throw new ActionError({
code: "SERVICE_UNAVAILABLE", code: "SERVICE_UNAVAILABLE",
message: "Message failed to send.", message: "Message failed to send.",

View file

@ -4,17 +4,16 @@ import { actions, isInputError } from "astro:actions";
export const prerender = false; export const prerender = false;
const result = Astro.getActionResult(actions.contact.submitForm); const result = Astro.getActionResult(actions.contact.submitForm);
// FIX (might be fixed with below change): if user types in invalid otp code, it returns an error
// and then nextAction is set to "send_otp". It needs to be set
// to "send_msg" if the error is caused by invalid otp code
//
// ALSO: change it maybe so user can always fill out all fields
// in one go, including otp code (have verify number swap with code field when sent)
// text me button should be disabled if otp code is invalid or missing
const nextAction = result?.data?.nextAction || "send_otp";
const error = isInputError(result?.error) ? result.error.fields : {};
const formDraft = (await Astro.session?.get("contactFormDraft")) ?? undefined; const error = isInputError(result?.error)
? result.error.fields
: result?.data?.error
? { [result?.data?.field]: [result?.data?.error] }
: {};
const nextAction = result?.data?.nextAction ?? "send_otp";
const formDraft = await Astro.session?.get("contactFormDraft");
if (formDraft && Object.keys(formDraft).length) { if (formDraft && Object.keys(formDraft).length) {
Astro.session?.delete("contactFormDraft"); Astro.session?.delete("contactFormDraft");
} }
@ -51,7 +50,8 @@ const msgValue = pickValue("msg");
progressIcon progressIcon
) { ) {
cap.addEventListener("solve", function (e) { cap.addEventListener("solve", function (e) {
statusText.textContent = "You seem human enough!"; const humanness = Math.round((85 + Math.random() * 14.9) * 10) / 10;
statusText.textContent = `${humanness}% human. Good enough!`;
progressIcon.classList.add("hidden"); progressIcon.classList.add("hidden");
errorIcon.classList.add("hidden"); errorIcon.classList.add("hidden");
initIcon.classList.add("hidden"); initIcon.classList.add("hidden");
@ -73,7 +73,7 @@ const msgValue = pickValue("msg");
}); });
} }
if (captchaInput && "value" in captchaInput) { if (captchaInput && "value" in captchaInput) {
const {token} = await cap.solve(); const { token } = await cap.solve();
captchaInput.value = token; captchaInput.value = token;
} }
</script> </script>
@ -88,8 +88,8 @@ const msgValue = pickValue("msg");
"header header" "header header"
"name phone" "name phone"
"msg msg" "msg msg"
"otp captcha" "captcha otp"
"send_otp send_msg"; "lbtn rbtn";
} }
.hidden { .hidden {
display: none; display: none;
@ -124,11 +124,17 @@ const msgValue = pickValue("msg");
height: 39px; height: 39px;
padding: 8px 12px; padding: 8px 12px;
} }
button#reset {
grid-area: lbtn;
}
button#send_otp { button#send_otp {
grid-area: send_otp; grid-area: lbtn;
} }
button#send_msg { button#send_msg {
grid-area: send_msg; grid-area: rbtn;
}
fieldset {
display: contents;
} }
.spin { .spin {
animation: spin 4s linear infinite; animation: spin 4s linear infinite;
@ -158,20 +164,74 @@ const msgValue = pickValue("msg");
</p> </p>
)) || <p>Use the below form to shoot me a quick text!</p>} )) || <p>Use the below form to shoot me a quick text!</p>}
</div> </div>
<label for="name"> <fieldset id="send_otp_fields" disabled={nextAction != "send_otp"}>
Name <label for="name">
<input Name
type="text" <input
id="name" type="text"
name="name" id="name"
aria-describedby="name" name="name"
value="Bad Blocks" aria-describedby="name"
value="Bad Blocks"
/><!-- value={nameValue} --> />
{error.name && <p id="error_name">{error.name.join(",")}</p>} {error.name && <p id="error_name">{error.name.join(",")}</p>}
</label> </label>
<label for="phone">
Phone
<input
type="text"
id="phone"
name="phone"
aria-describedby="error_phone"
value="2067452154"
/>
{error.phone && <p id="error_phone">{error.phone.join(",")}</p>}
</label>
<label for="msg">
Msg
{/* prettier-ignore */}
<textarea id="msg" name="msg" aria-describedby="error_msg">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean tortor neque, ullamcorper molestie fermentum quis, vulputate et lacus.</textarea>
{error.msg && <p id="error_msg">{error.msg.join(",")}</p>}
</label>
<button
id="send_otp"
name="action"
value="send_otp"
type="submit"
disabled={nextAction != "send_otp"}
class={nextAction != "send_otp" ? "hidden" : ""}
>
Send Verification Code!
</button>
</fieldset>
<fieldset id="send_msg_fields" disabled={nextAction != "send_msg"}>
<label for="otp" class={nextAction != "send_msg" ? "hidden" : ""}>
Verification Code
<input
type="text"
id="otp"
name="otp"
aria-describedby="error_otp"
placeholder="123456"
/>
{error.otp && <p id="error_otp">{error.otp.join(",")}</p>}
</label>
<button
id="reset"
name="action"
value="reset"
type="submit"
class={nextAction != "send_msg" ? "hidden" : ""}
>
Go Back!
</button>
<button id="send_msg" name="action" value="send_msg" type="submit">
Text Me!
</button>
</fieldset>
<label for="captcha"> <label for="captcha">
<a href="https://capjs.js.org/">Cap</a>tcha {/* prettier-ignore */}
<a href="https://capjs.js.org/" tabindex="-1">Cap</a>tcha
<input type="hidden" id="captcha" name="captcha" /> <input type="hidden" id="captcha" name="captcha" />
<div id="captchaStatus"> <div id="captchaStatus">
<iconify-icon id="initIcon" icon="line-md:loading-loop" /> <iconify-icon id="initIcon" icon="line-md:loading-loop" />
@ -194,67 +254,6 @@ const msgValue = pickValue("msg");
</div> </div>
{error.captcha && <p id="error_name">{error.captcha.join(",")}</p>} {error.captcha && <p id="error_name">{error.captcha.join(",")}</p>}
</label> </label>
<label for="phone">
Phone
<input
type="text"
id="phone"
name="phone"
aria-describedby="error_phone"
value="2067452154"
/><!-- value={phoneValue} -->
{error.phone && <p id="error_phone">{error.phone.join(",")}</p>}
</label>
<label for="msg">
Msg
<textarea
id="msg"
name="msg"
aria-describedby="error_msg"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi augue eros, maximus nec ex sit amet, scelerisque interdum leo. Sed eu turpis sit amet dui congue efficitur. Duis eu laoreet risus, eget vestibulum lectus.
</textarea>
<!-- <textarea
id="msg"
name="msg"
aria-describedby="error_msg"
placeholder="I think badblocks rocks!"
>
{msgValue}
</textarea> -->
{error.msg && <p id="error_msg">{error.msg.join(",")}</p>}
</label>
<button
id="send_otp"
name="action"
value="send_otp"
type="submit"
class={nextAction != "send_otp" ? "hidden" : undefined}
>
Send Verification Code!
</button>
<label for="otp">
Verification Code
<input
type="text"
id="otp"
name="otp"
aria-describedby="error_otp"
placeholder="123456"
disabled={nextAction != "send_msg"}
/>
{error.otp && <p id="error_otp">{error.otp.join(",")}</p>}
</label>
<button
id="send_msg"
name="action"
value="send_msg"
type="submit"
disabled={nextAction != "send_msg"}
>
Text Me!
</button>
</form> </form>
)) || <p>Your message has been sent successfully!</p> )) || <p>Your message has been sent successfully!</p>
} }