feat(tender-details): add lots section and enhance tender details functionality

- Introduced a new "Lots" section in the tender details page to display lot-specific information, improving data organization and visibility.
- Updated the tender navigation to conditionally include the "Lots" section based on its availability.
- Enhanced the tender display utilities with functions to determine the visibility of various sections, including lots, description, and translation.
- Refactored components to utilize the new utilities for better conditional rendering and improved user experience.
- Improved type definitions for tender details to ensure better type safety and clarity.
This commit is contained in:
AmirReza Jamali
2026-05-13 09:44:43 +03:30
parent 2178f25af5
commit 37e2744420
13 changed files with 850 additions and 137 deletions
@@ -1,5 +1,6 @@
import type { TTenderDetails } from "@/lib/api/types/Tenders";
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
import { shouldShowAiSummarySection } from "../tender-display-utils";
import { SectionHeading } from "./section-heading";
type TenderAiSummarySectionProps = {
@@ -9,12 +10,14 @@ type TenderAiSummarySectionProps = {
export function TenderAiSummarySection({
tender,
}: TenderAiSummarySectionProps) {
if (!shouldShowAiSummarySection(tender)) return null;
return (
<div id={TENDER_SECTION_IDS.aiSummary} className={HEADER_SCROLL_MARGIN}>
<SectionHeading>AI summary</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.overall_summary || "Summary not ready yet"}
{tender.overall_summary}
</p>
</div>
</div>
@@ -1,5 +1,6 @@
import type { TTenderDetails } from "@/lib/api/types/Tenders";
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
import { shouldShowDescriptionSection } from "../tender-display-utils";
import { SectionHeading } from "./section-heading";
type TenderDescriptionSectionProps = {
@@ -9,12 +10,14 @@ type TenderDescriptionSectionProps = {
export function TenderDescriptionSection({
tender,
}: TenderDescriptionSectionProps) {
if (!shouldShowDescriptionSection(tender)) return null;
return (
<div id={TENDER_SECTION_IDS.description} className={HEADER_SCROLL_MARGIN}>
<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 || "—"}
{tender.description}
</p>
</div>
</div>
@@ -5,10 +5,12 @@ import type {
import { shouldShowLocationSection } from "../tender-nav-utils";
import type { TenderNavItem } from "../types";
import { scrollToTenderSection } from "../scroll-to-section";
import { shouldShowLotsSection } from "../tender-display-utils";
import { TenderAiSummarySection } from "./tender-ai-summary-section";
import { TenderDescriptionSection } from "./tender-description-section";
import { TenderDocumentsSection } from "./tender-documents-section";
import { TenderLocationSection } from "./tender-location-section";
import { TenderLotsSection } from "./tender-lots-section";
import { TenderProcurementSection } from "./tender-procurement-section";
import { TenderSectionNavList } from "./tender-section-nav-list";
import { TenderTimelineSection } from "./tender-timeline-section";
@@ -52,6 +54,9 @@ export function TenderDetailsContent({
</div>
<TenderProcurementSection tender={tender} />
{shouldShowLotsSection(tender) ? (
<TenderLotsSection tender={tender} />
) : null}
<TenderTimelineSection tender={tender} />
{showLocation && <TenderLocationSection tender={tender} />}
@@ -9,6 +9,7 @@ import {
HEADER_SCROLL_MARGIN,
TENDER_SECTION_IDS,
} from "../constants";
import { isValidRegionCode } from "../tender-display-utils";
import { isValidDisplayCountryCode } from "../tender-nav-utils";
import { SectionHeading } from "./section-heading";
@@ -34,10 +35,25 @@ export function TenderLocationSection({ tender }: TenderLocationSectionProps) {
Number.isFinite(tender.estimated_value) &&
tender.estimated_value > 0;
const regionNorm = tender.region_code?.trim() ?? "";
const hasValidRegion = isValidRegionCode(regionNorm);
return (
<div id={TENDER_SECTION_IDS.location} className={HEADER_SCROLL_MARGIN}>
<SectionHeading>Location & currency</SectionHeading>
<div className="grid gap-4 sm:grid-cols-2 lg:max-w-5xl lg:grid-cols-3">
{hasValidRegion && (
<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-violet/[0.06] p-5 dark:border-dark-3 dark:from-gray-dark dark:via-gray-dark dark:to-violet/[0.12]">
<div className="pointer-events-none absolute -right-8 top-0 h-24 w-24 rounded-full bg-violet/15 blur-2xl dark:bg-violet/25" />
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
Region (NUTS)
</h3>
<p className="text-sm font-semibold uppercase tracking-wide text-dark dark:text-white">
{regionNorm}
</p>
</div>
)}
{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" />
@@ -0,0 +1,112 @@
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import type { TTenderDetails, TTenderLot } from "@/lib/api/types/Tenders";
import { cn } from "@/lib/utils";
import { isNonEmptyTrimmed } from "../tender-display-utils";
import {
HEADER_SCROLL_MARGIN,
SHOWCASE_CENTERED_BODY,
TENDER_SECTION_IDS,
} from "../constants";
import { SectionHeading } from "./section-heading";
type TenderLotsSectionProps = {
tender: TTenderDetails;
};
function lotHasVisibleContent(lot: TTenderLot): boolean {
return (
isNonEmptyTrimmed(lot.lot_id) ||
isNonEmptyTrimmed(lot.title) ||
isNonEmptyTrimmed(lot.description) ||
isNonEmptyTrimmed(lot.main_nature_of_contract) ||
isNonEmptyTrimmed(lot.main_classification) ||
isNonEmptyTrimmed(lot.main_classification_display) ||
(typeof lot.estimated_value === "number" &&
Number.isFinite(lot.estimated_value) &&
lot.estimated_value > 0) ||
isNonEmptyTrimmed(lot.currency)
);
}
export function TenderLotsSection({ tender }: TenderLotsSectionProps) {
const lots = (tender.lots ?? []).filter(lotHasVisibleContent);
if (lots.length === 0) return null;
return (
<div id={TENDER_SECTION_IDS.lots} className={HEADER_SCROLL_MARGIN}>
<SectionHeading>Lots</SectionHeading>
<div className="space-y-6">
{lots.map((lot, idx) => {
const key = lot.lot_id?.trim() || `lot-${idx}`;
const cpv =
lot.main_classification_display?.trim() ||
lot.main_classification?.trim();
const hasValue =
typeof lot.estimated_value === "number" &&
Number.isFinite(lot.estimated_value) &&
lot.estimated_value > 0;
const currency = lot.currency?.trim().toUpperCase() ?? "";
return (
<div
key={key}
className="rounded-2xl border border-stroke/60 bg-gray-2/30 p-4 shadow-inner dark:border-dark-3 dark:bg-dark-3/25 sm:p-6"
>
<h3 className="mb-4 text-sm font-semibold text-dark dark:text-white">
{isNonEmptyTrimmed(lot.lot_id) ? lot.lot_id : `Lot ${idx + 1}`}
</h3>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{isNonEmptyTrimmed(lot.title) ? (
<ShowcaseSection
title="Title"
rootClassName={cn("sm:col-span-2 xl:col-span-3")}
className="text-start"
>
<span className="font-medium text-dark dark:text-white">
{lot.title}
</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(lot.description) ? (
<ShowcaseSection
title="Description"
rootClassName={cn("sm:col-span-2 xl:col-span-3")}
className="text-start"
>
<p className="text-sm leading-relaxed text-dark dark:text-white/90">
{lot.description}
</p>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(lot.main_nature_of_contract) ? (
<ShowcaseSection title="Nature of contract" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{lot.main_nature_of_contract}
</span>
</ShowcaseSection>
) : null}
{cpv ? (
<ShowcaseSection title="Classification (CPV)" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">{cpv}</span>
</ShowcaseSection>
) : null}
{hasValue ? (
<ShowcaseSection title="Estimated value" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium tabular-nums text-dark dark:text-white">
{new Intl.NumberFormat().format(lot.estimated_value!)}
{currency ? (
<span className="ms-1 text-sm font-medium uppercase text-dark-5 dark:text-dark-6">
{currency}
</span>
) : null}
</span>
</ShowcaseSection>
) : null}
</div>
</div>
);
})}
</div>
</div>
);
}
@@ -1,12 +1,17 @@
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import type { TTenderAddress, TTenderDetails } from "@/lib/api/types/Tenders";
import { cn } from "@/lib/utils";
import type { TTenderDetails } from "@/lib/api/types/Tenders";
import Link from "next/link";
import {
HEADER_SCROLL_MARGIN,
SHOWCASE_CENTERED_BODY,
TENDER_SECTION_IDS,
} from "../constants";
import {
hasBuyerExtendedDetails,
isNonEmptyTrimmed,
isValidHttpUrl,
} from "../tender-display-utils";
import { SectionHeading } from "./section-heading";
type TenderProcurementSectionProps = {
@@ -28,11 +33,38 @@ function uniqueTrimmedNoticeIds(ids: string[] | undefined): string[] {
return out;
}
function formatAddressLines(addr: TTenderAddress | undefined): string[] {
if (!addr) return [];
const lines: string[] = [];
if (isNonEmptyTrimmed(addr.street_name)) lines.push(addr.street_name!.trim());
const cityLine = [addr.postal_zone, addr.city_name]
.map((s) => s?.trim())
.filter(Boolean)
.join(" ");
if (cityLine) lines.push(cityLine);
const regionCountry = [addr.country_subentity_code, addr.country_code]
.map((s) => s?.trim())
.filter(Boolean)
.join(" · ");
if (regionCountry) lines.push(regionCountry);
return lines;
}
export function TenderProcurementSection({ tender }: TenderProcurementSectionProps) {
const relatedNoticeIds = uniqueTrimmedNoticeIds(tender.related_notice_publication_ids);
const noticeTypeCode = tender.notice_type_code?.trim();
const mainClassification = tender.main_classification?.trim();
const mainCpv =
tender.main_classification_display?.trim() || tender.main_classification?.trim();
const noticePubId = tender.notice_publication_id?.trim();
const durationParts = [tender.duration?.trim(), tender.duration_unit?.trim()].filter(
Boolean,
);
const durationLabel = durationParts.join(" ");
const buyer = tender.buyer_organization;
const buyerAddressLines = formatAddressLines(buyer.address);
const showBuyerOrgName = isNonEmptyTrimmed(buyer.name);
const showBuyerExtended = hasBuyerExtendedDetails(tender);
return (
<div
@@ -41,11 +73,21 @@ export function TenderProcurementSection({ tender }: TenderProcurementSectionPro
>
<SectionHeading>Procurement</SectionHeading>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{isNonEmptyTrimmed(tender.tender_id) ? (
<ShowcaseSection title="Tender ID" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{tender.tender_id}
<span className="font-medium text-dark dark:text-white">{tender.tender_id}</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(tender.project_name) ? (
<ShowcaseSection title="Project name" {...SHOWCASE_CENTERED_BODY}>
<span className="break-words font-medium text-dark dark:text-white">
{tender.project_name}
</span>
</ShowcaseSection>
) : null}
{showBuyerOrgName ? (
<ShowcaseSection
title="Buyer organization"
rootClassName={SHOWCASE_CENTERED_BODY.rootClassName}
@@ -54,48 +96,130 @@ export function TenderProcurementSection({ tender }: TenderProcurementSectionPro
SHOWCASE_CENTERED_BODY.className,
)}
>
<span className="font-medium text-dark dark:text-white">
{tender.buyer_organization.name}
</span>
<span className="font-medium text-dark dark:text-white">{buyer.name}</span>
</ShowcaseSection>
) : null}
{noticePubId ? (
<ShowcaseSection title="Notice publication ID" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-primary underline-offset-4 hover:underline">
<Link
href={`${TED_NOTICE_DETAIL}/${tender.notice_publication_id}`}
href={`${TED_NOTICE_DETAIL}/${noticePubId}`}
target="_blank"
rel="noopener noreferrer"
>
{tender.notice_publication_id}
{noticePubId}
</Link>
</span>
</ShowcaseSection>
<ShowcaseSection title="Procedure code" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{tender.procedure_code}
) : null}
{isNonEmptyTrimmed(tender.procurement_project_id) ? (
<ShowcaseSection title="Procurement project ID" {...SHOWCASE_CENTERED_BODY}>
<span className="break-all font-medium text-dark dark:text-white">
{tender.procurement_project_id}
</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(tender.contract_folder_id) ? (
<ShowcaseSection title="Contract folder ID" {...SHOWCASE_CENTERED_BODY}>
<span className="break-all font-medium text-dark dark:text-white">
{tender.contract_folder_id}
</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(tender.contract_notice_id) ? (
<ShowcaseSection title="Contract notice ID" {...SHOWCASE_CENTERED_BODY}>
<span className="break-all font-medium text-dark dark:text-white">
{tender.contract_notice_id}
</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(tender.notice_identifier) ? (
<ShowcaseSection title="Notice identifier" {...SHOWCASE_CENTERED_BODY}>
<span className="break-all font-medium text-dark dark:text-white">
{tender.notice_identifier}
</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(tender.notice_version) ? (
<ShowcaseSection title="Notice version" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{tender.notice_version}
</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(tender.gazette_id) ? (
<ShowcaseSection title="Gazette ID" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">{tender.gazette_id}</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(tender.form_type) ? (
<ShowcaseSection title="Form type" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">{tender.form_type}</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(tender.notice_sub_type_code) ? (
<ShowcaseSection title="Notice sub-type code" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{tender.notice_sub_type_code}
</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(tender.source) ? (
<ShowcaseSection title="Source" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">{tender.source}</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(tender.procedure_code) ? (
<ShowcaseSection title="Procedure code" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">{tender.procedure_code}</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(tender.procurement_type_code) ? (
<ShowcaseSection title="Procurement type" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{tender.procurement_type_code}
</span>
</ShowcaseSection>
) : null}
{noticeTypeCode ? (
<ShowcaseSection title="Notice type code" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">{noticeTypeCode}</span>
</ShowcaseSection>
) : null}
{mainCpv ? (
<ShowcaseSection title="Main classification (CPV)" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">{mainCpv}</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(tender.estimated_value_vat_basis) ? (
<ShowcaseSection title="Estimated value (VAT basis)" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{noticeTypeCode}
{tender.estimated_value_vat_basis}
</span>
</ShowcaseSection>
) : null}
{mainClassification ? (
<ShowcaseSection
title="Main classification (CPV)"
{...SHOWCASE_CENTERED_BODY}
>
<span className="font-medium text-dark dark:text-white">
{mainClassification}
</span>
{durationLabel ? (
<ShowcaseSection title="Duration" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">{durationLabel}</span>
</ShowcaseSection>
) : null}
{relatedNoticeIds.length > 0 ? (
<ShowcaseSection
title="Related notice publications"
@@ -122,6 +246,78 @@ export function TenderProcurementSection({ tender }: TenderProcurementSectionPro
</ShowcaseSection>
) : null}
</div>
{showBuyerExtended ? (
<div className="mt-8 space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
Buyer contact &amp; address
</h3>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{isNonEmptyTrimmed(buyer.company_id) ? (
<ShowcaseSection title="Company ID" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">{buyer.company_id}</span>
</ShowcaseSection>
) : null}
{isValidHttpUrl(buyer.website_uri) ? (
<ShowcaseSection title="Buyer website" {...SHOWCASE_CENTERED_BODY}>
<Link
href={buyer.website_uri!.trim()}
target="_blank"
rel="noopener noreferrer"
className="break-all font-medium text-primary underline-offset-4 hover:underline"
>
{buyer.website_uri!.trim()}
</Link>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(buyer.contact_name) ? (
<ShowcaseSection title="Contact name" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{buyer.contact_name}
</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(buyer.contact_telephone) ? (
<ShowcaseSection title="Contact telephone" {...SHOWCASE_CENTERED_BODY}>
<span className="font-medium text-dark dark:text-white">
{buyer.contact_telephone}
</span>
</ShowcaseSection>
) : null}
{isNonEmptyTrimmed(buyer.contact_email) ? (
<ShowcaseSection title="Contact email" {...SHOWCASE_CENTERED_BODY}>
<a
href={`mailto:${buyer.contact_email!.trim()}`}
className="break-all font-medium text-primary underline-offset-4 hover:underline"
>
{buyer.contact_email!.trim()}
</a>
</ShowcaseSection>
) : null}
{buyerAddressLines.length > 0 ? (
<ShowcaseSection
title="Address"
rootClassName={cn(
SHOWCASE_CENTERED_BODY.rootClassName,
"sm:col-span-2 xl:col-span-2 2xl:col-span-3",
)}
className="text-start"
>
<address className="not-italic">
{buyerAddressLines.map((line, i) => (
<div
key={`${line}-${i}`}
className="text-sm font-medium text-dark dark:text-white"
>
{line}
</div>
))}
</address>
</ShowcaseSection>
) : null}
</div>
</div>
) : null}
</div>
);
}
@@ -1,12 +1,21 @@
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import type { TTenderDetails } from "@/lib/api/types/Tenders";
import { truncateString, unixToDate } from "@/utils/shared";
import { isoStringToDate, truncateString, unixToDate } from "@/utils/shared";
import Link from "next/link";
import {
HEADER_SCROLL_MARGIN,
SHOWCASE_CENTERED_BODY,
TENDER_SECTION_IDS,
} from "../constants";
import {
hasAddressContent,
hasWinningTendererContent,
isNonEmptyTrimmed,
isValidHttpUrl,
isValidIsoDateTimeString,
isValidPositiveUnix,
shouldShowExternalLinksBlock,
} from "../tender-display-utils";
import { ExternalLinkIcon } from "./external-link-icon";
import { SectionHeading } from "./section-heading";
@@ -14,58 +23,212 @@ type TenderTimelineSectionProps = {
tender: TTenderDetails;
};
function formatWinnerAddress(tender: TTenderDetails): string[] {
const w = tender.winning_tenderer;
if (!w?.address || !hasAddressContent(w.address)) return [];
const a = w.address;
const lines: string[] = [];
if (isNonEmptyTrimmed(a.street_name)) lines.push(a.street_name!.trim());
const cityLine = [a.postal_zone, a.city_name]
.map((s) => s?.trim())
.filter(Boolean)
.join(" ");
if (cityLine) lines.push(cityLine);
const tail = [a.country_subentity_code, a.country_code]
.map((s) => s?.trim())
.filter(Boolean)
.join(" · ");
if (tail) lines.push(tail);
return lines;
}
export function TenderTimelineSection({ tender }: TenderTimelineSectionProps) {
const submissionUrl = tender.submission_url?.trim();
const dispatchIso = tender.notice_dispatch_date_with_timezone?.trim();
const dispatchLabel = isValidIsoDateTimeString(dispatchIso)
? isoStringToDate({ isoString: dispatchIso!, hasTime: true })
: isValidPositiveUnix(tender.notice_dispatch_at)
? unixToDate({
unix: tender.notice_dispatch_at!,
hasTime: true,
})
: null;
const winnerName = tender.winning_tenderer?.name?.trim();
const winnerLines = formatWinnerAddress(tender);
const showWinnerBlock =
hasWinningTendererContent(tender) &&
(Boolean(winnerName) || winnerLines.length > 0);
const showSubmissionUrl = Boolean(submissionUrl) && isValidHttpUrl(submissionUrl);
const showExternal = shouldShowExternalLinksBlock(tender);
const deadlineCards = [
{
key: "application",
title: "Application deadline",
unix: tender.application_deadline,
},
{
key: "publication",
title: "Publication date",
unix: tender.publication_date,
},
{
key: "submission",
title: "Submission deadline",
unix: tender.submission_deadline,
},
{
key: "tender",
title: "Tender deadline",
unix: tender.tender_deadline,
},
].filter((d) => isValidPositiveUnix(d.unix));
return (
<div id={TENDER_SECTION_IDS.timeline} className={HEADER_SCROLL_MARGIN}>
<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}>
{isValidPositiveUnix(tender.award_date) ? (
<ShowcaseSection title="Award date" {...SHOWCASE_CENTERED_BODY}>
<time className="font-medium tabular-nums text-dark dark:text-white">
{unixToDate({
unix: tender.application_deadline ?? 0,
unix: tender.award_date!,
hasTime: true,
})}
</time>
</ShowcaseSection>
<ShowcaseSection title="Publication date" {...SHOWCASE_CENTERED_BODY}>
) : null}
{showWinnerBlock ? (
<ShowcaseSection
title="Winning tenderer"
rootClassName="sm:col-span-2 lg:col-span-3 xl:col-span-5"
className="text-start"
>
{winnerName ? (
<p className="font-medium text-dark dark:text-white">{winnerName}</p>
) : null}
{winnerLines.length > 0 ? (
<div className={winnerName ? "mt-2 space-y-1" : "space-y-1"}>
{winnerLines.map((line, i) => (
<p
key={`w-${i}`}
className="text-sm text-dark dark:text-white/90"
>
{line}
</p>
))}
</div>
) : null}
</ShowcaseSection>
) : null}
{dispatchLabel ? (
<ShowcaseSection title="Notice dispatch" {...SHOWCASE_CENTERED_BODY}>
<time className="font-medium tabular-nums text-dark dark:text-white">
{dispatchLabel}
</time>
</ShowcaseSection>
) : null}
{isValidPositiveUnix(tender.issue_date) ? (
<ShowcaseSection title="Issue date" {...SHOWCASE_CENTERED_BODY}>
<time className="font-medium tabular-nums text-dark dark:text-white">
{unixToDate({
unix: tender.publication_date ?? 0,
unix: tender.issue_date!,
hasTime: false,
})}
</time>
</ShowcaseSection>
) : null}
{isValidPositiveUnix(tender.issue_time) ? (
<ShowcaseSection title="Issue time" {...SHOWCASE_CENTERED_BODY}>
<time className="font-medium tabular-nums text-dark dark:text-white">
{unixToDate({
unix: tender.issue_time!,
hasTime: true,
})}
</time>
</ShowcaseSection>
<ShowcaseSection title="Submission deadline" {...SHOWCASE_CENTERED_BODY}>
) : null}
{deadlineCards.map((d) => (
<ShowcaseSection key={d.key} title={d.title} {...SHOWCASE_CENTERED_BODY}>
<time className="font-medium tabular-nums text-dark dark:text-white">
{unixToDate({
unix: tender.submission_deadline ?? 0,
unix: d.unix as number,
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 && (
))}
{showSubmissionUrl ? (
<ShowcaseSection title="Submission URL" {...SHOWCASE_CENTERED_BODY}>
<Link
href={submissionUrl}
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)}
{truncateString(submissionUrl!, 42)}
</Link>
</ShowcaseSection>
)}
) : null}
{showExternal ? (
<ShowcaseSection
title="External links"
rootClassName="sm:col-span-2 lg:col-span-3 xl:col-span-5"
className="text-start"
>
<ul className="flex flex-col gap-2">
{isValidHttpUrl(tender.document_url) ? (
<li>
<Link
href={tender.document_url!.trim()}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 break-all font-medium text-primary underline-offset-4 hover:underline"
>
<ExternalLinkIcon className="shrink-0" />
Document (TED)
</Link>
</li>
) : null}
{isValidHttpUrl(tender.tender_url) ? (
<li>
<Link
href={tender.tender_url!.trim()}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 break-all font-medium text-primary underline-offset-4 hover:underline"
>
<ExternalLinkIcon className="shrink-0" />
{truncateString(tender.tender_url!.trim(), 56)}
</Link>
</li>
) : null}
{isValidHttpUrl(tender.buyer_profile_url) ? (
<li>
<Link
href={tender.buyer_profile_url!.trim()}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 break-all font-medium text-primary underline-offset-4 hover:underline"
>
<ExternalLinkIcon className="shrink-0" />
Buyer profile
</Link>
</li>
) : null}
</ul>
</ShowcaseSection>
) : null}
</div>
</div>
);
@@ -1,5 +1,9 @@
import type { TTenderDetails } from "@/lib/api/types/Tenders";
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
import {
isNonEmptyTrimmed,
shouldShowTranslationSection,
} from "../tender-display-utils";
import { SectionHeading } from "./section-heading";
type TenderTranslationSectionProps = {
@@ -9,18 +13,33 @@ type TenderTranslationSectionProps = {
export function TenderTranslationSection({
tender,
}: TenderTranslationSectionProps) {
if (!shouldShowTranslationSection(tender)) return null;
const showNoticeLang = isNonEmptyTrimmed(tender.notice_language_code);
const showResolved = isNonEmptyTrimmed(tender.language);
return (
<div id={TENDER_SECTION_IDS.translation} className={HEADER_SCROLL_MARGIN}>
<SectionHeading>Translation</SectionHeading>
<div className="grid gap-4 sm:grid-cols-2">
{showNoticeLang ? (
<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="mb-2 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
Notice language
</p>
<p className="text-sm text-dark dark:text-white">
{tender.notice_language_code}
</p>
</div>
) : null}
{showResolved ? (
<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="mb-2 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
Resolved language
</p>
<p className="text-sm text-dark dark:text-white">
{tender.language || "original"}
</p>
<p className="text-sm text-dark dark:text-white">{tender.language}</p>
</div>
) : null}
</div>
</div>
);
+1
View File
@@ -7,6 +7,7 @@ export const TENDER_HEADER_ACTIONS_RESERVE_PX = 360;
export const TENDER_SECTION_IDS = {
procurement: "tender-section-procurement",
lots: "tender-section-lots",
timeline: "tender-section-timeline",
location: "tender-section-location",
translation: "tender-section-translation",
+2 -1
View File
@@ -8,6 +8,7 @@ import {
useTenderDetailQuery,
useTranslateTenderMutation,
} from "@/hooks/queries";
import type { TTenderDetails } from "@/lib/api/types/Tenders";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { use, useCallback, useMemo } from "react";
import { TenderDetailsContent } from "./components/tender-details-content";
@@ -93,7 +94,7 @@ const TenderDetails = ({ params }: IProps) => {
return <TenderDetailsError breadcrumbItems={breadcrumbItems} />;
}
const tender = data!.data;
const tender: TTenderDetails = data!.data;
return (
<>
@@ -0,0 +1,105 @@
import type { TTenderAddress, TTenderDetails } from "@/lib/api/types/Tenders";
export function isNonEmptyTrimmed(value: string | undefined | null): boolean {
return Boolean(value?.trim());
}
export function isValidPositiveUnix(value: number | undefined | null): boolean {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
export function isValidHttpUrl(value: string | undefined | null): boolean {
const t = value?.trim();
if (!t) return false;
try {
const u = new URL(t);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}
export function isValidIsoDateTimeString(
value: string | undefined | null,
): boolean {
const t = value?.trim();
if (!t) return false;
const ms = Date.parse(t);
return Number.isFinite(ms);
}
export function hasAddressContent(addr: TTenderAddress | undefined): boolean {
if (!addr) return false;
return (
isNonEmptyTrimmed(addr.street_name) ||
isNonEmptyTrimmed(addr.city_name) ||
isNonEmptyTrimmed(addr.postal_zone) ||
isNonEmptyTrimmed(addr.country_subentity_code) ||
isNonEmptyTrimmed(addr.country_code)
);
}
export function hasWinningTendererContent(tender: TTenderDetails): boolean {
const w = tender.winning_tenderer;
if (!w) return false;
if (isNonEmptyTrimmed(w.name)) return true;
return hasAddressContent(w.address);
}
export function shouldShowLotsSection(tender: TTenderDetails): boolean {
const lots = tender.lots ?? [];
return lots.some(
(lot) =>
isNonEmptyTrimmed(lot.lot_id) ||
isNonEmptyTrimmed(lot.title) ||
isNonEmptyTrimmed(lot.description) ||
isNonEmptyTrimmed(lot.main_nature_of_contract) ||
isNonEmptyTrimmed(lot.main_classification) ||
isNonEmptyTrimmed(lot.main_classification_display) ||
(typeof lot.estimated_value === "number" &&
Number.isFinite(lot.estimated_value) &&
lot.estimated_value > 0) ||
isNonEmptyTrimmed(lot.currency),
);
}
export function hasBuyerExtendedDetails(tender: TTenderDetails): boolean {
const b = tender.buyer_organization;
return (
isNonEmptyTrimmed(b.company_id) ||
isValidHttpUrl(b.website_uri) ||
isNonEmptyTrimmed(b.contact_name) ||
isNonEmptyTrimmed(b.contact_telephone) ||
isNonEmptyTrimmed(b.contact_email) ||
hasAddressContent(b.address)
);
}
export function shouldShowDescriptionSection(tender: TTenderDetails): boolean {
return isNonEmptyTrimmed(tender.description);
}
export function shouldShowTranslationSection(tender: TTenderDetails): boolean {
return (
isNonEmptyTrimmed(tender.language) ||
isNonEmptyTrimmed(tender.notice_language_code)
);
}
export function shouldShowAiSummarySection(tender: TTenderDetails): boolean {
return isNonEmptyTrimmed(tender.overall_summary);
}
export function shouldShowExternalLinksBlock(tender: TTenderDetails): boolean {
return (
isValidHttpUrl(tender.document_url) ||
isValidHttpUrl(tender.tender_url) ||
isValidHttpUrl(tender.buyer_profile_url)
);
}
export function isValidRegionCode(code: string | undefined | null): boolean {
const t = code?.trim() ?? "";
if (t.length < 2) return false;
return /^[A-Z0-9]{2,10}$/i.test(t);
}
+21 -6
View File
@@ -1,6 +1,13 @@
import type { TTenderDetails } from "@/lib/api/types/Tenders";
import { TENDER_SECTION_IDS } from "./constants";
import type { TenderNavItem } from "./types";
import {
isValidRegionCode,
shouldShowAiSummarySection,
shouldShowDescriptionSection,
shouldShowLotsSection,
shouldShowTranslationSection,
} from "./tender-display-utils";
export const isValidDisplayCountryCode = (code: string) =>
/^[A-Za-z]{2,3}$/.test(code);
@@ -14,7 +21,8 @@ function analyzeLocationVisibility(tender: TTenderDetails) {
typeof tender.estimated_value === "number" &&
Number.isFinite(tender.estimated_value) &&
tender.estimated_value > 0;
const showLocation = hasCountry || hasCurrency || hasEst;
const hasRegion = isValidRegionCode(tender.region_code);
const showLocation = hasCountry || hasCurrency || hasEst || hasRegion;
return { showLocation };
}
@@ -27,22 +35,29 @@ export function getTenderNavItems(
const items: TenderNavItem[] = [
{ id: TENDER_SECTION_IDS.procurement, label: "Procurement" },
{ id: TENDER_SECTION_IDS.timeline, label: "Timeline" },
];
if (shouldShowLotsSection(tender)) {
items.push({ id: TENDER_SECTION_IDS.lots, label: "Lots" });
}
items.push({ id: TENDER_SECTION_IDS.timeline, label: "Timeline" });
if (showLocation) {
items.push({
id: TENDER_SECTION_IDS.location,
label: "Location & currency",
});
}
if (shouldShowTranslationSection(tender)) {
items.push({ id: TENDER_SECTION_IDS.translation, label: "Translation" });
}
if (showDocuments) {
items.push({ id: TENDER_SECTION_IDS.documents, label: "Documents" });
}
items.push(
{ id: TENDER_SECTION_IDS.description, label: "Description" },
{ id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" },
);
if (shouldShowDescriptionSection(tender)) {
items.push({ id: TENDER_SECTION_IDS.description, label: "Description" });
}
if (shouldShowAiSummarySection(tender)) {
items.push({ id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" });
}
return items;
}
+98 -24
View File
@@ -1,37 +1,111 @@
import { z } from "zod";
export const TenderSchema = z.object({
/** TED / eForms notice address block */
export const TenderAddressSchema = z
.object({
street_name: z.string().optional(),
city_name: z.string().optional(),
postal_zone: z.string().optional(),
country_subentity_code: z.string().optional(),
country_code: z.string().optional(),
})
.passthrough();
export type TTenderAddress = z.infer<typeof TenderAddressSchema>;
export const BuyerOrganizationSchema = z
.object({
name: z.string().default(""),
company_id: z.string().optional(),
website_uri: z.string().optional(),
contact_name: z.string().optional(),
contact_telephone: z.string().optional(),
contact_email: z.string().optional(),
address: TenderAddressSchema.optional(),
})
.passthrough();
export type TBuyerOrganization = z.infer<typeof BuyerOrganizationSchema>;
export const WinningTendererSchema = z
.object({
name: z.string().optional(),
address: TenderAddressSchema.optional(),
})
.passthrough();
export type TWinningTenderer = z.infer<typeof WinningTendererSchema>;
export const TenderLotSchema = z
.object({
lot_id: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
main_nature_of_contract: z.string().optional(),
main_classification: z.string().optional(),
main_classification_display: z.string().optional(),
estimated_value: z.number().optional(),
currency: z.string().optional(),
})
.passthrough();
export type TTenderLot = z.infer<typeof TenderLotSchema>;
export const TenderSchema = z
.object({
id: z.string(),
buyer_organization: z.object({
name: z.string(),
}),
application_deadline: z.number(),
country_code: z.string(),
currency: z.string(),
description: z.string(),
duration: z.string(),
duration_unit: z.string(),
estimated_value: z.number(),
notice_publication_id: z.string(),
/** Related TED notice publication IDs (e.g. modifications, same procedure). */
related_notice_publication_ids: z.array(z.string()).optional(),
/** TED form / notice type code (e.g. can-modif). */
contract_folder_id: z.string().optional(),
procurement_project_id: z.string().optional(),
contract_notice_id: z.string().optional(),
notice_type_code: z.string().optional(),
/** Primary CPV classification code. */
main_classification: z.string().optional(),
procedure_code: z.string(),
procurement_type_code: z.string(),
publication_date: z.number(),
status: z.string(),
submission_deadline: z.number(),
submission_url: z.string(),
tender_deadline: z.number(),
created_at: z.number().optional(),
tender_id: z.string(),
form_type: z.string().optional(),
notice_sub_type_code: z.string().optional(),
notice_identifier: z.string().optional(),
notice_version: z.string().optional(),
notice_language_code: z.string().optional(),
gazette_id: z.string().optional(),
notice_dispatch_at: z.number().optional(),
notice_dispatch_date_with_timezone: z.string().optional(),
issue_date: z.number().optional(),
issue_time: z.number().optional(),
title: z.string(),
description: z.string(),
procurement_type_code: z.string(),
procedure_code: z.string(),
main_classification: z.string().optional(),
main_classification_display: z.string().optional(),
estimated_value: z.number(),
currency: z.string(),
estimated_value_vat_basis: z.string().optional(),
duration: z.string().optional(),
duration_unit: z.string().optional(),
publication_date: z.number(),
tender_deadline: z.number(),
submission_deadline: z.number(),
application_deadline: z.number(),
submission_url: z.string(),
country_code: z.string(),
region_code: z.string().optional(),
document_url: z.string().optional(),
tender_url: z.string().optional(),
buyer_profile_url: z.string().optional(),
buyer_organization: BuyerOrganizationSchema,
winning_tenderer: WinningTendererSchema.optional(),
lots: z.array(TenderLotSchema).optional(),
award_date: z.number().optional(),
status: z.string(),
source: z.string().optional(),
tender_id: z.string(),
project_name: z.string().optional(),
created_at: z.number().optional(),
/** Resolved / UI language (may come from translation pipeline). */
language: z.string().optional(),
overall_summary: z.string().optional(),
});
})
.passthrough();
export const TenderDocumentsResponseSchema = z.object({
filename: z.string(),