From 55ae1d46d0d95f8adb0563343c8109733fb38b7f Mon Sep 17 00:00:00 2001 From: "Claude (auto-fix)" Date: Wed, 24 Jun 2026 06:37:29 +0530 Subject: [PATCH] fix(vendors): move GSTIN CAPTCHA into a popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GSTIN lookup rendered its CAPTCHA (image + 6-digit input + Verify / New image) inline inside the Add/Edit Vendor dialog. AdminDialog has no internal scroll and is vertically centred, so the taller form pushed its footer (Cancel / Create Vendor / Save) off-screen and out of reach. Extract the CAPTCHA into a dedicated popup (CaptchaPopup) overlaid on the vendor form at z-[60] with an explicit Cancel button and a ✕ close control. It handles Escape on the capture phase so dismissing the CAPTCHA does not also close the underlying form. In-flight CAPTCHA errors now show inside the popup (it stays open so the user can retry / get a new image); the success line still lands on the main form. The form footer is never displaced. Adds a unit test covering popup open on Look up, Cancel closing only the popup, and a successful verify populating the fields. Fixes #114 --- .../(portal)/admin/vendors/vendor-form.tsx | 113 +++++++++++++----- App/tests/unit/vendor-form-captcha.test.tsx | 96 +++++++++++++++ 2 files changed, 182 insertions(+), 27 deletions(-) create mode 100644 App/tests/unit/vendor-form-captcha.test.tsx 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:

- CAPTCHA -
- 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 && ( + CAPTCHA + )} +
+ 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(); + }); +});