diff --git a/App/app/(portal)/admin/vendors/vendor-form.tsx b/App/app/(portal)/admin/vendors/vendor-form.tsx
index 7a4558d..a154d60 100644
--- a/App/app/(portal)/admin/vendors/vendor-form.tsx
+++ b/App/app/(portal)/admin/vendors/vendor-form.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2 } from "lucide-react";
import { AdminDialog } from "@/components/ui/admin-dialog";
@@ -113,6 +113,44 @@ function ContactsEditor({ initial }: { initial?: ContactRow[] }) {
);
}
+// CAPTCHA popup — overlays the vendor form (which is itself an AdminDialog at z-50) so the
+// CAPTCHA never grows the form and pushes its footer buttons off-screen. Sits at z-[60] and
+// handles Escape on the capture phase so closing it does NOT also close the underlying form.
+function CaptchaPopup({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
+ useEffect(() => {
+ if (!open) return;
+ function onKey(e: KeyboardEvent) {
+ if (e.key === "Escape") { e.stopImmediatePropagation(); onClose(); }
+ }
+ document.addEventListener("keydown", onKey, true);
+ return () => document.removeEventListener("keydown", onKey, true);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ return (
+
{ if (e.target === e.currentTarget) onClose(); }}
+ >
+
+
+
GSTIN CAPTCHA
+
+
+
{children}
+
+
+ );
+}
+
function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendor?: VendorRow; suggestedVendorId?: string; simple?: boolean }) {
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
const [name, setName] = useState(vendor?.name ?? "");
@@ -149,13 +187,19 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }),
});
const data: GstResult & { error?: string } = await res.json();
- if (data.error) { setGstError(data.error); setCaptchaStep("idle"); return; }
+ // Keep the popup open on error so the user sees it in context and can retry / get a new image.
+ if (data.error) { setGstError(data.error); setCaptchaStep("ready"); return; }
setName(data.tradeName || data.legalName);
setAddress(data.address);
if (data.pincode) setPincode(data.pincode);
setGstSuccess(`✓ ${data.legalName} — ${data.status} since ${data.registrationDate}`);
setCaptchaStep("idle");
- } catch { setGstError("Lookup failed"); setCaptchaStep("idle"); }
+ } catch { setGstError("Lookup failed"); setCaptchaStep("ready"); }
+ }
+
+ // Close the CAPTCHA popup without touching the vendor form fields.
+ function closeCaptcha() {
+ setCaptchaStep("idle"); setCaptchaAnswer(""); setGstError("");
}
return (
@@ -183,31 +227,46 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
{captchaStep === "loading" ? "Loading…" : "Look up"}
- {captchaStep === "ready" && captchaB64 && (
-
-
Enter the code shown in the image:
-

-
-
setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
- placeholder="6 digits"
- className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none"
- autoFocus
- onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
- />
-
-
+
+ {captchaStep === "loading" ? (
+ Loading CAPTCHA…
+ ) : (
+
+
Enter the code shown in the image:
+ {captchaB64 && (
+

+ )}
+
+ setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
+ placeholder="6 digits"
+ disabled={captchaStep === "verifying"}
+ className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none disabled:opacity-60"
+ autoFocus
+ onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
+ />
+
+
+
+ {gstError &&
{gstError}
}
+
+
+
-
- )}
- {captchaStep === "verifying" &&
Verifying…
}
- {gstError &&
{gstError}
}
+ )}
+
+ {/* Errors before the popup opens (e.g. invalid GSTIN) show inline; in-popup errors show in context above. */}
+ {captchaStep === "idle" && gstError &&
{gstError}
}
{gstSuccess &&
{gstSuccess}
}
diff --git a/App/tests/unit/vendor-form-captcha.test.tsx b/App/tests/unit/vendor-form-captcha.test.tsx
new file mode 100644
index 0000000..d3aba25
--- /dev/null
+++ b/App/tests/unit/vendor-form-captcha.test.tsx
@@ -0,0 +1,96 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { AddVendorButton } from "@/app/(portal)/admin/vendors/vendor-form";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
+}));
+
+// The form imports server actions; stub them so the client component renders in jsdom.
+vi.mock("@/app/(portal)/admin/vendors/actions", () => ({
+ createVendor: vi.fn(),
+ updateVendor: vi.fn(),
+ toggleVendorActive: vi.fn(),
+}));
+
+const GSTIN = "27AAHCP5787B1Z6"; // 15 chars
+
+describe("VendorForm — GSTIN CAPTCHA popup (issue #114)", () => {
+ beforeEach(() => {
+ global.fetch = vi.fn(async () =>
+ new Response(JSON.stringify({ captchaBase64: "ABC123", sessionId: "sess-1" }), {
+ headers: { "Content-Type": "application/json" },
+ }),
+ ) as unknown as typeof fetch;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ async function openFormAndLookup() {
+ render();
+ fireEvent.click(screen.getByText("+ Add Vendor"));
+ const gstinInput = screen.getByPlaceholderText(/27AAHCP5787B1Z6/);
+ fireEvent.change(gstinInput, { target: { value: GSTIN } });
+ fireEvent.click(screen.getByText("Look up"));
+ // Popup renders the CAPTCHA prompt once the fetch resolves.
+ await screen.findByText(/Enter the code shown in the image/i);
+ }
+
+ it("opens the CAPTCHA in a popup with a Cancel/Close control, leaving the form footer reachable", async () => {
+ await openFormAndLookup();
+ // The CAPTCHA lives in its own popup …
+ expect(screen.getByRole("heading", { name: /GSTIN CAPTCHA/i })).toBeTruthy();
+ // Both the form's ✕ and the popup's ✕/Cancel close controls are present.
+ expect(screen.getAllByLabelText("Close").length).toBeGreaterThanOrEqual(2);
+ expect(screen.getAllByText("Cancel").length).toBeGreaterThanOrEqual(2);
+ // … and the underlying vendor form's submit button is still rendered (never displaced).
+ expect(screen.getByText("Create Vendor")).toBeTruthy();
+ });
+
+ it("closes the popup on Cancel without closing the vendor form", async () => {
+ await openFormAndLookup();
+ // The popup's Cancel is the first one in the DOM (the CAPTCHA section precedes the footer).
+ fireEvent.click(screen.getAllByText("Cancel")[0]);
+ await waitFor(() => {
+ expect(screen.queryByText(/Enter the code shown in the image/i)).toBeNull();
+ });
+ // The vendor form itself stays open.
+ expect(screen.getByText("Create Vendor")).toBeTruthy();
+ expect(screen.queryByRole("heading", { name: /GSTIN CAPTCHA/i })).toBeNull();
+ });
+
+ it("verifies the CAPTCHA, fills the form fields, and closes the popup on success", async () => {
+ (global.fetch as ReturnType).mockImplementation(async (url: string) => {
+ if (String(url).includes("/api/gst/captcha")) {
+ return new Response(JSON.stringify({ captchaBase64: "ABC123", sessionId: "sess-1" }), {
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return new Response(
+ JSON.stringify({
+ legalName: "Acme Pvt Ltd",
+ tradeName: "Acme",
+ address: "1 Dock Rd",
+ pincode: "400001",
+ gstin: GSTIN,
+ status: "Active",
+ registrationDate: "2020-01-01",
+ }),
+ { headers: { "Content-Type": "application/json" } },
+ );
+ });
+
+ await openFormAndLookup();
+ fireEvent.change(screen.getByPlaceholderText("6 digits"), { target: { value: "123456" } });
+ fireEvent.click(screen.getByText("Verify"));
+
+ // Popup closes; success line + populated fields appear on the form.
+ await waitFor(() => {
+ expect(screen.queryByText(/Enter the code shown in the image/i)).toBeNull();
+ });
+ expect((screen.getByDisplayValue("Acme") as HTMLInputElement)).toBeTruthy();
+ expect(screen.getByText(/Acme Pvt Ltd — Active/)).toBeTruthy();
+ });
+});