More progress but need to fully refactor into Astro action and take advantage of

all the boilerplate
This commit is contained in:
badblocks 2026-01-19 18:10:33 -08:00
parent f5eac7145c
commit 608348a5a5
No known key found for this signature in database
7 changed files with 411 additions and 142 deletions

View file

@ -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

Binary file not shown.

View file

@ -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",

View file

@ -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) { return response;
errors.form = response.message || "An unexpected error occurred.";
return { errors, success, action };
}
action = response.data.nextAction;
return { errors, success, action };
} 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>}

View file

@ -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>

View file

@ -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";
} }
//TODO: session.get returns undefined always
if (action == "send_otp" || action == "send_msg") {
fields.name = { fields.name = {
hasError: false, hasError: false,
value: value: await (action === "send_msg"
action === "send_msg" ? session.get("name")
? session.get("name")?.toString() : data.get("name")),
: await data.get("name")?.toString(),
}; };
fields.phone = { fields.phone = {
hasError: false, hasError: false,
value: value: await (action === "send_msg"
action === "send_msg" ? session.get("phone")
? session.get("phone")?.toString() : data.get("phone")),
: await data.get("phone")?.toString(),
}; };
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: data.get("cap-token")?.toString(), value: await data.get("cap-token"),
}; };
fields.code = { hasError: false, value: data.get("code")?.toString() }; if (action === "send_msg") {
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 {
state.fields = await validateFields(state.fields);
// if state.fields has any errors, set hasError on state too and set a message // if state.fields has any errors, set hasError on state too and set a message
return state; 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;
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; return state;
} catch (error) {
return {
state: "initial",
fields: {},
hasError: true,
error:
error instanceof Error
? "Unexpected runAction error: " + error.message
: "Unexpected runAction error.",
};
}
} }

View file

@ -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;