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:
parent
f7bdfd3cb8
commit
8e35387841
7 changed files with 261 additions and 222 deletions
|
|
@ -4,88 +4,103 @@ import { actions, isInputError } from "astro:actions";
|
|||
export const prerender = false;
|
||||
|
||||
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;
|
||||
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>
|
||||
import CapWidget from "@cap.js/widget";
|
||||
import Cap from "@cap.js/widget";
|
||||
import "iconify-icon";
|
||||
|
||||
const widget = document.querySelector("cap-widget");
|
||||
if (widget) {
|
||||
const credits = widget?.shadowRoot?.querySelector('[part="attribution"]');
|
||||
const captchaInput = document.querySelector("input[id='captcha']");
|
||||
const captchaStatus = document.querySelector("#captchaStatus");
|
||||
const statusText = captchaStatus?.querySelector("#statusText");
|
||||
const initIcon = captchaStatus?.querySelector("#initIcon");
|
||||
const completeIcon = captchaStatus?.querySelector("#completeIcon");
|
||||
const errorIcon = captchaStatus?.querySelector("#errorIcon");
|
||||
const progressIcon = captchaStatus?.querySelector("#progressIcon");
|
||||
const cap = new Cap({
|
||||
apiEndpoint: "/cap/",
|
||||
});
|
||||
|
||||
if (credits) {
|
||||
const clone = credits.cloneNode(true);
|
||||
const poweredByTextBefore = document.createTextNode("(by ");
|
||||
const poweredByTextAfter = document.createTextNode(")");
|
||||
document
|
||||
.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>
|
||||
<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 {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-areas:
|
||||
"header header header header "
|
||||
"name name phone phone"
|
||||
"msg msg msg msg"
|
||||
"captcha captcha verify verify"
|
||||
"otp otp submit submit";
|
||||
"header header"
|
||||
"name phone"
|
||||
"msg msg"
|
||||
"otp captcha"
|
||||
"send_otp send_msg";
|
||||
}
|
||||
form fieldset {
|
||||
display: contents;
|
||||
.hidden {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
#captcha-credits {
|
||||
font-size: x-small;
|
||||
}
|
||||
div {
|
||||
#header {
|
||||
grid-area: header;
|
||||
}
|
||||
label {
|
||||
text-align: left;
|
||||
}
|
||||
label[for="name"] {
|
||||
grid-area: name;
|
||||
}
|
||||
|
|
@ -95,96 +110,142 @@ const error = isInputError(result?.error) ? result.error.fields : {};
|
|||
label[for="msg"] {
|
||||
grid-area: msg;
|
||||
}
|
||||
label[for="otp"] {
|
||||
grid-area: otp;
|
||||
}
|
||||
label[for="captcha"] {
|
||||
grid-area: captcha;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
#captchaStatus {
|
||||
padding: 0.8rem 0;
|
||||
font-size: small;
|
||||
}
|
||||
button {
|
||||
height: 39px;
|
||||
margin: 29px 0 10px 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
button#send_otp {
|
||||
grid-area: verify;
|
||||
grid-area: send_otp;
|
||||
}
|
||||
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>
|
||||
<Layout>
|
||||
<title slot="head">Home</title>
|
||||
<Fragment slot="content">
|
||||
<h2>
|
||||
Contact <iconify-icon
|
||||
icon="streamline-sharp-color:chat-bubble-typing-oval"></iconify-icon>
|
||||
</h2>
|
||||
<h2>Contact</h2>
|
||||
{
|
||||
(nextAction != "complete" && (
|
||||
<form method="post" x-data="{}" action={actions.contact.submitForm}>
|
||||
<div>
|
||||
<div id="header">
|
||||
{(result?.error && (
|
||||
<p class="error">
|
||||
Unable to send {nextAction == "send_otp" ? "OTP" : "message"}.
|
||||
Please correct any errors and try again.
|
||||
{result?.error.message}
|
||||
Please correct any errors and try again.
|
||||
</p>
|
||||
)) || <p>Use the below form to shoot me a quick text!</p>}
|
||||
</div>
|
||||
<fieldset id="send_otp" disabled={nextAction != "send_otp"}>
|
||||
<label for="name">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
aria-describedby="name"
|
||||
placeholder="Bad Blocks"
|
||||
<label for="name">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
aria-describedby="name"
|
||||
value="Bad Blocks"
|
||||
|
||||
/><!-- value={nameValue} -->
|
||||
{error.name && <p id="error_name">{error.name.join(",")}</p>}
|
||||
</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"
|
||||
/>
|
||||
{error.name && <p id="error_name">{error.name.join(",")}</p>}
|
||||
</label>
|
||||
<label for="phone">
|
||||
Phone
|
||||
<input
|
||||
type="text"
|
||||
id="phone"
|
||||
name="phone"
|
||||
aria-describedby="error_phone"
|
||||
placeholder="555-555-5555"
|
||||
<iconify-icon
|
||||
id="errorIcon"
|
||||
icon="line-md:alert-circle-loop"
|
||||
class="hidden"
|
||||
/>
|
||||
{error.phone && <p id="error_phone">{error.phone.join(",")}</p>}
|
||||
</label>
|
||||
<label for="msg">
|
||||
Msg
|
||||
<textarea
|
||||
id="msg"
|
||||
name="msg"
|
||||
aria-describedby="error_msg"
|
||||
placeholder="I think badblocks rocks!"
|
||||
<iconify-icon
|
||||
id="progressIcon"
|
||||
icon="line-md:speedometer-loop"
|
||||
class="hidden"
|
||||
/>
|
||||
{error.msg && <p id="error_msg">{error.msg.join(",")}</p>}
|
||||
</label>
|
||||
</fieldset>
|
||||
<span id="statusText">Loading...</span>
|
||||
</div>
|
||||
{error.captcha && <p id="error_name">{error.captcha.join(",")}</p>}
|
||||
</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"
|
||||
disabled={nextAction != "send_otp"}
|
||||
class={nextAction != "send_otp" ? "hidden" : undefined}
|
||||
>
|
||||
Verify Your Number!
|
||||
Send Verification Code!
|
||||
</button>
|
||||
<fieldset id="send_msg" disabled={nextAction != "send_msg"}>
|
||||
<label for="otp">
|
||||
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>
|
||||
</fieldset>
|
||||
<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"
|
||||
|
|
@ -194,18 +255,6 @@ const error = isInputError(result?.error) ? result.error.fields : {};
|
|||
>
|
||||
Text Me!
|
||||
</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>
|
||||
)) || <p>Your message has been sent successfully!</p>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue