From 2a4f3e7e4b31c53054db8a86b9451df79fc5936d Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Sat, 25 Apr 2026 12:35:03 +0330 Subject: [PATCH] refactor(loading, auth, status): enhance UI components for improved aesthetics and functionality - Redesigned Loading component to feature a full-screen loader with animated elements for a modern look. - Updated SigninWithPassword component to include error handling and improved password visibility toggle. - Refactored Status component to utilize a tone-based styling system for better visual feedback based on status types. --- cypress/e2e/auth/sign-in-page.cy.ts | 109 +++++++++++++++ src/components/Auth/SigninWithPassword.tsx | 25 +++- src/components/loading.tsx | 104 ++++++++++++-- src/components/ui/Status.tsx | 152 ++++++++++++++++----- src/components/ui/pagination.tsx | 12 +- 5 files changed, 350 insertions(+), 52 deletions(-) create mode 100644 cypress/e2e/auth/sign-in-page.cy.ts diff --git a/cypress/e2e/auth/sign-in-page.cy.ts b/cypress/e2e/auth/sign-in-page.cy.ts new file mode 100644 index 0000000..ecf4335 --- /dev/null +++ b/cypress/e2e/auth/sign-in-page.cy.ts @@ -0,0 +1,109 @@ +/// + +Cypress.env("AUTO_LOGIN", false); + +describe("sign-in page", () => { + after(() => { + Cypress.env("AUTO_LOGIN", true); + }); + + beforeEach(() => { + cy.clearCookies(); + cy.clearLocalStorage(); + + cy.intercept("POST", "**/api/proxy/profile/login*", (req) => { + req.reply({ + delay: 2500, + statusCode: 200, + body: { + success: true, + message: "ok", + data: { + access_token: "e2e-access-token", + refresh_token: "e2e-refresh-token", + expires_at: Date.now() + 60_000, + user: { + id: "507f1f77bcf86cd799439011", + username: "tester", + email: "tester@example.com", + role: "admin", + }, + }, + }, + }); + }).as("loginRequest"); + + cy.visit("/auth/sign-in"); + cy.location("pathname").should("eq", "/auth/sign-in"); + }); + + it("keeps the sign-in box centered and visible in viewport", () => { + cy.get("div[class*='max-w-'][class*='shadow-theme-xs'][class*='rounded-2xl']") + .first() + .should("be.visible") + .then(($card) => { + const rect = $card[0].getBoundingClientRect(); + + cy.window().then((win) => { + const viewportCenterX = win.innerWidth / 2; + const viewportCenterY = win.innerHeight / 2; + const cardCenterX = rect.left + rect.width / 2; + const cardCenterY = rect.top + rect.height / 2; + + expect(rect.top).to.be.gte(0); + expect(rect.left).to.be.gte(0); + expect(rect.bottom).to.be.lte(win.innerHeight); + expect(rect.right).to.be.lte(win.innerWidth); + expect(Math.abs(cardCenterX - viewportCenterX)).to.be.lessThan(80); + expect(Math.abs(cardCenterY - viewportCenterY)).to.be.lessThan(100); + }); + }); + }); + + it("toggles theme correctly", () => { + cy.get("html").should("not.have.class", "dark"); + cy.contains("button", "Switch to dark mode").click(); + cy.get("html").should("have.class", "dark"); + + cy.contains("button", "Switch to light mode").click(); + cy.get("html").should("not.have.class", "dark"); + }); + + it("shows required errors under each field and does not call login API", () => { + cy.contains('button[type="submit"]', "Sign in").click(); + + cy.get("p.text-red") + .should("have.length", 2) + .each(($message) => { + expect($message.text().trim()).to.eq("This field is required"); + }); + cy.get("@loginRequest.all").should("have.length", 0); + }); + + it("shows and hides password value when lock icon is clicked", () => { + cy.get('input[name="password"]').as("passwordInput"); + cy.get("@passwordInput").should("have.attr", "type", "password"); + + cy.get('button[aria-label="Show password"]').click(); + cy.get("@passwordInput").should("have.attr", "type", "text"); + + cy.get('button[aria-label="Hide password"]').click(); + cy.get("@passwordInput").should("have.attr", "type", "password"); + }); + + it("shows default button text first, then spinner while submitting", () => { + cy.contains('button[type="submit"]', "Sign in") + .as("submitButton") + .should("be.visible") + .and("not.be.disabled"); + + cy.get('input[name="username"]').type("tester"); + cy.get('input[name="password"]').type("Secret123!"); + + cy.get("@submitButton").click(); + cy.get('button[type="submit"]') + .find("div.animate-spin", { timeout: 10000 }) + .should("exist"); + cy.get('button[type="submit"]').should("not.contain", "Sign in"); + }); +}); diff --git a/src/components/Auth/SigninWithPassword.tsx b/src/components/Auth/SigninWithPassword.tsx index eb4848a..ae35b2c 100644 --- a/src/components/Auth/SigninWithPassword.tsx +++ b/src/components/Auth/SigninWithPassword.tsx @@ -19,7 +19,12 @@ export default function SigninWithPassword() { const [passwordFieldInputType, setPasswordFieldInputType] = useState< "password" | "text" >("password"); - const { handleSubmit, reset, register } = useForm(); + const { + handleSubmit, + reset, + register, + formState: { errors }, + } = useForm(); const { mutate } = useLoginQuery(reset); const onSubmit: SubmitHandler = (data) => { @@ -46,6 +51,7 @@ export default function SigninWithPassword() { className="mb-4 [&_input]:py-[15px]" placeholder="Enter your Username" name="username" + errors={errors} icon={} /> @@ -62,15 +68,24 @@ export default function SigninWithPassword() { className="mb-5 [&_input]:py-[15px]" placeholder="Enter your password" name="password" + errors={errors} icon={ - { setPasswordFieldInputType( passwordFieldInputType === "password" ? "text" : "password", ); }} - /> + className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0" + aria-label={ + passwordFieldInputType === "password" + ? "Show password" + : "Hide password" + } + > + + } /> @@ -89,7 +104,7 @@ export default function SigninWithPassword() { type="submit" disabled={!!isMutating} className={cn( - "w-full rounded-xl bg-gradient-to-r from-primary to-primary/85 p-4 font-semibold shadow-theme-xs transition-all duration-300 hover:-translate-y-0.5 hover:opacity-95", + "shadow-theme-xs w-full rounded-xl bg-gradient-to-r from-primary to-primary/85 p-4 font-semibold transition-all duration-300 hover:-translate-y-0.5 hover:opacity-95", isMutating && "cursor-not-allowed opacity-70 hover:translate-y-0", )} /> diff --git a/src/components/loading.tsx b/src/components/loading.tsx index 785a8b3..dd4374c 100644 --- a/src/components/loading.tsx +++ b/src/components/loading.tsx @@ -3,20 +3,104 @@ import { cn } from "@/lib/utils"; interface IProps { className?: string | null; } + /** - * Grows with a flex `main` parent so the spinner sits in the middle of the content area. - * Guards: `min-h-svh flex-none`. Compact UI: `flex-none min-h-*`. + * Full-screen route-style loader (orbs, rings, shimmer). Pass `className` to adapt + * the shell—for example `min-h-48 flex-none` inside compact panels. */ const Loading = ({ className }: IProps) => { return ( -
-
-
+ <> + +
+ {/* Soft gradient wash */} +
+ + {/* Floating orbs */} +
+
+
+ + {/* Subtle grid */} +
+ +
+
+ {/* Outer glow */} +
+ + {/* Concentric rings */} +
+
+
+ + {/* Core */} +
+ + OL + +
+
+ +
+
+ + + +
+

