More progress but need to fully refactor into Astro action and take advantage of
all the boilerplate
This commit is contained in:
parent
f5eac7145c
commit
608348a5a5
7 changed files with 411 additions and 142 deletions
|
|
@ -4,14 +4,20 @@ import { defineConfig, envField } from "astro/config";
|
||||||
import alpinejs from "@astrojs/alpinejs";
|
import alpinejs from "@astrojs/alpinejs";
|
||||||
import sitemap from "@astrojs/sitemap";
|
import sitemap from "@astrojs/sitemap";
|
||||||
import bun from "@nurodev/astro-bun";
|
import bun from "@nurodev/astro-bun";
|
||||||
|
import node from "@astrojs/node";
|
||||||
import db from "@astrojs/db";
|
import db from "@astrojs/db";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: "https://badblocks.dev",
|
site: "https://badblocks.dev",
|
||||||
trailingSlash: "never",
|
trailingSlash: "never",
|
||||||
adapter: bun(),
|
// bun adapter is not official, so keep
|
||||||
output: "static",
|
// the node adapter available just in case
|
||||||
|
adapter: node({
|
||||||
|
mode: "standalone",
|
||||||
|
}),
|
||||||
|
// adapter: bun(),
|
||||||
|
// output: "static",
|
||||||
devToolbar: { enabled: false },
|
devToolbar: { enabled: false },
|
||||||
prefetch: {
|
prefetch: {
|
||||||
prefetchAll: true,
|
prefetchAll: true,
|
||||||
|
|
|
||||||
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -16,6 +16,7 @@
|
||||||
"@astrojs/alpinejs": "^0.4.9",
|
"@astrojs/alpinejs": "^0.4.9",
|
||||||
"@astrojs/check": "^0.9.6",
|
"@astrojs/check": "^0.9.6",
|
||||||
"@astrojs/db": "^0.18.3",
|
"@astrojs/db": "^0.18.3",
|
||||||
|
"@astrojs/node": "^9.5.2",
|
||||||
"@astrojs/partytown": "^2.1.4",
|
"@astrojs/partytown": "^2.1.4",
|
||||||
"@astrojs/sitemap": "^3.6.0",
|
"@astrojs/sitemap": "^3.6.0",
|
||||||
"@astrojs/ts-plugin": "^1.10.6",
|
"@astrojs/ts-plugin": "^1.10.6",
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,30 @@
|
||||||
---
|
---
|
||||||
import { POST} from "@pages/endpoints/contact";
|
import { POST, generateInitialState } from "@pages/endpoints/contact";
|
||||||
import type { ContactFormErrors, ContactFormState, ContactFormResult } from "../types/ContactForm";
|
import * as ContactFormTypes from "../types/ContactForm";
|
||||||
type Props = Record<string, never>;
|
|
||||||
|
|
||||||
async function handleFormRequest(): Promise<ContactFormState> {
|
|
||||||
const errors: ContactFormErrors = {
|
|
||||||
name: "",
|
|
||||||
phone: "",
|
|
||||||
msg: "",
|
|
||||||
code: "",
|
|
||||||
captcha: "",
|
|
||||||
form: "",
|
|
||||||
};
|
|
||||||
let success = false;
|
|
||||||
let action = "send_otp";
|
|
||||||
|
|
||||||
|
async function handlePost(): Promise<ContactFormTypes.State> {
|
||||||
try {
|
try {
|
||||||
let response: ContactFormResult<{ nextAction: string }> =
|
let response =
|
||||||
await (await POST(Astro))?.json();
|
await (await POST(Astro))?.json();
|
||||||
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
errors.form = "Invalid response.";
|
return generateInitialState("Invalid response.");
|
||||||
return { errors, success, action };
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.success) {
|
|
||||||
errors.form = response.message || "An unexpected error occurred.";
|
|
||||||
return { errors, success, action };
|
|
||||||
}
|
|
||||||
|
|
||||||
action = response.data.nextAction;
|
|
||||||
|
|
||||||
return { errors, success, action };
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
let message = "An unexpected error occurred.";
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
errors.form = "An unexpected error occurred: " + error.message;
|
message = "An unexpected error occurred: " + error.message;
|
||||||
} else {
|
|
||||||
errors.form = "An unexpected error occurred.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { errors, success, action };
|
return generateInitialState(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Astro.session?.set('init', true); // Make sure session cookie is set early, else error (better fix: disable html streaming maybe?)
|
// CANNOT USE SESSION INSIDE AN ASTRO COMPONENT! MUST REVALIDATE FORM FIELDS OR CONVERT TO REGULAR PAGE (preferable as there will never be more than one contact form)
|
||||||
|
|
||||||
const { errors, success, action } = (Astro.request.method === "POST")? await handleFormRequest() : { errors: {}, success: false, action: "send_otp" };
|
const state = (Astro.request.method === "POST")? await handlePost() : generateInitialState();
|
||||||
|
|
||||||
---
|
---
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -104,7 +83,7 @@ const { errors, success, action } = (Astro.request.method === "POST")? await han
|
||||||
"name name phone phone"
|
"name name phone phone"
|
||||||
"msg msg msg msg"
|
"msg msg msg msg"
|
||||||
"captcha captcha verify verify"
|
"captcha captcha verify verify"
|
||||||
"code code submit submit";
|
"otp otp submit submit";
|
||||||
}
|
}
|
||||||
form fieldset {
|
form fieldset {
|
||||||
display: contents;
|
display: contents;
|
||||||
|
|
@ -142,40 +121,40 @@ const { errors, success, action } = (Astro.request.method === "POST")? await han
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h2>Contact <iconify-icon icon="streamline-sharp-color:chat-bubble-typing-oval"></iconify-icon></h2>
|
<h2>Contact <iconify-icon icon="streamline-sharp-color:chat-bubble-typing-oval"></iconify-icon></h2>
|
||||||
{!success && action != "success" && <form method="post" x-data="{}">
|
{state.state !== "complete" && <form method="post" x-data="{}">
|
||||||
<div>
|
<div>
|
||||||
<p>Use the below form to shoot me a quick text!</p>
|
<p>Use the below form to shoot me a quick text!</p>
|
||||||
{errors.form && <p>{errors.form}</p>}
|
{state.error && <p>{state.error}</p>}
|
||||||
</div>
|
</div>
|
||||||
<fieldset id="send_otp" disabled={action !== "send_otp"}>
|
<fieldset id="send_otp" disabled={state.state !== "initial"}>
|
||||||
<label for="name">
|
<label for="name">
|
||||||
Name
|
Name
|
||||||
<input type="text" id="name" name="name" value="Bad Blocks" />
|
<input type="text" id="name" name="name" value="Bad Blocks" />
|
||||||
{errors.name && <p>{errors.name}</p>}
|
{"name" in state.fields && state.fields.name.hasError && <p>{state.fields.name.error}</p>}
|
||||||
</label>
|
</label>
|
||||||
<label for="phone">
|
<label for="phone">
|
||||||
Phone
|
Phone
|
||||||
<input type="text" id="phone" name="phone" value="206-745-2154" />
|
<input type="text" id="phone" name="phone" value="206-745-2154" />
|
||||||
{errors.phone && <p>{errors.phone}</p>}
|
{"phone" in state.fields && state.fields.phone.hasError && <p>{state.fields.phone.error}</p>}
|
||||||
</label>
|
</label>
|
||||||
<label for="msg">
|
<label for="msg">
|
||||||
Msg
|
Msg
|
||||||
<textarea id="msg" name="msg">I think badblocks rocks! A quick brown fox jumped over the lazy dog!</textarea>
|
<textarea id="msg" name="msg">I think badblocks rocks! A quick brown fox jumped over the lazy dog!</textarea>
|
||||||
{errors.msg && <p>{errors.msg}</p>}
|
{"msg" in state.fields && state.fields.msg.hasError && <p>{state.fields.msg.error}</p>}
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button id="send_otp" name="action" value="send_otp" type="submit" disabled={action !== "send_otp"}>Verify Your Number!</button>
|
<button id="send_otp" name="action" value="send_otp" type="submit" disabled={state.state !== "initial"}>Verify Your Number!</button>
|
||||||
<fieldset id="send_msg" disabled={action !== "send_msg"}>
|
<fieldset id="send_msg" disabled={state.state !== "otp_sent"}>
|
||||||
<label for="code">
|
<label for="otp">
|
||||||
Code
|
Code
|
||||||
<input type="text" id="code" name="code" placeholder="123456" />
|
<input type="text" id="otp" name="otp" placeholder="123456" />
|
||||||
{errors.code && <p>{errors.code}</p>}
|
{"otp" in state.fields && state.fields.otp.hasError && <p>{state.fields.otp.error}</p>}
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button id="send_msg" name="action" value="send_msg" type="submit" disabled={action !== "send_msg"}>Text Me!</button>
|
<button id="send_msg" name="action" value="send_msg" type="submit" disabled={state.state !== "otp_sent"}>Text Me!</button>
|
||||||
<label for="captcha">
|
<label for="captcha">
|
||||||
Captcha <span id="captcha-credits"></span>
|
Captcha <span id="captcha-credits"></span>
|
||||||
<cap-widget id="captcha" data-cap-api-endpoint="/cap/"></cap-widget>
|
<cap-widget id="captcha" data-cap-api-endpoint="/cap/"></cap-widget>
|
||||||
{errors.captcha && <p>{errors.captcha}</p>}
|
{"captcha" in state.fields && state.fields.captcha.hasError && <p>{state.fields.captcha.error}</p>}
|
||||||
</label>
|
</label>
|
||||||
</form> || <p>Your message has been sent successfully!</p>}
|
</form> || <p>Your message has been sent successfully!</p>}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,163 @@
|
||||||
---
|
---
|
||||||
import Layout from "@layouts/BaseLayout.astro";
|
import Layout from "@layouts/BaseLayout.astro";
|
||||||
import ContactForm from "@components/ContactForm.astro";
|
import { POST, generateInitialState } from "@pages/endpoints/contact";
|
||||||
|
import * as ContactFormTypes from "../types/ContactForm";
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
|
async function handlePost(): Promise<ContactFormTypes.State> {
|
||||||
|
try {
|
||||||
|
let response =
|
||||||
|
await (await POST(Astro))?.json();
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
return generateInitialState("Invalid response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
let message = "An unexpected error occurred.";
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = "An unexpected error occurred: " + error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateInitialState(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = (Astro.request.method === "POST")? await handlePost() : generateInitialState();
|
||||||
---
|
---
|
||||||
|
<script>
|
||||||
|
// @ts-nocheck
|
||||||
|
import CapWidget from "@cap.js/widget";
|
||||||
|
import "iconify-icon";
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
const widget = document.querySelector('cap-widget');
|
||||||
|
const shadowRoot = widget?.shadowRoot;
|
||||||
|
const credits = shadowRoot?.querySelector('[part="attribution"]');
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
</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-areas:
|
||||||
|
"header header header header "
|
||||||
|
"name name phone phone"
|
||||||
|
"msg msg msg msg"
|
||||||
|
"captcha captcha verify verify"
|
||||||
|
"otp otp submit submit";
|
||||||
|
}
|
||||||
|
form fieldset {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
#captcha-credits {
|
||||||
|
font-size: x-small;
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
grid-area: header;
|
||||||
|
}
|
||||||
|
label[for="name"] {
|
||||||
|
grid-area: name;
|
||||||
|
}
|
||||||
|
label[for="phone"] {
|
||||||
|
grid-area: phone;
|
||||||
|
}
|
||||||
|
label[for="msg"] {
|
||||||
|
grid-area: msg;
|
||||||
|
}
|
||||||
|
label[for="captcha"] {
|
||||||
|
grid-area: captcha;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
height: 39px;
|
||||||
|
margin: 29px 0 10px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
button#send_otp {
|
||||||
|
grid-area: verify;
|
||||||
|
}
|
||||||
|
button#send_msg {
|
||||||
|
grid-area: submit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<Layout>
|
<Layout>
|
||||||
<title slot="head">Contact</title>
|
<title slot="head">Home</title>
|
||||||
<Fragment slot="content">
|
<Fragment slot="content">
|
||||||
<ContactForm />
|
<h2>Contact <iconify-icon icon="streamline-sharp-color:chat-bubble-typing-oval"></iconify-icon></h2>
|
||||||
|
{state.state !== "complete" && <form method="post" x-data="{}">
|
||||||
|
<div>
|
||||||
|
<p>Use the below form to shoot me a quick text!</p>
|
||||||
|
{state.error && <p>{state.error}</p>}
|
||||||
|
</div>
|
||||||
|
<fieldset id="send_otp" disabled={state.state !== "initial"}>
|
||||||
|
<label for="name">
|
||||||
|
Name
|
||||||
|
<input type="text" id="name" name="name" value="Bad Blocks" />
|
||||||
|
{"name" in state.fields && state.fields.name.hasError && <p>{state.fields.name.error}</p>}
|
||||||
|
</label>
|
||||||
|
<label for="phone">
|
||||||
|
Phone
|
||||||
|
<input type="text" id="phone" name="phone" value="206-745-2154" />
|
||||||
|
{"phone" in state.fields && state.fields.phone.hasError && <p>{state.fields.phone.error}</p>}
|
||||||
|
</label>
|
||||||
|
<label for="msg">
|
||||||
|
Msg
|
||||||
|
<textarea id="msg" name="msg">I think badblocks rocks! A quick brown fox jumped over the lazy dog!</textarea>
|
||||||
|
{"msg" in state.fields && state.fields.msg.hasError && <p>{state.fields.msg.error}</p>}
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<button id="send_otp" name="action" value="send_otp" type="submit" disabled={state.state !== "initial"}>Verify Your Number!</button>
|
||||||
|
<fieldset id="send_msg" disabled={state.state !== "otp_sent"}>
|
||||||
|
<label for="otp">
|
||||||
|
Code
|
||||||
|
<input type="text" id="otp" name="otp" placeholder="123456" />
|
||||||
|
{"otp" in state.fields && state.fields.otp.hasError && <p>{state.fields.otp.error}</p>}
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<button id="send_msg" name="action" value="send_msg" type="submit" disabled={state.state !== "otp_sent"}>Text Me!</button>
|
||||||
|
<label for="captcha">
|
||||||
|
Captcha <span id="captcha-credits"></span>
|
||||||
|
<cap-widget id="captcha" data-cap-api-endpoint="/cap/"></cap-widget>
|
||||||
|
{"captcha" in state.fields && state.fields.captcha.hasError && <p>{state.fields.captcha.error}</p>}
|
||||||
|
</label>
|
||||||
|
</form> || <p>Your message has been sent successfully!</p>}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { APIContext, APIRoute, AstroGlobal } from "astro";
|
import type { APIContext, APIRoute, AstroSession } from "astro";
|
||||||
import SmsClient from "@lib/SmsGatewayClient.ts";
|
import SmsClient from "@lib/SmsGatewayClient.ts";
|
||||||
import Otp, { verifyOtp } from "@lib/Otp.ts";
|
import Otp, { verifyOtp } from "@lib/Otp.ts";
|
||||||
import CapServer from "@lib/CapAdapter";
|
import CapServer from "@lib/CapAdapter";
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
OTP_SUPER_SECRET_SALT,
|
OTP_SUPER_SECRET_SALT,
|
||||||
ANDROID_SMS_GATEWAY_RECIPIENT_PHONE,
|
ANDROID_SMS_GATEWAY_RECIPIENT_PHONE,
|
||||||
} from "astro:env/server";
|
} from "astro:env/server";
|
||||||
import type { defaultSettings } from "astro/runtime/client/dev-toolbar/settings.js";
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const OTP_SALT = OTP_SUPER_SECRET_SALT;
|
const OTP_SALT = OTP_SUPER_SECRET_SALT;
|
||||||
|
|
@ -15,9 +14,16 @@ if (!OTP_SALT) {
|
||||||
throw new Error("OTP secret salt configuration is missing.");
|
throw new Error("OTP secret salt configuration is missing.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendOtp({
|
async function sendOtp(
|
||||||
phone,
|
phone: string | undefined,
|
||||||
}: ContactFormOtpPayload): Promise<SendSMSResult> {
|
): Promise<ContactForm.SendSMSResult> {
|
||||||
|
if (!phone) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Phone number is required.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const otp = Otp.generateOtp(phone, OTP_SALT);
|
const otp = Otp.generateOtp(phone, OTP_SALT);
|
||||||
const stepSeconds = Otp.getOtpStep();
|
const stepSeconds = Otp.getOtpStep();
|
||||||
const stepMinutes = Math.floor(stepSeconds / 60);
|
const stepMinutes = Math.floor(stepSeconds / 60);
|
||||||
|
|
@ -37,24 +43,31 @@ async function sendOtp({
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
errors: { form: "Verification code failed to send." },
|
error: "Verification code failed to send.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMsg({
|
async function sendMsg(
|
||||||
name,
|
name: string | undefined,
|
||||||
phone,
|
phone: string | undefined,
|
||||||
code,
|
otp: string | undefined,
|
||||||
msg,
|
msg: string | undefined,
|
||||||
}: ContactFormMsgPayload): Promise<SendSMSResult> {
|
): Promise<ContactForm.SendSMSResult> {
|
||||||
|
if (!name || !phone || !otp || !msg) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "SendMsg: Missing required fields",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const message = `Web message from ${name} ( ${phone} ):\n\n"${msg}"`;
|
const message = `Web message from ${name} ( ${phone} ):\n\n"${msg}"`;
|
||||||
|
|
||||||
const isVerified = verifyOtp(phone, OTP_SALT, code);
|
const isVerified = verifyOtp(phone, OTP_SALT, otp);
|
||||||
if (!isVerified) {
|
if (!isVerified) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
errors: { code: "Invalid or expired verification code." },
|
error: "Invalid or expired verification code.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +86,7 @@ async function sendMsg({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
errors: { form: "Message failed to send." },
|
error: "Message failed to send.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,9 +101,9 @@ export const ALL: APIRoute = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function validateFields<K extends ContactForm.FieldKey>(
|
async function validateFields<K extends ContactForm.FieldKey>(
|
||||||
unsafe: ContactForm.Fields<K>,
|
unsafe: ContactForm.Fields<K>,
|
||||||
): ContactForm.Fields<K> {
|
): Promise<ContactForm.Fields<K>> {
|
||||||
const fields: Partial<ContactForm.Fields<K>> = {};
|
const fields: Partial<ContactForm.Fields<K>> = {};
|
||||||
const printableAsciiRegex = /^[\x20-\x7E\n\r]*$/;
|
const printableAsciiRegex = /^[\x20-\x7E\n\r]*$/;
|
||||||
const sixDigitsOnlyRegex = /^[0-9]{6}$/;
|
const sixDigitsOnlyRegex = /^[0-9]{6}$/;
|
||||||
|
|
@ -166,13 +179,21 @@ function validateFields<K extends ContactForm.FieldKey>(
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "code": {
|
case "otp": {
|
||||||
if (!sixDigitsOnlyRegex.test(value)) {
|
if (!sixDigitsOnlyRegex.test(value)) {
|
||||||
error = "OTP code invalid.";
|
error = "OTP code invalid.";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "captcha": {
|
||||||
|
const capValidation = await CapServer.validateToken(value);
|
||||||
|
if (!capValidation.success) {
|
||||||
|
error = "Invalid captcha token.";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -181,31 +202,32 @@ function validateFields<K extends ContactForm.FieldKey>(
|
||||||
fields[field] = { hasError: false, value };
|
fields[field] = { hasError: false, value };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fields as ContactForm.Fields<K>;
|
return fields as ContactForm.Fields<K>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidState = (value: unknown): value is ContactForm.State => {
|
export function generateInitialState(error?: string): ContactForm.State {
|
||||||
if (typeof value !== "object" || value === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate = value as Partial<ContactForm.State>;
|
|
||||||
return (
|
return (
|
||||||
typeof candidate.state === "string" &&
|
!error
|
||||||
(typeof candidate.fields === "object" ||
|
? {
|
||||||
typeof candidate.fields === "undefined") &&
|
state: "initial",
|
||||||
(typeof candidate.error === "string" ||
|
fields: {},
|
||||||
typeof candidate.error === "undefined") &&
|
hasError: false,
|
||||||
typeof candidate.hasError === "boolean"
|
}
|
||||||
);
|
: {
|
||||||
};
|
state: "initial",
|
||||||
|
fields: {},
|
||||||
|
error,
|
||||||
|
hasError: true,
|
||||||
|
}
|
||||||
|
) as ContactForm.State;
|
||||||
|
}
|
||||||
|
|
||||||
export const POST: APIRoute = async (Astro) => {
|
const respondWithState = (state: ContactForm.State) =>
|
||||||
const respondWithState = (state: ContactForm.State) =>
|
new Response(JSON.stringify(state), {
|
||||||
new Response(JSON.stringify(state), {
|
status: state.hasError ? 400 : 200,
|
||||||
status: state.hasError ? 400 : 200,
|
});
|
||||||
});
|
|
||||||
|
export const POST: APIRoute = async (Astro: APIContext) => {
|
||||||
try {
|
try {
|
||||||
const initialState = await processRequestIntoState(Astro);
|
const initialState = await processRequestIntoState(Astro);
|
||||||
if (initialState.hasError) {
|
if (initialState.hasError) {
|
||||||
|
|
@ -217,24 +239,15 @@ export const POST: APIRoute = async (Astro) => {
|
||||||
return respondWithState(validatedState);
|
return respondWithState(validatedState);
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalState = await runStateAction(validatedState);
|
const finalState = await runStateAction(validatedState, Astro);
|
||||||
return respondWithState(finalState);
|
return respondWithState(finalState);
|
||||||
} catch (caught) {
|
} catch (error) {
|
||||||
if (isValidState(caught)) {
|
|
||||||
return respondWithState(caught);
|
|
||||||
}
|
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
caught instanceof Error
|
error instanceof Error
|
||||||
? caught.message
|
? "Unexpected POST error: " + error.message
|
||||||
: String(caught ?? "Unexpected error");
|
: "Unexpected POST error.";
|
||||||
|
|
||||||
return respondWithState({
|
return respondWithState(generateInitialState(message));
|
||||||
state: "initial",
|
|
||||||
fields: {},
|
|
||||||
hasError: true,
|
|
||||||
error: message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -266,38 +279,43 @@ export async function processRequestIntoState(
|
||||||
throw "Data is undefined.";
|
throw "Data is undefined.";
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = await data.get("action")?.toString();
|
const action = await data.get("action");
|
||||||
|
|
||||||
if (!action) {
|
if (!action) {
|
||||||
throw "Invalid action";
|
throw "Invalid action";
|
||||||
}
|
}
|
||||||
|
|
||||||
fields.name = {
|
//TODO: session.get returns undefined always
|
||||||
hasError: false,
|
if (action == "send_otp" || action == "send_msg") {
|
||||||
value:
|
fields.name = {
|
||||||
action === "send_msg"
|
hasError: false,
|
||||||
? session.get("name")?.toString()
|
value: await (action === "send_msg"
|
||||||
: await data.get("name")?.toString(),
|
? session.get("name")
|
||||||
};
|
: data.get("name")),
|
||||||
fields.phone = {
|
};
|
||||||
hasError: false,
|
fields.phone = {
|
||||||
value:
|
hasError: false,
|
||||||
action === "send_msg"
|
value: await (action === "send_msg"
|
||||||
? session.get("phone")?.toString()
|
? session.get("phone")
|
||||||
: await data.get("phone")?.toString(),
|
: data.get("phone")),
|
||||||
};
|
};
|
||||||
fields.msg = {
|
fields.msg = {
|
||||||
hasError: false,
|
hasError: false,
|
||||||
value:
|
value: await (action === "send_msg"
|
||||||
action === "send_msg"
|
? session.get("msg")
|
||||||
? session.get("msg")?.toString()
|
: data.get("msg")),
|
||||||
: await data.get("msg")?.toString(),
|
};
|
||||||
};
|
fields.captcha = {
|
||||||
fields.captcha = {
|
hasError: false,
|
||||||
hasError: false,
|
value: await data.get("cap-token"),
|
||||||
value: data.get("cap-token")?.toString(),
|
};
|
||||||
};
|
if (action === "send_msg") {
|
||||||
fields.code = { hasError: false, value: data.get("code")?.toString() };
|
fields.otp = {
|
||||||
|
hasError: false,
|
||||||
|
value: await data.get("otp"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: action,
|
state: action,
|
||||||
|
|
@ -307,24 +325,127 @@ export async function processRequestIntoState(
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
state: "initial",
|
state: "initial",
|
||||||
fields,
|
fields: {},
|
||||||
hasError: true,
|
hasError: true,
|
||||||
error: error instanceof Error ? error.message : "Unknown error.",
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? "Unexpected processRequest error: " + error.message
|
||||||
|
: "Unexpected processRequest error.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextState(state: ContactForm.State): ContactForm.State {
|
||||||
|
if (state.hasError) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = {
|
||||||
|
state: "initial",
|
||||||
|
fields: {},
|
||||||
|
hasError: false,
|
||||||
|
};
|
||||||
|
switch (state.state) {
|
||||||
|
case "send_otp":
|
||||||
|
next.state = "otp_sent";
|
||||||
|
break;
|
||||||
|
case "send_msg":
|
||||||
|
next.state = "complete";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return next as ContactForm.State;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevState(state: ContactForm.State): ContactForm.State {
|
||||||
|
let next = {
|
||||||
|
state: "initial",
|
||||||
|
fields: {},
|
||||||
|
hasError: state.hasError,
|
||||||
|
};
|
||||||
|
switch (state.state) {
|
||||||
|
case "send_otp":
|
||||||
|
next.state = "initial";
|
||||||
|
break;
|
||||||
|
case "send_msg":
|
||||||
|
next.state = "otp_sent";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return next as ContactForm.State;
|
||||||
|
}
|
||||||
|
|
||||||
export async function validateState(
|
export async function validateState(
|
||||||
state: ContactForm.State,
|
state: ContactForm.State,
|
||||||
): Promise<ContactForm.State> {
|
): Promise<ContactForm.State> {
|
||||||
state.fields = validateFields(state.fields);
|
try {
|
||||||
// if state.fields has any errors, set hasError on state too and set a message
|
state.fields = await validateFields(state.fields);
|
||||||
return state;
|
// if state.fields has any errors, set hasError on state too and set a message
|
||||||
|
return state;
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
state: "initial",
|
||||||
|
fields: {},
|
||||||
|
hasError: true,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? "Unexpected validateState error: " + error.message
|
||||||
|
: "Unexpected validateState error.",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runStateAction(
|
export async function runStateAction(
|
||||||
state: ContactForm.State,
|
state: ContactForm.State,
|
||||||
|
Astro: APIContext,
|
||||||
): Promise<ContactForm.State> {
|
): Promise<ContactForm.State> {
|
||||||
//Todo
|
const { session } = Astro;
|
||||||
return state;
|
|
||||||
|
try {
|
||||||
|
if (state.state === "send_otp" || state.state === "send_msg") {
|
||||||
|
const name = state.fields.name.value;
|
||||||
|
const phone = state.fields.phone.value;
|
||||||
|
const msg = state.fields.msg.value;
|
||||||
|
const otp =
|
||||||
|
state.state === "send_msg" ? state.fields.otp.value : undefined;
|
||||||
|
|
||||||
|
let result;
|
||||||
|
switch (state.state) {
|
||||||
|
case "send_otp":
|
||||||
|
result = await sendOtp(phone);
|
||||||
|
if (result.success) {
|
||||||
|
session?.set("name", name);
|
||||||
|
session?.set("phone", phone);
|
||||||
|
session?.set("msg", msg);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "send_msg":
|
||||||
|
result = await sendMsg(name, phone, msg, otp);
|
||||||
|
if (result.success) {
|
||||||
|
session?.delete("name");
|
||||||
|
session?.delete("phone");
|
||||||
|
session?.delete("msg");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!result.success) {
|
||||||
|
state.hasError = true;
|
||||||
|
state.error = result.error;
|
||||||
|
state = prevState(state);
|
||||||
|
} else {
|
||||||
|
state = nextState(state);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return generateInitialState("Invalid action.");
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
state: "initial",
|
||||||
|
fields: {},
|
||||||
|
hasError: true,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? "Unexpected runAction error: " + error.message
|
||||||
|
: "Unexpected runAction error.",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export type FieldKey = "name" | "phone" | "msg" | "code" | "captcha" | "form";
|
export type FieldKey = "name" | "phone" | "msg" | "otp" | "captcha" | "form";
|
||||||
export type FieldValue = {
|
export type FieldValue = {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
value?: string;
|
value?: string;
|
||||||
|
|
@ -24,11 +24,11 @@ export type SendOtpState = {
|
||||||
|
|
||||||
export type OtpSentState = {
|
export type OtpSentState = {
|
||||||
state: "otp_sent";
|
state: "otp_sent";
|
||||||
} & BaseState<"name" | "phone" | "msg" | "captcha">;
|
} & BaseState<never>;
|
||||||
|
|
||||||
export type SendMsgState = {
|
export type SendMsgState = {
|
||||||
state: "send_msg";
|
state: "send_msg";
|
||||||
} & BaseState<"name" | "phone" | "msg" | "code" | "captcha">;
|
} & BaseState<"name" | "phone" | "msg" | "otp" | "captcha">;
|
||||||
|
|
||||||
export type CompleteState = {
|
export type CompleteState = {
|
||||||
state: "complete";
|
state: "complete";
|
||||||
|
|
@ -40,3 +40,13 @@ export type State =
|
||||||
| OtpSentState
|
| OtpSentState
|
||||||
| SendMsgState
|
| SendMsgState
|
||||||
| CompleteState;
|
| CompleteState;
|
||||||
|
|
||||||
|
export type SMSResultSuccess = {
|
||||||
|
success: true;
|
||||||
|
expiresInSeconds?: number;
|
||||||
|
};
|
||||||
|
export type SMSResultFailure = {
|
||||||
|
success: false;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
export type SendSMSResult = SMSResultSuccess | SMSResultFailure;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue