Switch to Cap invisible widget, add form drafts to middleware, and improve OTP

validation

Use the Cap client widget in the contact UI with status icons and auto-solve,
replacing the capwidget element. Normalize and tighten phone validation by
splitting
normalizePhone and isValidPhone in the Otp lib and use it in contact action
validation. Replace loose text validation with a character-stripper helper.
Also bump several dependencies and adjust middleware to save and restore form
data for
form actions.
This commit is contained in:
badblocks 2026-01-27 09:49:06 -08:00
parent f7bdfd3cb8
commit 8e35387841
No known key found for this signature in database
7 changed files with 261 additions and 222 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -18,15 +18,15 @@
"@astrojs/db": "^0.18.3", "@astrojs/db": "^0.18.3",
"@astrojs/node": "^9.5.2", "@astrojs/node": "^9.5.2",
"@astrojs/partytown": "^2.1.4", "@astrojs/partytown": "^2.1.4",
"@astrojs/sitemap": "^3.6.0", "@astrojs/sitemap": "^3.7.0",
"@astrojs/ts-plugin": "^1.10.6", "@astrojs/ts-plugin": "^1.10.6",
"@cap.js/server": "^4.0.5", "@cap.js/server": "^4.0.5",
"@cap.js/widget": "^0.1.33", "@cap.js/widget": "^0.1.34",
"@nurodev/astro-bun": "^2.1.2", "@nurodev/astro-bun": "^2.1.2",
"@types/alpinejs": "^3.13.11", "@types/alpinejs": "^3.13.11",
"alpinejs": "^3.15.3", "alpinejs": "^3.15.5",
"android-sms-gateway": "^3.0.0", "android-sms-gateway": "^3.0.0",
"astro": "^5.16.6", "astro": "^5.16.15",
"astro-htmx": "^1.0.6", "astro-htmx": "^1.0.6",
"htmx.org": "^2.0.8", "htmx.org": "^2.0.8",
"iconify-icon": "^3.0.2", "iconify-icon": "^3.0.2",
@ -35,7 +35,7 @@
"validator": "^13.15.26" "validator": "^13.15.26"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.5", "@types/bun": "^1.3.6",
"@types/validator": "^13.15.10", "@types/validator": "^13.15.10",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-astro": "^0.14.1" "prettier-plugin-astro": "^0.14.1"

View file

@ -10,20 +10,10 @@ import {
ANDROID_SMS_GATEWAY_RECIPIENT_PHONE, ANDROID_SMS_GATEWAY_RECIPIENT_PHONE,
} from "astro:env/server"; } from "astro:env/server";
const isValidCaptcha: [(data: string) => any, { message: string }] = [ const isValidMobilePhone: [(data: string) => any, { message: string }] = [
async (value: string) => (value: string) =>
typeof console.log(value) && validator.isMobilePhone(value, ["en-US", "en-CA"]) &&
/^[a-fA-F0-9]{16}:[a-fA-F0-9]{30}$/.test(value) && Otp.isValidPhone(value),
(await CapServer.validateToken(value)),
{
message: "Invalid captcha token.",
},
];
const stripLow = (value: string) => validator.stripLow(value);
const isMobilePhone: [(data: string) => any, { message: string }] = [
(value: string) => validator.isMobilePhone(value, ["en-US", "en-CA"]),
{ message: "Invalid phone number" }, { message: "Invalid phone number" },
]; ];
@ -38,45 +28,30 @@ const noExcessiveRepetitions: [(data: string) => any, { message: string }] = [
{ message: "No excessive repetitions!" }, { message: "No excessive repetitions!" },
]; ];
const acceptableText: [(data: string) => any, { message: string }] = [ const stripDisallowedCharacters = (value: string) =>
(value: string) => value
/^[\p{Letter}\p{Mark}\p{General_Category=Decimal_Number}\p{General_Category=Punctuation}\p{General_Category=Space_Separator}\p{General_Category=Symbol}\p{RGI_Emoji}]*$/v.test( .match(
value, /(?:[\p{Letter}\p{Mark}\p{General_Category=Decimal_Number}\p{General_Category=Punctuation}\p{General_Category=Space_Separator}\p{General_Category=Symbol}]|\p{RGI_Emoji})/gv,
), )
{ ?.join("") ?? "";
message:
"Only letters, numbers, punctuation, spaces, symbols, and emojis are allowed.",
},
];
const captcha_input = z const captcha_input = z.string().trim().nonempty();
.string()
.trim()
.nonempty()
.refine(...isValidCaptcha);
const sendOtpAction = z.object({ const sendOtpAction = z.object({
action: z.literal("send_otp"), action: z.literal("send_otp"),
name: z name: z.string().trim().min(5).max(32).transform(stripDisallowedCharacters),
.string()
.trim()
.min(5)
.max(32)
.transform(stripLow)
.refine(...acceptableText),
phone: z phone: z
.string() .string()
.trim() .trim()
.refine(...isMobilePhone), .refine(...isValidMobilePhone),
msg: z msg: z
.string() .string()
.trim() .trim()
.min(25) .min(25)
.max(512) .max(512)
.transform(stripLow) .transform(stripDisallowedCharacters)
.refine(...noYelling) .refine(...noYelling)
.refine(...noExcessiveRepetitions) .refine(...noExcessiveRepetitions),
.refine(...acceptableText),
captcha: captcha_input, captcha: captcha_input,
}); });
@ -95,49 +70,41 @@ 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.",
}); });
} }
if (input.action === "send_otp") { if (
const { name, phone, msg } = input; !(
if (!phone || !Otp.validatePhoneNumber(phone)) { /^[a-fA-F0-9]{16}:[a-fA-F0-9]{30}$/.test(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 phone number.", message: "Invalid Captcha Token",
}); });
} }
if (Otp.isRateLimitedForOtp(phone)) { if (input.action === "send_otp") {
throw new ActionError({ const { name, phone, msg } = input;
code: "TOO_MANY_REQUESTS",
message: "Too many OTP requests. Please try again later.",
});
}
if (Otp.isRateLimitedForMsgs(phone)) {
throw new ActionError({
code: "TOO_MANY_REQUESTS",
message: "Too many message requests. Please try again later.",
});
}
const otp = Otp.generateOtp(phone, OTP_SUPER_SECRET_SALT); const otp = Otp.generateOtp(phone, OTP_SUPER_SECRET_SALT);
const stepSeconds = Otp.getOtpStep(); const stepSeconds = Otp.getOtpStep();
const stepMinutes = Math.floor(stepSeconds / 60); const stepMinutes = Math.floor(stepSeconds / 60);
const remainingSeconds = stepSeconds % 60; const remainingSeconds = stepSeconds % 60;
const api = new SmsClient();
const message = `${otp} is your verification code. This code is valid for ${stepMinutes} minutes${ const message = `${otp} is your verification code. This code is valid for ${stepMinutes} minutes${
remainingSeconds != 0 ? " " + remainingSeconds + " seconds." : "." remainingSeconds != 0 ? " " + remainingSeconds + " seconds." : "."
}`; }`;
const result = await api.sendSMS(phone, message);
const result = await new SmsClient().sendSMS(phone, message);
console.log(JSON.stringify(result));
if (result.success) { if (result.success) {
Otp.recordOtpRequest(phone);
context.session?.set("phone", phone); context.session?.set("phone", phone);
context.session?.set("name", name); context.session?.set("name", name);
context.session?.set("msg", msg); context.session?.set("msg", msg);
@ -146,6 +113,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. Please try again later.",
@ -158,6 +128,7 @@ 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.",
@ -166,6 +137,7 @@ 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.");
throw new ActionError({ throw new ActionError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Invalid or expired verification code.", message: "Invalid or expired verification code.",
@ -192,6 +164,7 @@ const submitActionDefinition = {
}; };
} }
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

@ -29,33 +29,36 @@ function getUserSecret(phoneNumber: string, salt: string): string {
.digest("hex"); .digest("hex");
} }
export function validatePhoneNumber(unsafePhoneNum: string) { export function normalizePhone(phone: string) {
if (typeof unsafePhoneNum !== "string") { const result = phone.replace(/[^\d]/g, "").trim().startsWith("1")
return { success: false, message: "Invalid phone number." }; ? phone.substring(1)
: phone;
if (result.length !== 10) {
throw new Error("Invalid phone number.");
} }
unsafePhoneNum = unsafePhoneNum.replace(/[^0-9]/g, "").trim(); return result;
const cleanedNumber = unsafePhoneNum.startsWith("1") }
? unsafePhoneNum.substring(1)
: unsafePhoneNum;
const isValidFormat = /^[2-7][0-8][0-9][2-9][0-9]{6}$/.test(cleanedNumber); export function isValidPhone(phone: string): boolean {
const isNotAllSameDigit = !/^(.)\1{9}$/.test(cleanedNumber); phone = normalizePhone(phone);
const isNot911Number = !/^[0-9]{3}911[0-9]{4}$/.test(cleanedNumber); const match = phone.match(/(\d{3})(\d{3})(\d{4})/);
const isNot555Number = !/^[0-9]{3}555[0-9]{4}$/.test(cleanedNumber); const [, prefix, exchange, station] = match ?? [];
const isNotPopSongNumber = !/^[0-9]{3}8675309$/.test(cleanedNumber); const isValidNANPFormat =
/^[2-7][0-8][0-9]$/.test(prefix) && /^[2-9][0-9]{2}$/.test(exchange);
const isNotAllSameDigit = !/^(.)\1{6}$/.test(exchange + station);
const isNot911Number = prefix !== "911" && exchange !== "911";
const isNot555Number = prefix !== "555" && exchange !== "555";
const isNotPopSongNumber = exchange !== "867" && station !== "5309";
if ( return (
isValidFormat && isValidNANPFormat &&
isNotAllSameDigit && isNotAllSameDigit &&
isNot911Number && isNot911Number &&
isNot555Number && isNot555Number &&
isNotPopSongNumber isNotPopSongNumber
) { );
return { success: true, validatedPhoneNumber: cleanedNumber };
}
return { success: false, validatedPhoneNumber: undefined };
} }
export function generateOtp(phoneNumber: string, salt: string): string { export function generateOtp(phoneNumber: string, salt: string): string {
@ -141,7 +144,8 @@ export function recordOtpRequest(phoneNumber: string) {
} }
export default { export default {
validatePhoneNumber, normalizePhone,
isValidPhone,
generateOtp, generateOtp,
verifyOtp, verifyOtp,
getOtpStep, getOtpStep,

View file

@ -1,6 +1,5 @@
import { defineMiddleware } from "astro:middleware"; import { defineMiddleware } from "astro:middleware";
import { getActionContext } from "astro:actions"; import { getActionContext } from "astro:actions";
import { randomUUID } from "node:crypto";
export const onRequest = defineMiddleware(async (context, next) => { export const onRequest = defineMiddleware(async (context, next) => {
if (context.isPrerendered) return next(); if (context.isPrerendered) return next();
@ -19,6 +18,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
} }
if (action?.calledFrom === "form") { if (action?.calledFrom === "form") {
const formData = await context.request.clone().formData();
const actionResult = await action.handler(); const actionResult = await action.handler();
context.session?.set( context.session?.set(
@ -30,6 +30,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
); );
if (actionResult.error) { if (actionResult.error) {
const draft = {
action: formData.get("action")?.toString() ?? "",
name: formData.get("name")?.toString() ?? "",
phone: formData.get("phone")?.toString() ?? "",
msg: formData.get("msg")?.toString() ?? "",
};
context.session?.set("contactFormDraft", draft);
const referer = context.request.headers.get("Referer"); const referer = context.request.headers.get("Referer");
if (!referer) { if (!referer) {
throw new Error( throw new Error(
@ -39,6 +48,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
return context.redirect(referer); return context.redirect(referer);
} }
context.session?.delete("contactFormDraft");
return context.redirect(context.originPathname); return context.redirect(context.originPathname);
} }

View file

@ -4,9 +4,12 @@ export const prerender = false;
export const POST: APIRoute = async () => { export const POST: APIRoute = async () => {
try { try {
return new Response(JSON.stringify(await cap.createChallenge()), { return new Response(
JSON.stringify(await cap.createChallenge({ challengeDifficulty: 4 })),
{
status: 200, status: 200,
}); },
);
} catch { } catch {
return new Response(JSON.stringify({ success: false }), { status: 400 }); return new Response(JSON.stringify({ success: false }), { status: 400 });
} }

View file

@ -4,88 +4,103 @@ 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 nextAction = result?.data?.nextAction || "send_otp";
const error = isInputError(result?.error) ? result.error.fields : {}; const error = isInputError(result?.error) ? result.error.fields : {};
const formDraft = (await Astro.session?.get("contactFormDraft")) ?? undefined;
if (formDraft && Object.keys(formDraft).length) {
Astro.session?.delete("contactFormDraft");
}
const pickValue = (key: string) =>
typeof formDraft?.[key] === "string" ? formDraft[key] : undefined;
const nameValue = pickValue("name");
const phoneValue = pickValue("phone");
const msgValue = pickValue("msg");
--- ---
<script> <script>
import CapWidget from "@cap.js/widget"; import Cap from "@cap.js/widget";
import "iconify-icon"; import "iconify-icon";
const widget = document.querySelector("cap-widget"); const captchaInput = document.querySelector("input[id='captcha']");
if (widget) { const captchaStatus = document.querySelector("#captchaStatus");
const credits = widget?.shadowRoot?.querySelector('[part="attribution"]'); const statusText = captchaStatus?.querySelector("#statusText");
const initIcon = captchaStatus?.querySelector("#initIcon");
if (credits) { const completeIcon = captchaStatus?.querySelector("#completeIcon");
const clone = credits.cloneNode(true); const errorIcon = captchaStatus?.querySelector("#errorIcon");
const poweredByTextBefore = document.createTextNode("(by "); const progressIcon = captchaStatus?.querySelector("#progressIcon");
const poweredByTextAfter = document.createTextNode(")"); const cap = new Cap({
document apiEndpoint: "/cap/",
.querySelector("#captcha-credits")
?.appendChild(poweredByTextBefore);
document.querySelector("#captcha-credits")?.appendChild(clone);
document
.querySelector("#captcha-credits")
?.appendChild(poweredByTextAfter);
widget?.style.setProperty("--cap-credits-display", "none");
}
widget.addEventListener("solve", function (e) {
const token = e.detail.token;
const hiddenInput = document.querySelector("input[id='captcha']");
if (hiddenInput && "value" in hiddenInput) {
hiddenInput.value = token;
}
}); });
if (
captchaStatus &&
statusText &&
initIcon &&
completeIcon &&
errorIcon &&
progressIcon
) {
cap.addEventListener("solve", function (e) {
statusText.textContent = "You seem human enough!";
progressIcon.classList.add("hidden");
errorIcon.classList.add("hidden");
initIcon.classList.add("hidden");
completeIcon.classList.remove("hidden");
});
cap.addEventListener("error", function (e) {
statusText.textContent = "Oops! We crashed!";
progressIcon.classList.add("hidden");
completeIcon.classList.add("hidden");
initIcon.classList.add("hidden");
errorIcon.classList.remove("hidden");
});
cap.addEventListener("progress", (event) => {
statusText.textContent = `Weighing your humanity... ${event.detail.progress}%`;
errorIcon.classList.add("hidden");
completeIcon.classList.add("hidden");
initIcon.classList.add("hidden");
progressIcon.classList.remove("hidden");
});
}
if (captchaInput && "value" in captchaInput) {
const {token} = await cap.solve();
captchaInput.value = token;
} }
</script> </script>
<style> <style>
cap-widget {
--cap-background: var(--bg-color);
--cap-border-color: rgba(255, 255, 255, 0);
--cap-border-radius: 0;
--cap-widget-height: initial;
--cap-widget-width: 100%;
--cap-widget-padding: 0 0 11px 0;
--cap-gap: 3ch;
--cap-color: var(--text-color);
--cap-checkbox-size: 32px;
--cap-checkbox-border: 2px solid var(--border-color);
--cap-checkbox-border-radius: 4px;
--cap-checkbox-background: var(--input-bg);
--cap-checkbox-margin: 4px;
--cap-font: "Courier New", Courier, monospace;
--cap-spinner-color: var(--text-color);
--cap-spinner-background-color: var(--input-bg);
--cap-spinner-thickness: 2px;
--cap-credits-display: inline;
margin: 0;
display: block;
}
cap-widget::part(attribution) {
display: var(--cap-credits-display);
}
form { form {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
grid-template-columns: repeat(2, 1fr);
grid-template-areas: grid-template-areas:
"header header header header " "header header"
"name name phone phone" "name phone"
"msg msg msg msg" "msg msg"
"captcha captcha verify verify" "otp captcha"
"otp otp submit submit"; "send_otp send_msg";
} }
form fieldset { .hidden {
display: contents; display: none;
visibility: hidden;
} }
#captcha-credits { #header {
font-size: x-small;
}
div {
grid-area: header; grid-area: header;
} }
label {
text-align: left;
}
label[for="name"] { label[for="name"] {
grid-area: name; grid-area: name;
} }
@ -95,41 +110,54 @@ const error = isInputError(result?.error) ? result.error.fields : {};
label[for="msg"] { label[for="msg"] {
grid-area: msg; grid-area: msg;
} }
label[for="otp"] {
grid-area: otp;
}
label[for="captcha"] { label[for="captcha"] {
grid-area: captcha; grid-area: captcha;
margin-bottom: 0; }
#captchaStatus {
padding: 0.8rem 0;
font-size: small;
} }
button { button {
height: 39px; height: 39px;
margin: 29px 0 10px 0;
padding: 8px 12px; padding: 8px 12px;
} }
button#send_otp { button#send_otp {
grid-area: verify; grid-area: send_otp;
} }
button#send_msg { button#send_msg {
grid-area: submit; grid-area: send_msg;
}
.spin {
animation: spin 4s linear infinite;
position: relative;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
</style> </style>
<Layout> <Layout>
<title slot="head">Home</title> <title slot="head">Home</title>
<Fragment slot="content"> <Fragment slot="content">
<h2> <h2>Contact</h2>
Contact <iconify-icon
icon="streamline-sharp-color:chat-bubble-typing-oval"></iconify-icon>
</h2>
{ {
(nextAction != "complete" && ( (nextAction != "complete" && (
<form method="post" x-data="{}" action={actions.contact.submitForm}> <form method="post" x-data="{}" action={actions.contact.submitForm}>
<div> <div id="header">
{(result?.error && ( {(result?.error && (
<p class="error"> <p class="error">
Unable to send {nextAction == "send_otp" ? "OTP" : "message"}. {result?.error.message}
Please correct any errors and try again. Please correct any errors and try again.
</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>
<fieldset id="send_otp" disabled={nextAction != "send_otp"}>
<label for="name"> <label for="name">
Name Name
<input <input
@ -137,10 +165,35 @@ const error = isInputError(result?.error) ? result.error.fields : {};
id="name" id="name"
name="name" name="name"
aria-describedby="name" aria-describedby="name"
placeholder="Bad Blocks" 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="captcha">
<a href="https://capjs.js.org/">Cap</a>tcha
<input type="hidden" id="captcha" name="captcha" />
<div id="captchaStatus">
<iconify-icon id="initIcon" icon="line-md:loading-loop" />
<iconify-icon
id="completeIcon"
icon="line-md:circle-to-confirm-circle-transition"
class="hidden"
/>
<iconify-icon
id="errorIcon"
icon="line-md:alert-circle-loop"
class="hidden"
/>
<iconify-icon
id="progressIcon"
icon="line-md:speedometer-loop"
class="hidden"
/>
&nbsp;<span id="statusText">Loading...</span>
</div>
{error.captcha && <p id="error_name">{error.captcha.join(",")}</p>}
</label>
<label for="phone"> <label for="phone">
Phone Phone
<input <input
@ -148,8 +201,9 @@ const error = isInputError(result?.error) ? result.error.fields : {};
id="phone" id="phone"
name="phone" name="phone"
aria-describedby="error_phone" aria-describedby="error_phone"
placeholder="555-555-5555" value="2067452154"
/>
/><!-- value={phoneValue} -->
{error.phone && <p id="error_phone">{error.phone.join(",")}</p>} {error.phone && <p id="error_phone">{error.phone.join(",")}</p>}
</label> </label>
<label for="msg"> <label for="msg">
@ -158,33 +212,40 @@ const error = isInputError(result?.error) ? result.error.fields : {};
id="msg" id="msg"
name="msg" name="msg"
aria-describedby="error_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!" placeholder="I think badblocks rocks!"
/> >
{msgValue}
</textarea> -->
{error.msg && <p id="error_msg">{error.msg.join(",")}</p>} {error.msg && <p id="error_msg">{error.msg.join(",")}</p>}
</label> </label>
</fieldset>
<button <button
id="send_otp" id="send_otp"
name="action" name="action"
value="send_otp" value="send_otp"
type="submit" type="submit"
disabled={nextAction != "send_otp"} class={nextAction != "send_otp" ? "hidden" : undefined}
> >
Verify Your Number! Send Verification Code!
</button> </button>
<fieldset id="send_msg" disabled={nextAction != "send_msg"}>
<label for="otp"> <label for="otp">
Code Verification Code
<input <input
type="text" type="text"
id="otp" id="otp"
name="otp" name="otp"
aria-describedby="error_otp" aria-describedby="error_otp"
placeholder="123456" placeholder="123456"
disabled={nextAction != "send_msg"}
/> />
{error.otp && <p id="error_otp">{error.otp.join(",")}</p>} {error.otp && <p id="error_otp">{error.otp.join(",")}</p>}
</label> </label>
</fieldset>
<button <button
id="send_msg" id="send_msg"
name="action" name="action"
@ -194,18 +255,6 @@ const error = isInputError(result?.error) ? result.error.fields : {};
> >
Text Me! Text Me!
</button> </button>
<label for="captcha">
Captcha <span id="captcha-credits" />
<cap-widget
id="captcha"
data-cap-api-endpoint="/cap/"
aria-describedby="error_captcha"
/>
{error.captcha && (
<p id="error_captcha">{error.captcha.join(",")}</p>
)}
<input type="hidden" id="captcha" name="captcha" />
</label>
</form> </form>
)) || <p>Your message has been sent successfully!</p> )) || <p>Your message has been sent successfully!</p>
} }