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
+211 -72
View File
@@ -6,7 +6,8 @@ import Loading from "@/components/loading";
import Status from "@/components/ui/Status"; import Status from "@/components/ui/Status";
import { useTenderDetailQuery } from "@/hooks/queries"; import { useTenderDetailQuery } from "@/hooks/queries";
import { useGetFlagQuery } from "@/hooks/queries/useFlagsQueries"; 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 getSymbolFromCurrency from "currency-symbol-map";
import Link from "next/link"; import Link from "next/link";
import { use } from "react"; 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 TenderDetails = ({ params }: IProps) => {
const { details } = use(params); const { details } = use(params);
const { data, isLoading, isError } = useTenderDetailQuery(details); const { data, isLoading, isError } = useTenderDetailQuery(details);
@@ -31,86 +69,187 @@ const TenderDetails = ({ params }: IProps) => {
{ name: "Tenders", href: "/tenders" }, { name: "Tenders", href: "/tenders" },
{ name: "Tender details", href: `/tenders/${details}` }, { name: "Tender details", href: `/tenders/${details}` },
]; ];
if (isLoading) return <Loading />; if (isLoading) return <Loading />;
if (isError) return <div>Error</div>; 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="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>
</ShowcaseSection>
</>
);
}
const tender = data!.data;
const submissionUrl = tender.submission_url?.trim();
return ( return (
<> <>
<Breadcrumb items={breadcrumbItems} /> <Breadcrumb items={breadcrumbItems} />
<ShowcaseSection className="!p-6.5"> <ShowcaseSection
<div className="flex justify-between border-b pb-4"> title={
<p>{data?.data.title}</p> <div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<Status status={data?.data.status ?? "Unknown"}> <div className="min-w-0 space-y-2">
{data?.data.status} <p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
</Status> Tender
</div> </p>
<div className="mt-4 flex justify-between gap-2 border-b pb-4"> <h1 className="text-balance text-xl font-bold leading-tight text-dark dark:text-white sm:text-2xl lg:text-[1.65rem]">
<ShowcaseSection title="Tender ID"> {tender.title}
{data?.data.tender_id} </h1>
</ShowcaseSection> </div>
<ShowcaseSection title="Buyer Organization"> <div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
{data?.data.buyer_organization.name} <Status status={tender.status ?? "Unknown"}>{tender.status}</Status>
</ShowcaseSection> {submissionUrl && (
<ShowcaseSection title="Notice Publication Id"> <Link
{data?.data.notice_publication_id} href={submissionUrl}
</ShowcaseSection> target="_blank"
<ShowcaseSection title="Procedure Code"> rel="noopener noreferrer"
{data?.data.procedure_code} 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"
</ShowcaseSection> >
<ShowcaseSection title="Procurement Type Code"> <span>Open submission</span>
{data?.data.procurement_type_code} <ExternalLinkIcon className="opacity-90" />
</ShowcaseSection> </Link>
</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>
</ShowcaseSection>
{data?.data.submission_url && (
<ShowcaseSection title="Submission Url">
<Link href={data?.data.submission_url ?? ""} target="_blank">
{data?.data.submission_url}
</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>
<h2 className="mb-6 text-lg font-semibold">Country</h2>
<div className="flex items-center gap-1">
<LocationIcon />
<p className="text-sm uppercase">{data?.data.country_code}</p>
{flagData && (
<div
dangerouslySetInnerHTML={{ __html: flagData }}
className="flex w-7 items-center justify-center overflow-hidden rounded-md border p-0"
/>
)} )}
</div> </div>
</div> </div>
{data?.data.currency && ( }
<div> className="!p-0"
<h2 className="mb-6 text-lg font-semibold">Currency</h2> >
<div className="flex items-center gap-1"> <div className="space-y-10 px-4 pb-8 pt-2 sm:px-6 sm:pb-10 xl:px-10">
<p className="text-sm uppercase">{data?.data.currency}</p> <div>
<p>{getSymbolFromCurrency(data?.data.currency ?? "USD")}</p> <SectionHeading>Procurement</SectionHeading>
</div> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
<ShowcaseSection title="Tender ID">
<span className="font-medium text-dark dark:text-white">{tender.tender_id}</span>
</ShowcaseSection>
<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">
<span className="font-medium text-dark dark:text-white">
{tender.notice_publication_id}
</span>
</ShowcaseSection>
<ShowcaseSection title="Procedure code">
<span className="font-medium text-dark dark:text-white">{tender.procedure_code}</span>
</ShowcaseSection>
<ShowcaseSection title="Procurement type">
<span className="font-medium text-dark dark:text-white">
{tender.procurement_type_code}
</span>
</ShowcaseSection>
</div> </div>
)} </div>
<div></div> <div>
</div> <SectionHeading>Timeline</SectionHeading>
<div className="mt-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<h2 className="mb-6 text-lg font-semibold">Description</h2> <ShowcaseSection title="Application deadline" {...TIMELINE_SHOWCASE}>
<p>{data?.data.description}</p> <time className="font-medium tabular-nums text-dark dark:text-white">
{msToDate(tender.application_deadline ?? 0)}
</time>
</ShowcaseSection>
<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>
)}
</div>
</div>
<div>
<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 />
</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 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>
{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>
<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> </div>
</ShowcaseSection> </ShowcaseSection>
</> </>
+11 -3
View File
@@ -4,16 +4,24 @@ import type { ReactNode } from "react";
type PropsType = { type PropsType = {
title?: string | ReactNode; title?: string | ReactNode;
children: ReactNode; children: ReactNode;
/** Merges into the padded content wrapper below the title */
className?: string; 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 ( 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 -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" /> <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 && ( {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} {title}
</h2> </h2>
)} )}
+30 -41
View File
@@ -24,7 +24,6 @@ import TableSkeleton from "@/components/ui/TableSkeleton";
import { _TooltipDefaultParams } from "@/constants/tooltip"; import { _TooltipDefaultParams } from "@/constants/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getPaginatedRowNumber } from "@/utils/shared"; import { getPaginatedRowNumber } from "@/utils/shared";
import Link from "next/link";
import TenderListFilters from "./TenderListFilters"; import TenderListFilters from "./TenderListFilters";
const TendersTable = () => { const TendersTable = () => {
const { const {
@@ -89,49 +88,39 @@ const TendersTable = () => {
{item.country_code} {item.country_code}
</TableCell> </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}> <TableCell className={cn(`text capitalize`)} colSpan={100}>
<Status status={item.status}>{item.status}</Status> <Status status={item.status}>{item.status}</Status>
</TableCell> </TableCell>
<TableCell <TableCell className="capitalize" colSpan={100}>
className={cn(`text flex gap-2 capitalize`)} <div className="flex items-center gap-2">
colSpan={100} <button
> data-tooltip-id="details"
<button data-tooltip-content="Details"
data-tooltip-id="details" data-tooltip-place="top"
data-tooltip-content="Details" onClick={() => {
data-tooltip-place="top" router.push(`${pathName}/${item.id}`);
onClick={() => { }}
router.push(`${pathName}/${item.id}`); >
}} <Tooltip
> {..._TooltipDefaultParams({
<Tooltip id: "details",
{..._TooltipDefaultParams({ })}
id: "details", />
})} <EyeIcon />
/> </button>
<EyeIcon /> <button
</button> data-tooltip-id="feedback"
<button data-tooltip-content="Feedback"
data-tooltip-id="feedback" data-tooltip-place="top"
data-tooltip-content="Feedback" className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
data-tooltip-place="top" onClick={() => {
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5" router.push(`${pathName}/feedback/${item.id}`);
onClick={() => { }}
router.push(`${pathName}/feedback/${item.id}`); >
}} <Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
> <ExclamationIcon />
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} /> </button>
<ExclamationIcon /> </div>
</button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -57,7 +57,6 @@ const useTenderListPresenter = () => {
"row", "row",
"title", "title",
"country code", "country code",
"submission url",
"status", "status",
"actions", "actions",
]; ];