chore: update app version and enhance UI components

- Bumped NEXT_PUBLIC_APP_VERSION to 2.0.3 for the latest release.
- Improved Cypress test for customer feedback page to handle delayed route transitions.
- Refactored CompanyDetailsPage to enhance layout and added new components for better structure.
- Updated TenderDetails to conditionally render location and currency information based on validity checks.
- Added data-cy attributes to various form elements and tables for improved testing capabilities.
This commit is contained in:
AmirReza Jamali
2026-05-05 15:20:55 +03:30
parent 4b425d6a46
commit f48cb7e249
16 changed files with 713 additions and 113 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
NEXT_PUBLIC_APP_VERSION=2.0.2 NEXT_PUBLIC_APP_VERSION=2.0.3
NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500 NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0 NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
@@ -0,0 +1,149 @@
/// <reference types="cypress" />
import { makeCompanyCategory } from "../../support/company-category-fixtures";
import {
categoryMutationSuccessBody,
companyCategoriesListUrl,
companyCategoryCreateUrl,
companyCategoryEditUrl,
} from "../../support/company-category-helpers";
import { stubCompanyCategoriesReads } from "../../support/company-category-intercepts";
describe("company categories create and edit forms", () => {
beforeEach(() => {
cy.setAuthCookies();
});
it("create: shows required error when name is empty", () => {
stubCompanyCategoriesReads(() => []);
cy.visit(companyCategoryCreateUrl());
cy.getByCy("company-category-form").should("be.visible");
cy.contains("h2", "Create Company Category").should("be.visible");
cy.getByCy("admin-form-submit-button").click();
cy.contains("This field is required").should("be.visible");
});
it("create: shows min length for one-character name", () => {
stubCompanyCategoriesReads(() => []);
cy.visit(companyCategoryCreateUrl());
cy.getByCy("company-category-form-name-input").type("A");
cy.getByCy("admin-form-submit-button").click();
cy.contains("This field must be at least 2 characters long").should("be.visible");
});
it("create: shows min length when description is too short", () => {
stubCompanyCategoriesReads(() => []);
cy.visit(companyCategoryCreateUrl());
cy.getByCy("company-category-form-name-input").type("Valid Name");
cy.getByCy("company-category-form-description-input").type("short");
cy.getByCy("admin-form-submit-button").click();
cy.contains("This field must be at least 10 characters long").should("be.visible");
});
it("create: submits and redirects to list with success toast", () => {
let categories: ReturnType<typeof makeCompanyCategory>[] = [];
stubCompanyCategoriesReads(() => categories);
cy.intercept("POST", "**/api/proxy/company-categories", (req) => {
categories = [
makeCompanyCategory(
{
id: "507f1f77bcf86cd799439099",
name: "New Category",
description: "A description with enough characters.",
},
99,
),
];
req.reply({
statusCode: 201,
body: categoryMutationSuccessBody("Category created"),
});
}).as("createCategory");
cy.visit(companyCategoryCreateUrl());
cy.getByCy("company-category-form-name-input").type("New Category");
cy.getByCy("company-category-form-description-input").type(
"A description with enough characters.",
);
cy.getByCy("admin-form-submit-button").click();
cy.wait("@createCategory");
// Next.js navigation can be slightly delayed in CI/headless runs.
cy.location("pathname", { timeout: 15000 }).should("eq", "/company-categories");
cy.contains(/success|created|Category created/i).should("be.visible");
cy.contains("New Category").should("be.visible");
});
it("create: disables submit while request is in flight", () => {
stubCompanyCategoriesReads(() => []);
cy.intercept("POST", "**/api/proxy/company-categories", (req) => {
req.reply({
delay: 1400,
statusCode: 201,
body: categoryMutationSuccessBody("Category created"),
});
}).as("slowCreate");
cy.visit(companyCategoryCreateUrl());
cy.getByCy("company-category-form-name-input").type("Slow Category");
cy.getByCy("company-category-form-description-input").type(
"Description long enough for validation rules here.",
);
cy.getByCy("admin-form-submit-button").click();
cy.getByCy("admin-form-submit-button").should("be.disabled");
cy.getByCy("admin-form-submit-button").find("div.animate-spin").should("exist");
cy.wait("@slowCreate");
cy.get("@slowCreate.all").should("have.length", 1);
});
it("edit: prefills fields and updates then returns to list", () => {
const cat = makeCompanyCategory({ name: "Before Name", id: "507f1f77bcf86cd799439055" }, 5);
let categories = [cat];
stubCompanyCategoriesReads(() => categories);
cy.intercept("PUT", `**/api/proxy/company-categories/${cat.id}`, (req) => {
categories = [
makeCompanyCategory(
{
...cat,
name: "After Name",
},
5,
),
];
req.reply({
statusCode: 200,
body: categoryMutationSuccessBody("Category updated"),
});
}).as("updateCategory");
cy.visit(companyCategoryEditUrl(cat.id));
cy.contains("h2", "Edit Company Category").should("be.visible");
cy.getByCy("company-category-form-name-input").should("have.value", "Before Name");
cy.getByCy("company-category-form-name-input").clear().type("After Name");
cy.getByCy("admin-form-submit-button").click();
cy.wait("@updateCategory");
cy.location("pathname").should("eq", "/company-categories");
cy.contains(/success|updated|Category updated/i).should("be.visible");
cy.contains("After Name").should("be.visible");
});
it("edit: cancel uses back navigation", () => {
const cat = makeCompanyCategory({}, 1);
stubCompanyCategoriesReads(() => [cat]);
cy.visit(companyCategoriesListUrl());
cy.getByCy(`company-categories-row-edit-${cat.id}`).click();
cy.location("pathname").should("eq", `/company-categories/edit/${cat.id}`);
cy.getByCy("admin-form-cancel-button").click();
cy.location("pathname").should("eq", "/company-categories");
});
it("create: cancel navigates back when opened from list", () => {
stubCompanyCategoriesReads(() => [makeCompanyCategory({}, 1)]);
cy.visit(companyCategoriesListUrl());
cy.getByCy("company-categories-create-button").click();
cy.location("pathname").should("eq", "/company-categories/create");
cy.getByCy("admin-form-cancel-button").click();
cy.location("pathname").should("eq", "/company-categories");
});
});
@@ -0,0 +1,156 @@
/// <reference types="cypress" />
import { makeCompanyCategory } from "../../support/company-category-fixtures";
import { companyCategoriesListUrl } from "../../support/company-category-helpers";
import { stubCompanyCategoriesReads } from "../../support/company-category-intercepts";
describe("company categories list", () => {
beforeEach(() => {
cy.setAuthCookies();
});
it("toggles published optimistically, then keeps state after PATCH succeeds", () => {
const cat = makeCompanyCategory({ published: false }, 1);
let categories: ReturnType<typeof makeCompanyCategory>[] = [cat];
stubCompanyCategoriesReads(() => categories);
cy.intercept("PATCH", "**/api/proxy/company-categories/**/publish", (req) => {
const m = req.url.match(/company-categories\/([^/]+)\/publish/);
const id = m?.[1];
if (id) {
categories = categories.map((c) =>
c.id === id ? { ...c, published: !c.published } : c,
);
}
req.reply({
statusCode: 200,
body: { success: true, message: "Publish status updated", data: {} },
});
}).as("togglePublished");
cy.visit(companyCategoriesListUrl());
const toggle = () => cy.getByCy("company-categories-published-toggle-" + cat.id);
toggle().should("not.be.checked");
toggle().click({ force: true });
// Optimistic update (useOptimistic) before the network call finishes.
toggle().should("be.checked");
cy.wait("@togglePublished");
toggle().should("be.checked");
});
it("reverts published toggle when PATCH fails", () => {
const cat = makeCompanyCategory({ published: false }, 1);
stubCompanyCategoriesReads(() => [cat]);
cy.intercept("PATCH", "**/api/proxy/company-categories/**/publish", {
statusCode: 500,
body: { success: false, message: "Server error" },
delay: 200,
}).as("togglePublishedFail");
cy.visit(companyCategoriesListUrl());
const toggle = () => cy.getByCy("company-categories-published-toggle-" + cat.id);
toggle().should("not.be.checked");
toggle().click({ force: true });
toggle().should("be.checked");
cy.wait("@togglePublishedFail");
toggle().should("not.be.checked");
});
it("opens filter modal, searches by q, and updates the list", () => {
const alpha = makeCompanyCategory({ name: "Alpha Row", id: "507f1f77bcf86cd799439011" }, 1);
const beta = makeCompanyCategory({ name: "Beta Row", id: "507f1f77bcf86cd799439012" }, 2);
let categories = [alpha, beta];
stubCompanyCategoriesReads(() => categories);
cy.visit(companyCategoriesListUrl());
cy.contains("Alpha Row").should("be.visible");
cy.getByCy("company-categories-filter-button").click();
cy.getByCy("company-categories-filter-form").should("be.visible");
cy.getByCy("company-categories-filter-search-input").clear().type("Beta");
cy.then(() => { categories = [beta]; });
cy.getByCy("company-categories-filter-submit-button").click();
cy.location("search").should("include", "q=Beta");
cy.contains("Beta Row").should("be.visible");
cy.contains("Alpha Row").should("not.exist");
});
it("filters by published select and clears published filter", () => {
const pub = makeCompanyCategory({ name: "Pub Cat", published: true }, 1);
const draft = makeCompanyCategory(
{ name: "Draft Cat", published: false, id: "507f1f77bcf86cd799439013" },
2,
);
let categories = [pub, draft];
stubCompanyCategoriesReads(() => categories);
cy.visit(companyCategoriesListUrl());
cy.getByCy("company-categories-filter-button").click();
cy.getByCy("company-categories-filter-published-select").select("false");
cy.then(() => { categories = [draft]; });
cy.getByCy("company-categories-filter-submit-button").click();
cy.location("search").should("include", "published=false");
cy.contains("Draft Cat").should("be.visible");
cy.contains("Pub Cat").should("not.exist");
cy.getByCy("company-categories-filter-button").click();
cy.getByCy("company-categories-filter-published-select-clear").click();
cy.then(() => { categories = [pub, draft]; });
cy.getByCy("company-categories-filter-submit-button").click();
cy.location("search").should("not.include", "published=false");
cy.contains("Pub Cat").should("be.visible");
});
it("closes filter modal without applying", () => {
const cat = makeCompanyCategory({}, 1);
stubCompanyCategoriesReads(() => [cat]);
cy.visit(companyCategoriesListUrl());
cy.getByCy("company-categories-filter-button").click();
cy.getByCy("company-categories-filter-form").should("be.visible");
cy.getByCy("company-categories-filter-close-button").click();
cy.getByCy("company-categories-filter-form").should("not.exist");
});
it("navigates to create from header button", () => {
stubCompanyCategoriesReads(() => [makeCompanyCategory({}, 1)]);
cy.visit(companyCategoriesListUrl());
cy.getByCy("company-categories-create-button").click();
cy.location("pathname").should("eq", "/company-categories/create");
cy.getByCy("company-category-form").should("be.visible");
cy.contains("h2", "Create Company Category").should("be.visible");
});
it("opens edit route for a row", () => {
const cat = makeCompanyCategory({}, 1);
stubCompanyCategoriesReads(() => [cat]);
cy.visit(companyCategoriesListUrl());
cy.getByCy(`company-categories-row-edit-${cat.id}`).click();
cy.location("pathname").should("eq", `/company-categories/edit/${cat.id}`);
cy.contains("h2", "Edit Company Category").should("be.visible");
});
it("deletes after confirm and shows empty state", () => {
const cat = makeCompanyCategory({}, 1);
let categories = [cat];
stubCompanyCategoriesReads(() => categories);
cy.intercept("DELETE", `**/api/proxy/company-categories/${cat.id}`, {
statusCode: 200,
body: { success: true, message: "Deleted", data: {} },
}).as("deleteCategory");
cy.visit(companyCategoriesListUrl());
cy.getByCy(`company-categories-row-delete-${cat.id}`).click();
cy.getByCy("confirmation-modal").should("be.visible");
cy.getByCy("confirmation-modal-cancel").click();
cy.getByCy("confirmation-modal").should("not.exist");
cy.getByCy(`company-categories-row-delete-${cat.id}`).click();
cy.then(() => { categories = []; });
cy.getByCy("confirmation-modal-confirm").click();
cy.wait("@deleteCategory");
cy.getByCy("empty-list-message").should("contain.text", "No categories were found");
});
});
@@ -366,7 +366,11 @@ describe("customers management - list, actions, filters, feedback", () => {
cy.visit("/customers"); cy.visit("/customers");
cy.wait("@getCustomers"); cy.wait("@getCustomers");
cy.get('button[data-tooltip-id="feedback"]').first().click(); cy.get('button[data-tooltip-id="feedback"]').first().click();
cy.location("pathname").should("eq", `/customers/feedback/${customer.id}`); // Route transition can be delayed in headless runs.
cy.location("pathname", { timeout: 15000 }).should(
"eq",
`/customers/feedback/${customer.id}`,
);
cy.wait("@getCustomerFeedback"); cy.wait("@getCustomerFeedback");
cy.contains("feedback type").should("be.visible"); cy.contains("feedback type").should("be.visible");
}); });
@@ -0,0 +1,45 @@
export type CompanyCategoryRow = {
id: string;
name: string;
description: string;
published: boolean;
created_at: number;
updated_at: number;
published_at: number;
thumbnail: string;
};
export const makeCompanyCategory = (
overrides: Partial<CompanyCategoryRow> = {},
index = 1,
): CompanyCategoryRow => ({
id: `507f1f77bcf86cd7994390${(10 + index).toString().padStart(2, "0")}`,
name: `E2E Category ${index}`,
description: "This is a sufficiently long description for category rows and forms.",
published: false,
created_at: 1_700_000_000 + index,
updated_at: 1_700_000_000 + index,
published_at: 0,
thumbnail: "",
...overrides,
});
export const categoriesListResponse = (categories: CompanyCategoryRow[]) => ({
success: true,
message: "ok",
data: { categories },
});
export const categoryDetailsResponse = (data: {
name: string;
description: string;
thumbnail?: string;
}) => ({
success: true,
message: "ok",
data: {
name: data.name,
description: data.description,
thumbnail: data.thumbnail ?? "",
},
});
@@ -0,0 +1,14 @@
/** Matches `useCreateCompanyCategory` / `useUpdateCompanyCategories` onSuccess (axios `response.data` shape). */
export const categoryMutationSuccessBody = (message: string) => ({
success: true,
data: { message },
});
export const companyCategoriesListUrl = (path = "/company-categories") =>
`${path}?_cy=${Date.now()}`;
export const companyCategoryCreateUrl = () =>
`/company-categories/create?_cy=${Date.now()}`;
export const companyCategoryEditUrl = (id: string) =>
`/company-categories/edit/${id}?_cy=${Date.now()}`;
@@ -0,0 +1,53 @@
import {
categoriesListResponse,
categoryDetailsResponse,
type CompanyCategoryRow,
} from "./company-category-fixtures";
/** Subset of Cypress `cy.intercept` request used by this stub (avoids non-exported / deep Cypress types). */
type InterceptRequest = {
url: string;
reply(staticResponse: { statusCode: number; body: Record<string, unknown> }): void;
continue(): void;
};
const CATEGORY_ID_IN_URL = /\/company-categories\/([a-f0-9]{24})(?:\?|\/|$)/i;
/**
* Stubs GET list and GET single category. Uses `req.url` string matching so all
* request shapes (query strings, rewritten hosts) are handled consistently.
*/
export function stubCompanyCategoriesReads(
getCategories: () => CompanyCategoryRow[],
): void {
const reply = (req: InterceptRequest) => {
const raw = req.url;
const detailMatch = raw.match(CATEGORY_ID_IN_URL);
if (detailMatch) {
const id = detailMatch[1];
const cat = getCategories().find((c) => c.id === id);
req.reply({
statusCode: 200,
body: categoryDetailsResponse({
name: cat?.name ?? "Category",
description: cat?.description ?? "Default description long enough.",
thumbnail: cat?.thumbnail ?? "",
}),
});
return;
}
if (raw.includes("/company-categories")) {
req.reply({
statusCode: 200,
body: categoriesListResponse(getCategories()),
});
return;
}
req.continue();
};
cy.intercept("GET", /\/api\/proxy\/company-categories/, reply);
cy.intercept("GET", /\/admin\/v1\/company-categories/, reply);
}
@@ -2,20 +2,56 @@
import Loading from "@/components/loading"; import Loading from "@/components/loading";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb"; import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import { ShowcaseSection } from "@/components/Layouts/showcase-section"; import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import IsVisible from "@/components/ui/IsVisible";
import Status from "@/components/ui/Status"; import Status from "@/components/ui/Status";
import { useCompanyDetails } from "@/hooks/queries"; import { useCompanyDetails } from "@/hooks/queries";
import { cn } from "@/lib/utils";
import { BreadcrumbItem } from "@/types/shared"; import { BreadcrumbItem } from "@/types/shared";
import { formatPhoneNumber, unixToDate } from "@/utils/shared"; import { formatPhoneNumber, unixToDate } from "@/utils/shared";
import getSymbolFromCurrency from "currency-symbol-map"; import getSymbolFromCurrency from "currency-symbol-map";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { use } from "react"; import { use } from "react";
interface IProps { interface IProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
} }
function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<div className="mb-4 flex items-center gap-3">
<span className="h-px flex-1 bg-gradient-to-r from-stroke/0 via-stroke/80 to-stroke/0 dark:from-dark-3/0 dark:via-dark-3 dark:to-dark-3/0" />
<h2 className="shrink-0 text-[11px] font-semibold uppercase tracking-[0.2em] text-dark-5 dark:text-dark-6">
{children}
</h2>
<span className="h-px flex-1 bg-gradient-to-l from-stroke/0 via-stroke/80 to-stroke/0 dark:from-dark-3/0 dark:via-dark-3 dark:to-dark-3/0" />
</div>
);
}
function ExternalLinkIcon({ className }: { className?: string }) {
return (
<svg
className={cn("h-4 w-4 shrink-0", className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
);
}
const SHOWCASE_CENTERED_BODY = {
rootClassName: "flex h-full min-h-0 flex-col",
className:
"flex min-h-0 flex-1 flex-col items-center justify-center text-center",
} as const;
const CompanyDetailsPage = ({ params }: IProps) => { const CompanyDetailsPage = ({ params }: IProps) => {
const { id } = use(params); const { id } = use(params);
const breadcrumbItems: BreadcrumbItem[] = [ const breadcrumbItems: BreadcrumbItem[] = [
@@ -33,8 +69,9 @@ const CompanyDetailsPage = ({ params }: IProps) => {
}, },
]; ];
const { data, isLoading } = useCompanyDetails(id); const { data, isLoading } = useCompanyDetails(id);
const router = useRouter();
if (isLoading) return <Loading />; if (isLoading) return <Loading />;
const company = data.data;
const website = company.website?.trim();
return ( return (
<> <>
@@ -42,71 +79,142 @@ const CompanyDetailsPage = ({ params }: IProps) => {
<ShowcaseSection <ShowcaseSection
title={ title={
<div className="flex justify-between"> <div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<strong>{data.data.name}</strong> <div className="min-w-0 space-y-2">
<Status status={data?.data?.status}>{data.data.status}</Status> <p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
Company
</p>
<h1 className="text-balance text-xl font-bold leading-tight text-dark dark:text-white sm:text-2xl lg:text-[1.65rem]">
{company.name}
</h1>
</div>
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
<Status status={company.status}>{company.status}</Status>
{website && (
<Link
href={website}
target="_blank"
rel="noopener noreferrer"
className="shadow-theme-xs inline-flex items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:bg-primary/90 hover:shadow-md"
>
<span>Visit website</span>
<ExternalLinkIcon className="opacity-90" />
</Link>
)}
</div>
</div> </div>
} }
className="!p-0"
> >
<div className="grid grid-cols-4 gap-4"> <div className="space-y-10 px-4 pb-8 pt-2 sm:px-6 sm:pb-10 xl:px-10">
<ShowcaseSection title={"Currency"}> <div>
<strong> <SectionHeading>Profile</SectionHeading>
{`${data?.data?.currency} ${getSymbolFromCurrency(data?.data.currency ?? "USD")}`} <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
</strong> <ShowcaseSection title="Industry" {...SHOWCASE_CENTERED_BODY}>
</ShowcaseSection> <span className="font-medium text-dark dark:text-white">
<ShowcaseSection title={"Language"}> {company.industry || "—"}
<strong className="uppercase">{data?.data?.language}</strong> </span>
</ShowcaseSection> </ShowcaseSection>
<ShowcaseSection title={"Created At"}> <ShowcaseSection title="Type" {...SHOWCASE_CENTERED_BODY}>
<strong>{unixToDate({ unix: data?.data?.created_at })}</strong> <span className="font-medium text-dark dark:text-white">
</ShowcaseSection> {company.type || "—"}
<ShowcaseSection title={"Annual revenue"}> </span>
<strong>{data?.data?.annual_revenue}</strong> </ShowcaseSection>
</ShowcaseSection> <ShowcaseSection title="Language" {...SHOWCASE_CENTERED_BODY}>
<ShowcaseSection title={"Email"}> <span className="font-medium uppercase text-dark dark:text-white">
<strong>{data?.data?.email}</strong> {company.language || "—"}
</ShowcaseSection> </span>
<ShowcaseSection title={"Employee count"}> </ShowcaseSection>
<strong>{data?.data?.employee_count}</strong> <ShowcaseSection title="Founded year" {...SHOWCASE_CENTERED_BODY}>
</ShowcaseSection> <span className="font-medium text-dark dark:text-white">
<ShowcaseSection title={"Founded Year"}> {company.founded_year || "—"}
<strong>{data?.data?.founded_year}</strong> </span>
</ShowcaseSection> </ShowcaseSection>
<ShowcaseSection title={"Industry"}> </div>
<strong>{data?.data?.industry}</strong> </div>
</ShowcaseSection>
<ShowcaseSection title={"Is Compliant"}> <div>
<strong>{data?.data?.is_compliant ? "Yes" : "No"}</strong> <SectionHeading>Business</SectionHeading>
</ShowcaseSection> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<ShowcaseSection title={"Is Verified"}> <ShowcaseSection title="Currency" {...SHOWCASE_CENTERED_BODY}>
<strong>{data?.data?.is_verified ? "Yes" : "No"}</strong> <span className="font-medium text-dark dark:text-white">
</ShowcaseSection> {`${company.currency || "—"} ${getSymbolFromCurrency(company.currency ?? "USD")}`}
<ShowcaseSection title={"Phone"}> </span>
<strong>{formatPhoneNumber(data?.data?.phone ?? 0)}</strong> </ShowcaseSection>
</ShowcaseSection> <ShowcaseSection title="Annual revenue" {...SHOWCASE_CENTERED_BODY}>
<ShowcaseSection title={"Registration Number"}> <span className="font-medium text-dark dark:text-white">
<strong>{data?.data?.registration_number}</strong> {company.annual_revenue || "—"}
</ShowcaseSection> </span>
<ShowcaseSection title={"Tax id"}> </ShowcaseSection>
<strong>{data?.data?.tax_id}</strong> <ShowcaseSection title="Employee count" {...SHOWCASE_CENTERED_BODY}>
</ShowcaseSection> <span className="font-medium text-dark dark:text-white">
<ShowcaseSection title={"Type"}> {company.employee_count || "—"}
<strong>{data?.data?.type}</strong> </span>
</ShowcaseSection> </ShowcaseSection>
<ShowcaseSection title={"Updated at"}> <ShowcaseSection
<strong>{unixToDate({ unix: data?.data?.updated_at ?? 0 })}</strong> title="Registration number"
</ShowcaseSection> {...SHOWCASE_CENTERED_BODY}
<IsVisible condition={!!data.data.website}>
<ShowcaseSection title={"Website"}>
<Link
href={data.data.website}
className="font-bold"
target="_blank"
> >
{data.data.website} <span className="font-medium text-dark dark:text-white">
</Link> {company.registration_number || "—"}
</ShowcaseSection> </span>
</IsVisible> </ShowcaseSection>
<ShowcaseSection title="Tax ID" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{company.tax_id || "—"}
</span>
</ShowcaseSection>
<ShowcaseSection title="Compliant" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{company.is_compliant ? "Yes" : "No"}
</span>
</ShowcaseSection>
<ShowcaseSection title="Verified" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{company.is_verified ? "Yes" : "No"}
</span>
</ShowcaseSection>
</div>
</div>
<div>
<SectionHeading>Contact & timeline</SectionHeading>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<ShowcaseSection title="Email" {...SHOWCASE_CENTERED_BODY}>
<span className="break-all font-medium text-dark dark:text-white">
{company.email || "—"}
</span>
</ShowcaseSection>
<ShowcaseSection title="Phone" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{formatPhoneNumber(company.phone ?? 0)}
</span>
</ShowcaseSection>
<ShowcaseSection title="Created at" {...SHOWCASE_CENTERED_BODY}>
<time className="font-medium tabular-nums text-dark dark:text-white">
{unixToDate({ unix: company.created_at })}
</time>
</ShowcaseSection>
<ShowcaseSection title="Updated at" {...SHOWCASE_CENTERED_BODY}>
<time className="font-medium tabular-nums text-dark dark:text-white">
{unixToDate({ unix: company.updated_at ?? 0 })}
</time>
</ShowcaseSection>
{website && (
<ShowcaseSection title="Website" {...SHOWCASE_CENTERED_BODY}>
<Link
href={website}
target="_blank"
rel="noopener noreferrer"
className="inline-flex max-w-full items-center gap-1.5 break-all font-medium text-primary underline-offset-4 hover:underline"
>
<ExternalLinkIcon className="shrink-0" />
{website}
</Link>
</ShowcaseSection>
)}
</div>
</div>
</div> </div>
</ShowcaseSection> </ShowcaseSection>
</> </>
+77 -42
View File
@@ -22,6 +22,8 @@ interface IProps {
}>; }>;
} }
const isValidDisplayCountryCode = (code: string) => /^[A-Za-z]{2,3}$/.test(code);
function SectionHeading({ children }: { children: React.ReactNode }) { function SectionHeading({ children }: { children: React.ReactNode }) {
return ( return (
<div className="mb-4 flex items-center gap-3"> <div className="mb-4 flex items-center gap-3">
@@ -65,9 +67,11 @@ const TenderDetails = ({ params }: IProps) => {
const { data, isLoading, isError } = useTenderDetailQuery(details); const { data, isLoading, isError } = useTenderDetailQuery(details);
const countryCodeNormalized = const countryCodeNormalized =
data?.data.country_code?.trim().toUpperCase() ?? ""; data?.data.country_code?.trim().toUpperCase() ?? "";
const countryCodeValid = isValidAlpha2CountryCode(countryCodeNormalized); const hasValidCountryCodeForDisplay =
isValidDisplayCountryCode(countryCodeNormalized);
const canLoadCountryFlag = isValidAlpha2CountryCode(countryCodeNormalized);
const { data: flagData } = useGetFlagQuery(countryCodeNormalized, { const { data: flagData } = useGetFlagQuery(countryCodeNormalized, {
enabled: countryCodeValid, enabled: canLoadCountryFlag,
}); });
const breadcrumbItems = [ const breadcrumbItems = [
{ name: "Dashboard", href: "/" }, { name: "Dashboard", href: "/" },
@@ -103,6 +107,14 @@ const TenderDetails = ({ params }: IProps) => {
const tender = data!.data; const tender = data!.data;
const submissionUrl = tender.submission_url?.trim(); const submissionUrl = tender.submission_url?.trim();
const currencyCode = tender.currency?.trim().toUpperCase() ?? "";
const hasValidCurrency = Boolean(currencyCode);
const hasValidEstimatedValue =
typeof tender.estimated_value === "number" &&
Number.isFinite(tender.estimated_value) &&
tender.estimated_value > 0;
const hasValidLocationCurrencyOrValue =
hasValidCountryCodeForDisplay || hasValidCurrency || hasValidEstimatedValue;
return ( return (
<> <>
@@ -252,50 +264,73 @@ const TenderDetails = ({ params }: IProps) => {
</div> </div>
</div> </div>
<div> {hasValidLocationCurrencyOrValue && (
<SectionHeading>Location & currency</SectionHeading> <div>
<div className="grid gap-4 sm:grid-cols-2 lg:max-w-3xl"> <SectionHeading>Location & currency</SectionHeading>
<div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-gray-2/30 to-primary/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-primary/[0.12]"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:max-w-5xl">
<div className="pointer-events-none absolute -right-8 top-0 h-24 w-24 rounded-full bg-primary/15 blur-2xl dark:bg-primary/25" /> {hasValidCountryCodeForDisplay && (
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6"> <div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-gray-2/30 to-primary/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-primary/[0.12]">
Country <div className="pointer-events-none absolute -right-8 top-0 h-24 w-24 rounded-full bg-primary/15 blur-2xl dark:bg-primary/25" />
</h3> <h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
<div className="flex items-center gap-3"> Country
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/80 text-primary shadow-inner dark:bg-dark-2/80"> </h3>
<LocationIcon /> <div className="flex items-center gap-3">
</span> <span className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/80 text-primary shadow-inner dark:bg-dark-2/80">
<div className="flex flex-wrap items-center gap-2"> <LocationIcon />
<span className="text-sm font-semibold uppercase tracking-wide text-dark dark:text-white"> </span>
{tender.country_code} <div className="flex flex-wrap items-center gap-2">
</span> <span className="text-sm font-semibold uppercase tracking-wide text-dark dark:text-white">
{flagData && ( {countryCodeNormalized}
<div </span>
dangerouslySetInnerHTML={{ __html: flagData }} {flagData && (
className="flex h-8 w-11 items-center justify-center overflow-hidden rounded-lg border border-stroke/60 shadow-sm dark:border-dark-3" <div
/> dangerouslySetInnerHTML={{ __html: flagData }}
)} className="flex h-8 w-11 items-center justify-center overflow-hidden rounded-lg border border-stroke/60 shadow-sm dark:border-dark-3"
/>
)}
</div>
</div>
</div> </div>
</div> )}
</div>
{tender.currency && ( {hasValidCurrency && (
<div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-gray-2/30 to-purple-500/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-purple-500/[0.12]"> <div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-gray-2/30 to-blue/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-blue/[0.12]">
<div className="pointer-events-none absolute -left-6 bottom-0 h-20 w-20 rounded-full bg-purple-500/15 blur-2xl dark:bg-purple-500/25" /> <div className="pointer-events-none absolute -left-6 bottom-0 h-20 w-20 rounded-full bg-blue/15 blur-2xl dark:bg-blue/25" />
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6"> <h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
Currency Currency
</h3> </h3>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-2xl font-bold tabular-nums text-dark dark:text-white"> <span className="text-2xl font-bold tabular-nums text-dark dark:text-white">
{tender.currency} {currencyCode}
</span> </span>
<span className="text-lg font-medium text-dark-5 dark:text-dark-6"> <span className="text-lg font-medium text-dark-5 dark:text-dark-6">
{getSymbolFromCurrency(tender.currency ?? "USD")} {getSymbolFromCurrency(currencyCode || "USD")}
</span> </span>
</div>
</div> </div>
</div> )}
)}
{hasValidEstimatedValue && (
<div className="shadow-theme-xs relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-gray-2/30 to-green/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-green/[0.12]">
<div className="pointer-events-none absolute -right-6 bottom-0 h-20 w-20 rounded-full bg-green/15 blur-2xl dark:bg-green/25" />
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
Estimated value
</h3>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold tabular-nums text-dark dark:text-white">
{new Intl.NumberFormat().format(tender.estimated_value)}
</span>
{hasValidCurrency && (
<span className="text-sm font-medium uppercase text-dark-5 dark:text-dark-6">
{currencyCode}
</span>
)}
</div>
</div>
)}
</div>
</div> </div>
</div> )}
<div> <div>
<SectionHeading>Description</SectionHeading> <SectionHeading>Description</SectionHeading>
@@ -18,6 +18,7 @@ type PropsType<T extends FieldValues> = {
className?: string; className?: string;
icon?: React.ReactNode; icon?: React.ReactNode;
defaultValue?: string; defaultValue?: string;
dataCy?: string;
handleChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; handleChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
register?: UseFormRegister<T>; register?: UseFormRegister<T>;
errors?: FieldErrors<T>; errors?: FieldErrors<T>;
@@ -33,6 +34,7 @@ export function TextAreaGroup<T extends FieldValues>({
className, className,
icon, icon,
defaultValue, defaultValue,
dataCy,
handleChange, handleChange,
register, register,
errors, errors,
@@ -77,6 +79,7 @@ export function TextAreaGroup<T extends FieldValues>({
required={required} required={required}
disabled={disabled} disabled={disabled}
data-active={active} data-active={active}
data-cy={dataCy}
/> />
{icon} {icon}
+3
View File
@@ -8,6 +8,7 @@ type PropsType = {
backgroundSize?: "sm" | "default"; backgroundSize?: "sm" | "default";
name?: string; name?: string;
checked?: boolean; checked?: boolean;
dataCy?: string;
onToggle: (e: ToggleEvent<HTMLInputElement>) => void; onToggle: (e: ToggleEvent<HTMLInputElement>) => void;
onChange: (e: ChangeEvent<HTMLInputElement>) => void; onChange: (e: ChangeEvent<HTMLInputElement>) => void;
}; };
@@ -18,6 +19,7 @@ export function Switch({
backgroundSize, backgroundSize,
name, name,
checked = false, checked = false,
dataCy,
onToggle, onToggle,
onChange, onChange,
}: PropsType) { }: PropsType) {
@@ -37,6 +39,7 @@ export function Switch({
id={id} id={id}
className="peer sr-only" className="peer sr-only"
checked={checked} checked={checked}
data-cy={dataCy}
/> />
<div <div
className={cn("h-8 w-14 rounded-full bg-gray-3 dark:bg-[#5A616B]", { className={cn("h-8 w-14 rounded-full bg-gray-3 dark:bg-[#5A616B]", {
@@ -30,6 +30,7 @@ const CompanyCategoryListFilters = ({
return ( return (
<form <form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2" className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
data-cy="company-categories-filter-form"
onSubmit={handleFilterSubmit(search)} onSubmit={handleFilterSubmit(search)}
> >
<InputGroup <InputGroup
@@ -38,6 +39,7 @@ const CompanyCategoryListFilters = ({
label="search" label="search"
placeholder="Search" placeholder="Search"
type="search" type="search"
dataCy="company-categories-filter-search-input"
/> />
<Select <Select
{...filterRegister("published")} {...filterRegister("published")}
@@ -51,11 +53,13 @@ const CompanyCategoryListFilters = ({
clearable clearable
value={watch("published")} value={watch("published")}
onClear={() => setValue("published", undefined)} onClear={() => setValue("published", undefined)}
dataCy="company-categories-filter-published-select"
/> />
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
type="submit" type="submit"
data-cy="company-categories-filter-submit-button"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90" className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
> >
{isMutating ? ( {isMutating ? (
@@ -66,6 +70,7 @@ const CompanyCategoryListFilters = ({
</button> </button>
<button <button
type="button" type="button"
data-cy="company-categories-filter-close-button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90" className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)} onClick={() => setIsFilterModalOpen(false)}
> >
@@ -47,12 +47,13 @@ const CompanyCategoriesTable = () => {
} = useCompanyCategoriesPresenter(); } = useCompanyCategoriesPresenter();
return ( return (
<ListWrapper> <ListWrapper data-cy="company-categories-page">
<ListHeader <ListHeader
onFilter={() => setIsFilterModalOpen(true)} onFilter={() => setIsFilterModalOpen(true)}
createButtonText="Create Category" createButtonText="Create Category"
dataCyPrefix="company-categories"
/> />
<Table> <Table data-cy="company-categories-table">
<TableHeader> <TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start"> <TableRow className="border-none uppercase [&>th]:text-start">
<TableHead colSpan={100}>Row</TableHead> <TableHead colSpan={100}>Row</TableHead>
@@ -73,6 +74,7 @@ const CompanyCategoriesTable = () => {
{optimisticCategories.map((category, index) => ( {optimisticCategories.map((category, index) => (
<TableRow <TableRow
key={category.id} key={category.id}
data-cy={`company-categories-table-row-${category.id}`}
className="odd:bg-gray-2 dark:odd:bg-gray-7" className="odd:bg-gray-2 dark:odd:bg-gray-7"
> >
<TableCell colSpan={100}> <TableCell colSpan={100}>
@@ -84,8 +86,10 @@ const CompanyCategoriesTable = () => {
{unixToDate({ unix: category.created_at, hasTime: true })} {unixToDate({ unix: category.created_at, hasTime: true })}
</TableCell> </TableCell>
<TableCell colSpan={100}> <TableCell colSpan={100}>
{/* Published: flip UI immediately via useOptimistic; if togglePublished rejects, React rolls the optimistic row back. */}
<Switch <Switch
withIcon withIcon
dataCy={`company-categories-published-toggle-${category.id}`}
onToggle={() => {}} onToggle={() => {}}
onChange={() => { onChange={() => {
const nextPublished = !category.published; const nextPublished = !category.published;
@@ -94,7 +98,11 @@ const CompanyCategoriesTable = () => {
id: category.id, id: category.id,
published: nextPublished, published: nextPublished,
}); });
await togglePublished(category.id); try {
await togglePublished(category.id);
} catch {
// useOptimistic automatically reverts when the transition ends
}
}); });
}} }}
checked={category.published} checked={category.published}
@@ -103,6 +111,8 @@ const CompanyCategoriesTable = () => {
<TableCell className="text-start xl:pr-7.5"> <TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5"> <div className="flex items-center justify-start gap-x-3.5">
<button <button
type="button"
data-cy={`company-categories-row-delete-${category.id}`}
data-tooltip-id="delete" data-tooltip-id="delete"
data-tooltip-content="Delete" data-tooltip-content="Delete"
data-tooltip-place="top" data-tooltip-place="top"
@@ -117,6 +127,8 @@ const CompanyCategoriesTable = () => {
<TrashIcon /> <TrashIcon />
</button> </button>
<button <button
type="button"
data-cy={`company-categories-row-edit-${category.id}`}
data-tooltip-id="edit" data-tooltip-id="edit"
data-tooltip-content="Edit" data-tooltip-content="Edit"
data-tooltip-place="top" data-tooltip-place="top"
+6
View File
@@ -87,6 +87,12 @@ const TendersTable = () => {
<TableCell className="text-start" colSpan={100}> <TableCell className="text-start" colSpan={100}>
{item.country_code} {item.country_code}
</TableCell> </TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({
unix: item.publication_date,
hasTime: true,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}> <TableCell className="text-start" colSpan={100}>
{unixToDate({ {unixToDate({
unix: item.submission_deadline, unix: item.submission_deadline,
@@ -57,6 +57,7 @@ const useTenderListPresenter = () => {
"row", "row",
"title", "title",
"country code", "country code",
"publication date",
"submission deadline", "submission deadline",
"tender deadline", "tender deadline",
"created at", "created at",
@@ -17,15 +17,20 @@ const CreateCompanyCategoryForm = ({ defaultValues, editMode, id }: IProps) => {
const { errors, handleSubmit, onSubmit, register } = const { errors, handleSubmit, onSubmit, register } =
useCreateCompanyCategoryPresenter({ defaultValues, editMode, id }); useCreateCompanyCategoryPresenter({ defaultValues, editMode, id });
return ( return (
<form className="grid grid-cols-1 gap-9" onSubmit={handleSubmit(onSubmit)}> <form
className="grid grid-cols-1 gap-9"
data-cy="company-category-form"
onSubmit={handleSubmit(onSubmit)}
>
<ShowcaseSection <ShowcaseSection
title="Create Company Category" title={editMode ? "Edit Company Category" : "Create Company Category"}
className="grid grid-cols-1 gap-5 md:grid-cols-2" className="grid grid-cols-1 gap-5 md:grid-cols-2"
> >
<div className="flex flex-col gap-2 md:col-span-1"> <div className="flex flex-col gap-2 md:col-span-1">
<InputGroup <InputGroup
label="Name" label="Name"
required required
dataCy="company-category-form-name-input"
{...register("name", { {...register("name", {
required: { required: {
value: true, value: true,
@@ -51,6 +56,7 @@ const CreateCompanyCategoryForm = ({ defaultValues, editMode, id }: IProps) => {
<div className="flex flex-col gap-2 md:col-span-1"> <div className="flex flex-col gap-2 md:col-span-1">
<TextAreaGroup <TextAreaGroup
label="Description" label="Description"
dataCy="company-category-form-description-input"
{...register("description", { {...register("description", {
minLength: { minLength: {
value: 10, value: 10,