All files / src/scripts consultation-booking.ts

95% Statements 76/80
89.83% Branches 53/59
88.88% Functions 8/9
100% Lines 67/67

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154                          9x 9x 9x 9x       10x     10x   9x   9x 9x     9x     2x 2x 2x       9x 8x 8x           19x 19x 19x 2x 2x   17x 19x 19x 2x 2x   15x 15x         9x 1x 1x   9x 9x       9x   14x 14x 7x     9x 9x 9x 9x   9x 9x 9x       9x 9x 9x       9x 9x 9x       9x 9x 2x 2x     7x   7x 7x                       6x           6x 5x 5x 5x 5x           5x   1x     1x   7x        
// Client script for the standalone Free Consultation Booking form.
// Lightweight, framework-free (matches the marketplace's minimal-JS pattern).
// Client checks are for fast feedback only — 'api/book-consultation.ts' is the
// authoritative validator (shared 'lib/consultation.ts' rules).
//
// Exposed as an init function (not a module side effect) so it is unit-testable
// with a jsdom fixture; the .astro component calls it on load.
 
import { LEAD_CONSENT_VERSION } from "@interioring/utils/constants/consent";
import { firstTouch } from "./attribution";
 
// Basic Indian-mobile shape check (server does the authoritative parse).
function looksLikeIndianMobile(raw: string): boolean {
	let digits = raw.replace(/\D/g, "");
	if (digits.length === 12 && digits.startsWith("91")) digits = digits.slice(2);
	if (digits.length === 11 && digits.startsWith("0")) digits = digits.slice(1);
	return /^[6-9]\d{9}$/.test(digits);
}
 
export function initConsultationBooking(): void {
	const form = document.getElementById(
		"consultation-form",
	) as HTMLFormElement | null;
	if (!form) return;
 
	const confirmation = document.getElementById("consultation-confirmation");
	// Server and network errors; field errors sit under their field.
	const errorEl = document.getElementById("cb-error");
	const submitBtn = document.getElementById(
		"cb-submit",
	) as HTMLButtonElement | null;
	const refEl = document.getElementById("cb-ref");
 
	function showError(message: string): void {
		Iif (!errorEl) return;
		errorEl.textContent = message;
		errorEl.classList.remove("hidden");
	}
 
	function clearError(): void {
		if (!errorEl) return;
		errorEl.textContent = "";
		errorEl.classList.add("hidden");
	}
 
	// A field error (`#cb-{name}-error`, role="alert") is tied to its field
	// with aria-invalid + aria-describedby, as the sign-in modal does.
	function setFieldError(name: string, message: string | null): void {
		const field = form?.querySelector<HTMLElement>(`[name="${name}"]`);
		const el = document.getElementById(`cb-${name}-error`);
		if (!field || !el) {
			Iif (message) showError(message);
			return;
		}
		el.textContent = message ?? "";
		el.classList.toggle("hidden", !message);
		if (message) {
			field.setAttribute("aria-invalid", "true");
			field.setAttribute("aria-describedby", el.id);
		} else {
			field.removeAttribute("aria-invalid");
			field.removeAttribute("aria-describedby");
		}
	}
 
	// Typing in (or ticking) a field clears its error.
	const clearOwnError = (e: Event) => {
		const name = (e.target as HTMLInputElement).name;
		Eif (name) setFieldError(name, null);
	};
	form.addEventListener("input", clearOwnError);
	form.addEventListener("change", clearOwnError);
 
	// The button is never `disabled`: disabling the focused button drops focus
	// to <body>. While sending, aria-disabled plus the flag block a resubmit.
	let sending = false;
	function setSending(on: boolean): void {
		sending = on;
		if (on) submitBtn?.setAttribute("aria-disabled", "true");
		else submitBtn?.removeAttribute("aria-disabled");
	}
 
	form.addEventListener("submit", async (e) => {
		e.preventDefault();
		Iif (sending) return;
		clearError();
 
		const fd = new FormData(form);
		const phone = String(fd.get("phone") ?? "").trim();
		const details = String(fd.get("details") ?? "").trim();
 
		// Client-side quick check (mirrors server messages). Every problem is
		// shown at once; focus goes to the first.
		const invalid: string[] = [];
		const phoneOk = phone !== "" && looksLikeIndianMobile(phone);
		setFieldError(
			"phone",
			phoneOk ? null : "Enter a valid 10-digit Indian mobile number.",
		);
		if (!phoneOk) invalid.push("phone");
		const consentOk = Boolean(fd.get("consent"));
		setFieldError(
			"consent",
			consentOk ? null : "Please tick the box to agree to be contacted.",
		);
		if (!consentOk) invalid.push("consent");
		if (invalid.length > 0) {
			form.querySelector<HTMLElement>(`[name="${invalid[0]}"]`)?.focus();
			return;
		}
 
		setSending(true);
 
		try {
			const res = await fetch("/api/book-consultation", {
				method: "POST",
				headers: { "Content-Type": "application/json" },
				body: JSON.stringify({
					...firstTouch(),
					consent: true,
					consentVersion: LEAD_CONSENT_VERSION,
					entryType: "form",
					phone,
					details: details || undefined,
				}),
			});
			const json = (await res.json().catch(() => ({}))) as {
				success?: boolean;
				error?: string;
				data?: { bookingRef?: string };
			};
 
			if (res.ok && json.success && json.data) {
				if (refEl) refEl.textContent = json.data.bookingRef ?? "—";
				form.classList.add("hidden");
				confirmation?.classList.remove("hidden");
				confirmation?.scrollIntoView?.({
					behavior: "smooth",
					block: "center",
				});
				// The form (and the focused button) just vanished: focus the
				// confirmation heading so it is read out.
				confirmation?.querySelector<HTMLElement>("[tabindex='-1']")?.focus();
			} else {
				showError(json.error ?? "Something went wrong. Please try again.");
			}
		} catch {
			showError("Could not submit right now. Please try again.");
		} finally {
			setSending(false);
		}
	});
}