feat(tender-details): enhance tender details page with improved layout and error handling

- Added SectionHeading and ExternalLinkIcon components for better structure and visual appeal.
- Improved error handling UI when tender data fails to load, providing user-friendly messages and navigation options.
- Refactored ShowcaseSection to accept additional className and rootClassName props for more flexible styling.
- Updated TendersTable to streamline action buttons and improve layout consistency.
This commit is contained in:
AmirReza Jamali
2026-05-02 10:05:48 +03:30
parent 73d11ac72b
commit 37c4053a6c
4 changed files with 252 additions and 117 deletions
+190 -51
View File
@@ -6,7 +6,8 @@ import Loading from "@/components/loading";
import Status from "@/components/ui/Status";
import { useTenderDetailQuery } from "@/hooks/queries";
import { useGetFlagQuery } from "@/hooks/queries/useFlagsQueries";
import { isValidAlpha2CountryCode, msToDate } from "@/utils/shared";
import { cn } from "@/lib/utils";
import { isValidAlpha2CountryCode, msToDate, truncateString } from "@/utils/shared";
import getSymbolFromCurrency from "currency-symbol-map";
import Link from "next/link";
import { use } from "react";
@@ -17,6 +18,43 @@ interface IProps {
}>;
}
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>
);
}
/** Grid row stretch: center value vertically under the title */
const TIMELINE_SHOWCASE = {
rootClassName: "flex h-full min-h-0 flex-col",
className: "flex min-h-0 flex-1 flex-col justify-center",
} as const;
const TenderDetails = ({ params }: IProps) => {
const { details } = use(params);
const { data, isLoading, isError } = useTenderDetailQuery(details);
@@ -31,86 +69,187 @@ const TenderDetails = ({ params }: IProps) => {
{ name: "Tenders", href: "/tenders" },
{ name: "Tender details", href: `/tenders/${details}` },
];
if (isLoading) return <Loading />;
if (isError) return <div>Error</div>;
if (isError) {
return (
<>
<Breadcrumb items={breadcrumbItems} />
<ShowcaseSection className="!p-6.5">
<div className="flex justify-between border-b pb-4">
<p>{data?.data.title}</p>
<Status status={data?.data.status ?? "Unknown"}>
{data?.data.status}
</Status>
<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="mt-2 inline-flex items-center justify-center rounded-xl bg-primary px-5 py-2.5 text-sm font-medium text-white shadow-theme-xs transition hover:bg-primary/90"
>
Back to tenders
</Link>
</div>
<div className="mt-4 flex justify-between gap-2 border-b pb-4">
</ShowcaseSection>
</>
);
}
const tender = data!.data;
const submissionUrl = tender.submission_url?.trim();
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="inline-flex items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 text-sm font-medium text-white shadow-theme-xs 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">
{data?.data.tender_id}
<span className="font-medium text-dark dark:text-white">{tender.tender_id}</span>
</ShowcaseSection>
<ShowcaseSection title="Buyer Organization">
{data?.data.buyer_organization.name}
<ShowcaseSection title="Buyer organization" className="sm:col-span-2 xl:col-span-1 2xl:col-span-2">
<span className="font-medium text-dark dark:text-white">
{tender.buyer_organization.name}
</span>
</ShowcaseSection>
<ShowcaseSection title="Notice Publication Id">
{data?.data.notice_publication_id}
<ShowcaseSection title="Notice publication ID">
<span className="font-medium text-dark dark:text-white">
{tender.notice_publication_id}
</span>
</ShowcaseSection>
<ShowcaseSection title="Procedure Code">
{data?.data.procedure_code}
<ShowcaseSection title="Procedure code">
<span className="font-medium text-dark dark:text-white">{tender.procedure_code}</span>
</ShowcaseSection>
<ShowcaseSection title="Procurement Type Code">
{data?.data.procurement_type_code}
<ShowcaseSection title="Procurement type">
<span className="font-medium text-dark dark:text-white">
{tender.procurement_type_code}
</span>
</ShowcaseSection>
</div>
<div className="mt-4 flex justify-between gap-2 border-b pb-4">
<ShowcaseSection title="Application Deadline">
<span>{msToDate(data?.data.application_deadline ?? 0)}</span>
</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" {...TIMELINE_SHOWCASE}>
<time className="font-medium tabular-nums text-dark dark:text-white">
{msToDate(tender.application_deadline ?? 0)}
</time>
</ShowcaseSection>
{data?.data.submission_url && (
<ShowcaseSection title="Submission Url">
<Link href={data?.data.submission_url ?? ""} target="_blank">
{data?.data.submission_url}
<ShowcaseSection title="Publication date" {...TIMELINE_SHOWCASE}>
<time className="font-medium tabular-nums text-dark dark:text-white">
{msToDate(tender.publication_date ?? 0)}
</time>
</ShowcaseSection>
<ShowcaseSection title="Submission deadline" {...TIMELINE_SHOWCASE}>
<time className="font-medium tabular-nums text-dark dark:text-white">
{msToDate(tender.submission_deadline ?? 0)}
</time>
</ShowcaseSection>
<ShowcaseSection title="Tender deadline" {...TIMELINE_SHOWCASE}>
<time className="font-medium tabular-nums text-dark dark:text-white">
{msToDate(tender.tender_deadline ?? 0)}
</time>
</ShowcaseSection>
{submissionUrl && (
<ShowcaseSection title="Submission URL" {...TIMELINE_SHOWCASE}>
<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>
)}
<ShowcaseSection title="Publication Deadline">
<span>{msToDate(data?.data.publication_date ?? 0)}</span>
</ShowcaseSection>
<ShowcaseSection title="Submission Deadline">
<span>{msToDate(data?.data.submission_deadline ?? 0)}</span>
</ShowcaseSection>
<ShowcaseSection title="Tender Deadline">
<span>{msToDate(data?.data.tender_deadline ?? 0)}</span>
</ShowcaseSection>
</div>
<div className="mt-4 flex justify-between border-b pb-4">
</div>
<div>
<h2 className="mb-6 text-lg font-semibold">Country</h2>
<div className="flex items-center gap-1">
<SectionHeading>Location & currency</SectionHeading>
<div className="grid gap-4 sm:grid-cols-2 lg:max-w-3xl">
<div className="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 shadow-theme-xs 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 />
<p className="text-sm uppercase">{data?.data.country_code}</p>
</span>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold uppercase tracking-wide text-dark dark:text-white">
{tender.country_code}
</span>
{flagData && (
<div
dangerouslySetInnerHTML={{ __html: flagData }}
className="flex w-7 items-center justify-center overflow-hidden rounded-md border p-0"
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>
{data?.data.currency && (
<div>
<h2 className="mb-6 text-lg font-semibold">Currency</h2>
<div className="flex items-center gap-1">
<p className="text-sm uppercase">{data?.data.currency}</p>
<p>{getSymbolFromCurrency(data?.data.currency ?? "USD")}</p>
</div>
{tender.currency && (
<div className="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 shadow-theme-xs dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-purple-500/[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" />
<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">
{tender.currency}
</span>
<span className="text-lg font-medium text-dark-5 dark:text-dark-6">
{getSymbolFromCurrency(tender.currency ?? "USD")}
</span>
</div>
</div>
)}
<div></div>
</div>
<div className="mt-4">
<h2 className="mb-6 text-lg font-semibold">Description</h2>
<p>{data?.data.description}</p>
</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>
</>
+11 -3
View File
@@ -4,16 +4,24 @@ import type { ReactNode } from "react";
type PropsType = {
title?: string | ReactNode;
children: ReactNode;
/** Merges into the padded content wrapper below the title */
className?: string;
/** Merges into the outer card (e.g. `h-full flex flex-col` for grid rows) */
rootClassName?: string;
};
export function ShowcaseSection({ title, children, className }: PropsType) {
export function ShowcaseSection({ title, children, className, rootClassName }: PropsType) {
return (
<div className="relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-white to-primary/[0.03] shadow-theme-xs dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-primary/[0.08]">
<div
className={cn(
"relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-white to-primary/[0.03] shadow-theme-xs dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-primary/[0.08]",
rootClassName,
)}
>
<div className="pointer-events-none absolute -right-12 -top-12 h-32 w-32 rounded-full bg-primary/10 blur-3xl dark:bg-primary/20" />
<div className="pointer-events-none absolute -bottom-12 -left-12 h-32 w-32 rounded-full bg-purple-500/10 blur-3xl dark:bg-purple-500/20" />
{title && (
<h2 className="border-b border-stroke/70 bg-white/70 px-4 py-4 text-base font-semibold text-dark backdrop-blur-sm dark:border-dark-3 dark:bg-dark-2/40 dark:text-white sm:px-6 xl:px-7.5">
<h2 className="shrink-0 border-b border-stroke/70 bg-white/70 px-4 py-4 text-base font-semibold text-dark backdrop-blur-sm dark:border-dark-3 dark:bg-dark-2/40 dark:text-white sm:px-6 xl:px-7.5">
{title}
</h2>
)}
+3 -14
View File
@@ -24,7 +24,6 @@ import TableSkeleton from "@/components/ui/TableSkeleton";
import { _TooltipDefaultParams } from "@/constants/tooltip";
import { cn } from "@/lib/utils";
import { getPaginatedRowNumber } from "@/utils/shared";
import Link from "next/link";
import TenderListFilters from "./TenderListFilters";
const TendersTable = () => {
const {
@@ -89,22 +88,11 @@ const TendersTable = () => {
{item.country_code}
</TableCell>
<TableCell className={cn(`text capitalize`)} colSpan={100}>
{item.submission_url ? (
<Link target="_blank" href={item.submission_url}>
{item.submission_url}
</Link>
) : (
"-"
)}
</TableCell>
<TableCell className={cn(`text capitalize`)} colSpan={100}>
<Status status={item.status}>{item.status}</Status>
</TableCell>
<TableCell
className={cn(`text flex gap-2 capitalize`)}
colSpan={100}
>
<TableCell className="capitalize" colSpan={100}>
<div className="flex items-center gap-2">
<button
data-tooltip-id="details"
data-tooltip-content="Details"
@@ -132,6 +120,7 @@ const TendersTable = () => {
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
<ExclamationIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
@@ -57,7 +57,6 @@ const useTenderListPresenter = () => {
"row",
"title",
"country code",
"submission url",
"status",
"actions",
];