Files
tm_panel/src/app/tenders/[details]/page.tsx
T
AmirReza Jamali f48cb7e249 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.
2026-05-05 15:20:55 +03:30

350 lines
15 KiB
TypeScript

"use client";
import { LocationIcon } from "@/assets/icons";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import Loading from "@/components/loading";
import Status from "@/components/ui/Status";
import { useTenderDetailQuery } from "@/hooks/queries";
import { useGetFlagQuery } from "@/hooks/queries/useFlagsQueries";
import { cn } from "@/lib/utils";
import {
isValidAlpha2CountryCode,
truncateString,
unixToDate,
} from "@/utils/shared";
import getSymbolFromCurrency from "currency-symbol-map";
import Link from "next/link";
import { use } from "react";
interface IProps {
params: Promise<{
details: string;
}>;
}
const isValidDisplayCountryCode = (code: string) => /^[A-Za-z]{2,3}$/.test(code);
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>
);
}
/** Body area only: center content in the card; ShowcaseSection `title` is unchanged */
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 TenderDetails = ({ params }: IProps) => {
const { details } = use(params);
const { data, isLoading, isError } = useTenderDetailQuery(details);
const countryCodeNormalized =
data?.data.country_code?.trim().toUpperCase() ?? "";
const hasValidCountryCodeForDisplay =
isValidDisplayCountryCode(countryCodeNormalized);
const canLoadCountryFlag = isValidAlpha2CountryCode(countryCodeNormalized);
const { data: flagData } = useGetFlagQuery(countryCodeNormalized, {
enabled: canLoadCountryFlag,
});
const breadcrumbItems = [
{ name: "Dashboard", href: "/" },
{ name: "Tenders", href: "/tenders" },
{ name: "Tender details", href: `/tenders/${details}` },
];
if (isLoading) return <Loading />;
if (isError) {
return (
<>
<Breadcrumb items={breadcrumbItems} />
<ShowcaseSection className="!p-8 sm:!p-10">
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<p className="text-lg font-semibold text-dark dark:text-white">
We couldn&apos;t load this tender
</p>
<p className="max-w-sm text-sm text-dark-5 dark:text-dark-6">
Check your connection or try again. You can go back to the tender
list anytime.
</p>
<Link
href="/tenders"
className="shadow-theme-xs mt-2 inline-flex items-center justify-center rounded-xl bg-primary px-5 py-2.5 text-sm font-medium text-white transition hover:bg-primary/90"
>
Back to tenders
</Link>
</div>
</ShowcaseSection>
</>
);
}
const tender = data!.data;
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 (
<>
<Breadcrumb items={breadcrumbItems} />
<ShowcaseSection
title={
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
Tender
</p>
<h1 className="text-balance text-xl font-bold leading-tight text-dark dark:text-white sm:text-2xl lg:text-[1.65rem]">
{tender.title}
</h1>
</div>
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
<Status status={tender.status ?? "Unknown"}>
{tender.status}
</Status>
{submissionUrl && (
<Link
href={submissionUrl}
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>Open submission</span>
<ExternalLinkIcon className="opacity-90" />
</Link>
)}
</div>
</div>
}
className="!p-0"
>
<div className="space-y-10 px-4 pb-8 pt-2 sm:px-6 sm:pb-10 xl:px-10">
<div>
<SectionHeading>Procurement</SectionHeading>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
<ShowcaseSection title="Tender ID" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{tender.tender_id}
</span>
</ShowcaseSection>
<ShowcaseSection
title="Buyer organization"
rootClassName={SHOWCASE_CENTERED_BODY.rootClassName}
className={cn(
"sm:col-span-2 xl:col-span-1 2xl:col-span-2",
SHOWCASE_CENTERED_BODY.className,
)}
>
<span className="font-medium text-dark dark:text-white">
{tender.buyer_organization.name}
</span>
</ShowcaseSection>
<ShowcaseSection
title="Notice publication ID"
{...SHOWCASE_CENTERED_BODY}
>
<span className="font-medium text-dark dark:text-white">
{tender.notice_publication_id}
</span>
</ShowcaseSection>
<ShowcaseSection
title="Procedure code"
{...SHOWCASE_CENTERED_BODY}
>
<span className="font-medium text-dark dark:text-white">
{tender.procedure_code}
</span>
</ShowcaseSection>
<ShowcaseSection
title="Procurement type"
{...SHOWCASE_CENTERED_BODY}
>
<span className="font-medium text-dark dark:text-white">
{tender.procurement_type_code}
</span>
</ShowcaseSection>
</div>
</div>
<div>
<SectionHeading>Timeline</SectionHeading>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<ShowcaseSection
title="Application deadline"
{...SHOWCASE_CENTERED_BODY}
>
<time className="font-medium tabular-nums text-dark dark:text-white">
{unixToDate({
unix: tender.application_deadline ?? 0,
hasTime: true,
})}
</time>
</ShowcaseSection>
<ShowcaseSection
title="Publication date"
{...SHOWCASE_CENTERED_BODY}
>
<time className="font-medium tabular-nums text-dark dark:text-white">
{unixToDate({
unix: tender.publication_date ?? 0,
hasTime: true,
})}
</time>
</ShowcaseSection>
<ShowcaseSection
title="Submission deadline"
{...SHOWCASE_CENTERED_BODY}
>
<time className="font-medium tabular-nums text-dark dark:text-white">
{unixToDate({
unix: tender.submission_deadline ?? 0,
hasTime: true,
})}
</time>
</ShowcaseSection>
<ShowcaseSection
title="Tender deadline"
{...SHOWCASE_CENTERED_BODY}
>
<time className="font-medium tabular-nums text-dark dark:text-white">
{unixToDate({
unix: tender.tender_deadline ?? 0,
hasTime: true,
})}
</time>
</ShowcaseSection>
{submissionUrl && (
<ShowcaseSection
title="Submission URL"
{...SHOWCASE_CENTERED_BODY}
>
<Link
href={submissionUrl}
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" />
{truncateString(submissionUrl, 42)}
</Link>
</ShowcaseSection>
)}
</div>
</div>
{hasValidLocationCurrencyOrValue && (
<div>
<SectionHeading>Location & currency</SectionHeading>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:max-w-5xl">
{hasValidCountryCodeForDisplay && (
<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="pointer-events-none absolute -right-8 top-0 h-24 w-24 rounded-full bg-primary/15 blur-2xl dark:bg-primary/25" />
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
Country
</h3>
<div className="flex items-center gap-3">
<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">
<LocationIcon />
</span>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold uppercase tracking-wide text-dark dark:text-white">
{countryCodeNormalized}
</span>
{flagData && (
<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>
)}
{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-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-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">
Currency
</h3>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold tabular-nums text-dark dark:text-white">
{currencyCode}
</span>
<span className="text-lg font-medium text-dark-5 dark:text-dark-6">
{getSymbolFromCurrency(currencyCode || "USD")}
</span>
</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>
<SectionHeading>Description</SectionHeading>
<div className="rounded-2xl border border-stroke/60 bg-gray-2/40 p-6 shadow-inner dark:border-dark-3 dark:bg-dark-3/30">
<p className="whitespace-pre-wrap text-sm leading-relaxed text-dark dark:text-white/90">
{tender.description || "—"}
</p>
</div>
</div>
</div>
</ShowcaseSection>
</>
);
};
export default TenderDetails;