+ Preparing your workspace… +

+
+ + {/* Shimmer bar */} +
+
+
+
+
+ ); }; diff --git a/src/components/ui/Status.tsx b/src/components/ui/Status.tsx index afcdab9..5b7d134 100644 --- a/src/components/ui/Status.tsx +++ b/src/components/ui/Status.tsx @@ -6,48 +6,130 @@ interface IProps { children: ReactNode; } +const reds = [ + "cancelled", + "inactive", + "rejected", + "failed", + "denied", + "blocked", +]; +const greens = [ + "active", + "approved", + "completed", + "success", + "verified", + "published", + "sent", +]; +const blues = ["awarded", "processing", "in-review", "scheduled", "reviewed"]; +const yellows = ["pending", "warning", "on-hold", "delayed"]; +const oranges = ["expired", "expiring", "limited"]; +const grays = ["draft", "disabled", "archived", "paused", "suspended"]; + +type Tone = + | "green" + | "red" + | "blue" + | "yellow" + | "orange" + | "gray" + | "new" + | "info" + | "urgent" + | "featured"; + +function resolveTone(status: string): Tone { + if (greens.includes(status)) return "green"; + if (reds.includes(status)) return "red"; + if (blues.includes(status)) return "blue"; + if (yellows.includes(status)) return "yellow"; + if (oranges.includes(status)) return "orange"; + if (grays.includes(status)) return "gray"; + if (status === "new") return "new"; + if (status === "info") return "info"; + if (status === "urgent") return "urgent"; + if (status === "featured") return "featured"; + return "gray"; +} + +const toneStyles: Record< + Tone, + { root: string; dot: string; dotRing?: string } +> = { + green: { + root: "bg-gradient-to-br from-green via-green to-green-dark text-white shadow-[0_2px_10px_-2px_rgba(34,173,92,0.55)] ring-1 ring-inset ring-white/25", + dot: "bg-white shadow-[0_0_10px_rgba(255,255,255,0.85)]", + dotRing: "ring-1 ring-white/40", + }, + red: { + root: "bg-gradient-to-br from-error via-error to-error-dark text-white shadow-[0_2px_10px_-2px_rgba(242,48,48,0.5)] ring-1 ring-inset ring-white/20", + dot: "bg-white shadow-[0_0_10px_rgba(255,255,255,0.75)]", + dotRing: "ring-1 ring-white/35", + }, + blue: { + root: "bg-gradient-to-br from-blue via-blue to-blue-dark text-white shadow-[0_2px_10px_-2px_rgba(60,80,224,0.45)] ring-1 ring-inset ring-white/25", + dot: "bg-white shadow-[0_0_10px_rgba(255,255,255,0.8)]", + dotRing: "ring-1 ring-white/35", + }, + yellow: { + root: "border border-yellow-dark-2/15 bg-gradient-to-br from-yellow-light to-yellow-light/70 text-yellow-dark-2 shadow-sm shadow-yellow-dark/10 ring-1 ring-inset ring-white/60", + dot: "bg-yellow-dark-2 shadow-[0_0_8px_rgba(217,119,6,0.45)]", + dotRing: "ring-2 ring-yellow-dark-2/25", + }, + orange: { + root: "bg-gradient-to-br from-orange-light to-orange-light/85 text-white shadow-[0_2px_10px_-2px_rgba(245,148,96,0.45)] ring-1 ring-inset ring-white/25", + dot: "bg-white shadow-[0_0_8px_rgba(255,255,255,0.7)]", + dotRing: "ring-1 ring-white/30", + }, + gray: { + root: "border border-gray-4/60 bg-gradient-to-b from-gray-1 to-gray-2 text-gray-7 shadow-sm ring-1 ring-inset ring-white/80", + dot: "bg-gray-5 shadow-inner", + dotRing: "ring-1 ring-gray-4/50", + }, + new: { + root: "border border-green-light-3/80 bg-gradient-to-br from-green-light-6 via-green-light-6 to-green-light-5 text-green-dark shadow-sm shadow-green/10 ring-1 ring-inset ring-white/70", + dot: "bg-green shadow-[0_0_8px_rgba(34,173,92,0.5)]", + dotRing: "ring-2 ring-green/20", + }, + info: { + root: "border border-blue-light-3/80 bg-gradient-to-br from-blue-light-5 via-blue-light-5 to-blue-light-4 text-blue-dark shadow-sm shadow-blue/10 ring-1 ring-inset ring-white/70", + dot: "bg-blue shadow-[0_0_8px_rgba(60,80,224,0.45)]", + dotRing: "ring-2 ring-blue/15", + }, + urgent: { + root: "border border-error-light-3/90 bg-gradient-to-br from-error-light-6 via-error-light-6 to-error-light-5 text-error-dark shadow-sm shadow-error/10 ring-1 ring-inset ring-white/70", + dot: "bg-error shadow-[0_0_8px_rgba(242,48,48,0.45)]", + dotRing: "ring-2 ring-error/20", + }, + featured: { + root: "bg-gradient-to-br from-primary via-[#6B63F3] to-primary text-white shadow-[0_2px_12px_-2px_rgba(87,80,241,0.55)] ring-1 ring-inset ring-white/30", + dot: "bg-white shadow-[0_0_12px_rgba(255,255,255,0.9)]", + dotRing: "ring-1 ring-white/40", + }, +}; + const Status = ({ status = "draft", children }: IProps) => { - const reds = [ - "cancelled", - "inactive", - "rejected", - "failed", - "denied", - "blocked", - ]; - const greens = [ - "active", - "approved", - "completed", - "success", - "verified", - "published", - "sent", - ]; - const blues = ["awarded", "processing", "in-review", "scheduled", "reviewed"]; - const yellows = ["pending", "warning", "on-hold", "delayed"]; - const oranges = ["expired", "expiring", "limited"]; - const grays = ["draft", "disabled", "archived", "paused", "suspended"]; + const tone = resolveTone(status); + const { root, dot, dotRing } = toneStyles[tone]; return ( - {children} + + {children} ); }; diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx index 6f511fd..544a0dd 100644 --- a/src/components/ui/pagination.tsx +++ b/src/components/ui/pagination.tsx @@ -5,6 +5,11 @@ interface IProps { totalPages: number; onPageChange: ReactPaginateProps["onPageChange"]; } + +/** Full-cell hit target: styles live on
  • , but react-paginate puts clicks on the inner . */ +const pageLinkClassName = + "absolute inset-0 z-[1] flex cursor-pointer items-center justify-center select-none"; + const Pagination: FC = ({ totalPages, currentPage, onPageChange }) => { return ( @@ -21,9 +26,12 @@ const Pagination: FC = ({ totalPages, currentPage, onPageChange }) => { onPageChange(e); }} className="mt-2 flex w-full items-center justify-center gap-2 text-sm text-gray-dark dark:text-white" - nextClassName="flex h-9 min-w-9 items-center justify-center rounded-xl border border-stroke/80 bg-white/70 px-2 text-sm font-semibold text-dark shadow-theme-xs backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/35 hover:text-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-white dark:hover:bg-white/[0.1]" - previousClassName="flex h-9 min-w-9 items-center justify-center rounded-xl border border-stroke/80 bg-white/70 px-2 text-sm font-semibold text-dark shadow-theme-xs backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/35 hover:text-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-white dark:hover:bg-white/[0.1]" + nextClassName="relative flex h-9 min-w-9 items-center justify-center rounded-xl border border-stroke/80 bg-white/70 px-2 text-sm font-semibold text-dark shadow-theme-xs backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/35 hover:text-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-white dark:hover:bg-white/[0.1]" + previousClassName="relative flex h-9 min-w-9 items-center justify-center rounded-xl border border-stroke/80 bg-white/70 px-2 text-sm font-semibold text-dark shadow-theme-xs backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/35 hover:text-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-white dark:hover:bg-white/[0.1]" pageClassName="relative flex h-9 min-w-9 items-center justify-center rounded-xl border border-stroke/80 bg-white/70 px-3 text-sm font-semibold text-dark shadow-theme-xs backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/35 hover:text-primary dark:border-dark-3 dark:bg-dark-2/70 dark:text-white dark:hover:bg-white/[0.1]" + pageLinkClassName={pageLinkClassName} + previousLinkClassName={pageLinkClassName} + nextLinkClassName={pageLinkClassName} /> );