refactor(tender-details): enhance translation page tests and layout
- Updated Cypress tests for the tender translation page to improve visibility checks and streamline assertions. - Refactored test structure to utilize a dedicated function for fetching the visible title heading. - Adjusted test cases to ensure accurate language switching and URL handling for tender details. - Improved layout consistency in the translation page by refining element visibility checks and ensuring proper section rendering.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SectionHeading({ children }: { children: 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
type TenderAiSummarySectionProps = {
|
||||
tender: TTenderDetails;
|
||||
};
|
||||
|
||||
export function TenderAiSummarySection({
|
||||
tender,
|
||||
}: TenderAiSummarySectionProps) {
|
||||
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"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
type TenderDescriptionSectionProps = {
|
||||
tender: TTenderDetails;
|
||||
};
|
||||
|
||||
export function TenderDescriptionSection({
|
||||
tender,
|
||||
}: TenderDescriptionSectionProps) {
|
||||
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 || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { shouldShowLocationSection } from "../tender-nav-utils";
|
||||
import type { TenderNavItem } from "../types";
|
||||
import { scrollToTenderSection } from "../scroll-to-section";
|
||||
import { TenderAiSummarySection } from "./tender-ai-summary-section";
|
||||
import { TenderDescriptionSection } from "./tender-description-section";
|
||||
import { TenderLocationSection } from "./tender-location-section";
|
||||
import { TenderProcurementSection } from "./tender-procurement-section";
|
||||
import { TenderSectionNavList } from "./tender-section-nav-list";
|
||||
import { TenderTimelineSection } from "./tender-timeline-section";
|
||||
import { TenderTranslationSection } from "./tender-translation-section";
|
||||
|
||||
type TenderDetailsContentProps = {
|
||||
tender: TTenderDetails;
|
||||
tenderNavItems: TenderNavItem[];
|
||||
activeSectionId: string;
|
||||
};
|
||||
|
||||
export function TenderDetailsContent({
|
||||
tender,
|
||||
tenderNavItems,
|
||||
activeSectionId,
|
||||
}: TenderDetailsContentProps) {
|
||||
const showLocation = shouldShowLocationSection(tender);
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-8 pt-2 sm:px-6 sm:pb-10 xl:px-8 xl:pb-10 xl:pt-3 2xl:px-10">
|
||||
<div className="xl:grid xl:grid-cols-[minmax(0,1fr)_13.5rem] xl:items-start xl:gap-10 2xl:grid-cols-[minmax(0,1fr)_15rem] 2xl:gap-12">
|
||||
<div className="min-w-0 space-y-6 xl:space-y-10">
|
||||
<div className="xl:hidden">
|
||||
<div className="rounded-2xl border border-stroke/60 bg-white/70 p-3 shadow-sm backdrop-blur-sm dark:border-dark-3 dark:bg-dark-2/40">
|
||||
<TenderSectionNavList
|
||||
items={tenderNavItems}
|
||||
activeId={activeSectionId}
|
||||
onSelect={scrollToTenderSection}
|
||||
variant="strip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TenderProcurementSection tender={tender} />
|
||||
<TenderTimelineSection tender={tender} />
|
||||
|
||||
{showLocation && <TenderLocationSection tender={tender} />}
|
||||
|
||||
<TenderTranslationSection tender={tender} />
|
||||
<TenderDescriptionSection tender={tender} />
|
||||
<TenderAiSummarySection tender={tender} />
|
||||
</div>
|
||||
|
||||
<aside className="sticky top-24 z-10 mt-2 hidden min-w-0 self-start xl:block">
|
||||
<div className="shadow-theme-xs max-h-[calc(100vh-7rem)] overflow-y-auto rounded-2xl border border-stroke/60 bg-white/80 p-4 backdrop-blur-md dark:border-dark-3 dark:bg-dark-2/50">
|
||||
<TenderSectionNavList
|
||||
items={tenderNavItems}
|
||||
activeId={activeSectionId}
|
||||
onSelect={scrollToTenderSection}
|
||||
variant="rail"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import Link from "next/link";
|
||||
|
||||
type BreadcrumbItem = { name: string; href: string };
|
||||
|
||||
export function TenderDetailsError({
|
||||
breadcrumbItems,
|
||||
}: {
|
||||
breadcrumbItems: BreadcrumbItem[];
|
||||
}) {
|
||||
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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import Status from "@/components/ui/Status";
|
||||
import { Languages } from "@/constants/languages";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useRef } from "react";
|
||||
import type { FieldValues } from "react-hook-form";
|
||||
import { TENDER_HEADER_TITLE_CLASS } from "../constants";
|
||||
import { useStackTenderHeaderActions } from "../hooks/use-stack-tender-header-actions";
|
||||
|
||||
type LanguageSelectOnChange = NonNullable<
|
||||
ComponentProps<typeof Select<FieldValues>>["onChange"]
|
||||
>;
|
||||
|
||||
type TenderDetailsHeaderProps = {
|
||||
title: string;
|
||||
selectedLanguage: string;
|
||||
onLanguageChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
onTranslate: () => void;
|
||||
isTranslating: boolean;
|
||||
status: string | undefined;
|
||||
};
|
||||
|
||||
export function TenderDetailsHeader({
|
||||
title,
|
||||
selectedLanguage,
|
||||
onLanguageChange,
|
||||
onTranslate,
|
||||
isTranslating,
|
||||
status,
|
||||
}: TenderDetailsHeaderProps) {
|
||||
const headerLayoutContainerRef = useRef<HTMLDivElement>(null);
|
||||
const headerTitleProbeSizerRef = useRef<HTMLDivElement>(null);
|
||||
const stackActionsBelow = useStackTenderHeaderActions(
|
||||
title,
|
||||
headerLayoutContainerRef,
|
||||
headerTitleProbeSizerRef,
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={headerLayoutContainerRef} className="relative">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 max-h-0 overflow-hidden opacity-0"
|
||||
aria-hidden
|
||||
>
|
||||
<div ref={headerTitleProbeSizerRef}>
|
||||
<h1 className={TENDER_HEADER_TITLE_CLASS}>{title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-5",
|
||||
!stackActionsBelow &&
|
||||
"lg:flex-row lg:items-start lg:justify-between lg:gap-8",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 space-y-2",
|
||||
!stackActionsBelow && "lg:flex-1",
|
||||
stackActionsBelow && "lg:w-full",
|
||||
)}
|
||||
>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||
Tender
|
||||
</p>
|
||||
<h1 className={TENDER_HEADER_TITLE_CLASS}>{title}</h1>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-w-0 shrink-0 flex-col gap-3",
|
||||
!stackActionsBelow && "lg:max-w-2xl lg:items-end",
|
||||
stackActionsBelow && "lg:w-full lg:items-stretch",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] font-semibold uppercase tracking-[0.18em] text-dark-5 dark:text-dark-6 lg:w-full",
|
||||
stackActionsBelow ? "lg:text-start" : "lg:text-end",
|
||||
)}
|
||||
>
|
||||
Language & translation
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center",
|
||||
stackActionsBelow
|
||||
? "sm:justify-start lg:justify-start"
|
||||
: "sm:justify-end lg:justify-end",
|
||||
)}
|
||||
>
|
||||
<Select<FieldValues>
|
||||
name="lang"
|
||||
label="Display language"
|
||||
items={Languages}
|
||||
className={cn(
|
||||
"w-full shrink-0 space-y-0 sm:w-[min(100%,11.5rem)]",
|
||||
"[&_label]:sr-only",
|
||||
)}
|
||||
value={selectedLanguage}
|
||||
onChange={onLanguageChange as LanguageSelectOnChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
shape="rounded"
|
||||
className="w-full shrink-0 whitespace-nowrap sm:min-h-[3.375rem] sm:w-auto"
|
||||
label={
|
||||
isTranslating ? "Translating..." : "Translate metadata"
|
||||
}
|
||||
disabled={isTranslating}
|
||||
onClick={onTranslate}
|
||||
/>
|
||||
<div className="flex shrink-0 items-center sm:min-h-[3.375rem]">
|
||||
<Status status={status ?? "Unknown"}>{status}</Status>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { LocationIcon } from "@/assets/icons";
|
||||
import { useGetFlagQuery } from "@/hooks/queries/useFlagsQueries";
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { isValidAlpha2CountryCode } from "@/utils/shared";
|
||||
import getSymbolFromCurrency from "currency-symbol-map";
|
||||
import {
|
||||
HEADER_SCROLL_MARGIN,
|
||||
TENDER_SECTION_IDS,
|
||||
} from "../constants";
|
||||
import { isValidDisplayCountryCode } from "../tender-nav-utils";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
type TenderLocationSectionProps = {
|
||||
tender: TTenderDetails;
|
||||
};
|
||||
|
||||
export function TenderLocationSection({ tender }: TenderLocationSectionProps) {
|
||||
const countryCodeNormalized =
|
||||
tender.country_code?.trim().toUpperCase() ?? "";
|
||||
const hasValidCountryCodeForDisplay = isValidDisplayCountryCode(
|
||||
countryCodeNormalized,
|
||||
);
|
||||
const canLoadCountryFlag = isValidAlpha2CountryCode(countryCodeNormalized);
|
||||
const { data: flagData } = useGetFlagQuery(countryCodeNormalized, {
|
||||
enabled: canLoadCountryFlag,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
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 { SectionHeading } from "./section-heading";
|
||||
|
||||
type TenderProcurementSectionProps = {
|
||||
tender: TTenderDetails;
|
||||
};
|
||||
|
||||
export function TenderProcurementSection({ tender }: TenderProcurementSectionProps) {
|
||||
return (
|
||||
<div
|
||||
id={TENDER_SECTION_IDS.procurement}
|
||||
className={HEADER_SCROLL_MARGIN}
|
||||
>
|
||||
<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-primary underline-offset-4 hover:underline">
|
||||
<Link
|
||||
href={`https://ted.europa.eu/en/notice/-/detail/${tender.notice_publication_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{tender.notice_publication_id}
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TenderNavItem } from "../types";
|
||||
|
||||
export function TenderSectionNavList({
|
||||
items,
|
||||
activeId,
|
||||
onSelect,
|
||||
variant,
|
||||
}: {
|
||||
items: TenderNavItem[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
variant: "rail" | "strip";
|
||||
}) {
|
||||
if (variant === "strip") {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Jump to section"
|
||||
className="no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:-mx-6 sm:px-6"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isActive = activeId === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(item.id)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3.5 py-2 text-xs font-semibold tracking-wide transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
|
||||
isActive
|
||||
? "border-primary/35 bg-primary/10 text-primary shadow-sm dark:border-primary/40 dark:bg-primary/15"
|
||||
: "border-stroke/70 bg-white/80 text-dark-5 hover:border-primary/25 hover:text-dark dark:border-dark-3 dark:bg-dark-2/60 dark:text-dark-6 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="On this page" className="space-y-1">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase tracking-[0.22em] text-dark-5 dark:text-dark-6">
|
||||
On this page
|
||||
</p>
|
||||
<ul className="relative space-y-0.5 border-l border-stroke/60 pl-3 dark:border-dark-3">
|
||||
{items.map((item) => {
|
||||
const isActive = activeId === item.id;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item.id)}
|
||||
className={cn(
|
||||
"group relative flex w-full items-center rounded-lg py-2 pl-2.5 pr-2 text-left text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
|
||||
isActive
|
||||
? "bg-primary/[0.08] text-primary dark:bg-primary/10"
|
||||
: "text-dark-5 hover:bg-gray-2/80 hover:text-dark dark:text-dark-6 dark:hover:bg-dark-3/50 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -left-px top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full transition",
|
||||
isActive
|
||||
? "bg-primary"
|
||||
: "bg-transparent group-hover:bg-stroke dark:group-hover:bg-dark-3",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="line-clamp-2">{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { truncateString, unixToDate } from "@/utils/shared";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
HEADER_SCROLL_MARGIN,
|
||||
SHOWCASE_CENTERED_BODY,
|
||||
TENDER_SECTION_IDS,
|
||||
} from "../constants";
|
||||
import { ExternalLinkIcon } from "./external-link-icon";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
type TenderTimelineSectionProps = {
|
||||
tender: TTenderDetails;
|
||||
};
|
||||
|
||||
export function TenderTimelineSection({ tender }: TenderTimelineSectionProps) {
|
||||
const submissionUrl = tender.submission_url?.trim();
|
||||
|
||||
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}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { HEADER_SCROLL_MARGIN, TENDER_SECTION_IDS } from "../constants";
|
||||
import { SectionHeading } from "./section-heading";
|
||||
|
||||
type TenderTranslationSectionProps = {
|
||||
tender: TTenderDetails;
|
||||
};
|
||||
|
||||
export function TenderTranslationSection({
|
||||
tender,
|
||||
}: TenderTranslationSectionProps) {
|
||||
return (
|
||||
<div id={TENDER_SECTION_IDS.translation} className={HEADER_SCROLL_MARGIN}>
|
||||
<SectionHeading>Translation</SectionHeading>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Matches the tender title `<h1>` in the header — keep in sync for layout probe */
|
||||
export const TENDER_HEADER_TITLE_CLASS =
|
||||
"text-balance text-xl font-bold leading-tight text-dark dark:text-white sm:text-2xl lg:text-[1.65rem]";
|
||||
|
||||
/** Reserved width for the actions column when deciding side‑by‑side vs stacked (lg+) */
|
||||
export const TENDER_HEADER_ACTIONS_RESERVE_PX = 360;
|
||||
|
||||
export const TENDER_SECTION_IDS = {
|
||||
procurement: "tender-section-procurement",
|
||||
timeline: "tender-section-timeline",
|
||||
location: "tender-section-location",
|
||||
translation: "tender-section-translation",
|
||||
description: "tender-section-description",
|
||||
aiSummary: "tender-section-ai-summary",
|
||||
} as const;
|
||||
|
||||
export const HEADER_SCROLL_MARGIN = "scroll-mt-[5.5rem]";
|
||||
|
||||
/** Body area only: center content in the card; ShowcaseSection `title` is unchanged */
|
||||
export 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;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useLayoutEffect, useState, type RefObject } from "react";
|
||||
import { TENDER_HEADER_ACTIONS_RESERVE_PX } from "../constants";
|
||||
|
||||
export function useStackTenderHeaderActions(
|
||||
title: string,
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
probeSizerRef: RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
const [stackBelow, setStackBelow] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const probeSizer = probeSizerRef.current;
|
||||
if (!container || !probeSizer) return;
|
||||
|
||||
const run = () => {
|
||||
if (!window.matchMedia("(min-width: 1024px)").matches) {
|
||||
setStackBelow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = window.getComputedStyle(container);
|
||||
const gapRaw = parseFloat(styles.columnGap || styles.gap);
|
||||
const gap = Number.isFinite(gapRaw) ? gapRaw : 32;
|
||||
const containerW = container.getBoundingClientRect().width;
|
||||
const titleColumnW = Math.max(
|
||||
160,
|
||||
containerW - TENDER_HEADER_ACTIONS_RESERVE_PX - gap,
|
||||
);
|
||||
probeSizer.style.width = `${titleColumnW}px`;
|
||||
|
||||
const probeTitle = probeSizer.querySelector("h1");
|
||||
if (!probeTitle) return;
|
||||
|
||||
const titleStyles = window.getComputedStyle(probeTitle);
|
||||
const lineHeightRaw = parseFloat(titleStyles.lineHeight);
|
||||
const fontSize = parseFloat(titleStyles.fontSize);
|
||||
const lineHeight =
|
||||
Number.isFinite(lineHeightRaw) && lineHeightRaw > 0
|
||||
? lineHeightRaw
|
||||
: fontSize * 1.25;
|
||||
const lines = probeTitle.scrollHeight / lineHeight;
|
||||
const next = lines > 1.35;
|
||||
setStackBelow((p) => (p === next ? p : next));
|
||||
};
|
||||
|
||||
run();
|
||||
const ro = new ResizeObserver(run);
|
||||
ro.observe(container);
|
||||
window.addEventListener("resize", run);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", run);
|
||||
};
|
||||
}, [title, containerRef, probeSizerRef]);
|
||||
|
||||
return stackBelow;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useTenderSectionScrollSpy(sectionIds: string[]) {
|
||||
const [activeId, setActiveId] = useState(sectionIds[0] ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionIds.length === 0) {
|
||||
setActiveId("");
|
||||
return;
|
||||
}
|
||||
setActiveId((prev) =>
|
||||
sectionIds.includes(prev) ? prev : (sectionIds[0] ?? ""),
|
||||
);
|
||||
}, [sectionIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionIds.length === 0) return;
|
||||
|
||||
const pickActive = () => {
|
||||
const offset = 96;
|
||||
const scrollY = window.scrollY;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const documentHeight = Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight,
|
||||
);
|
||||
const maxScroll = Math.max(1, documentHeight - viewportHeight);
|
||||
|
||||
if (scrollY + viewportHeight >= documentHeight - 2) {
|
||||
const last = sectionIds[sectionIds.length - 1] ?? "";
|
||||
setActiveId((prev) => (prev === last ? prev : last));
|
||||
return;
|
||||
}
|
||||
|
||||
const naturalScrolls = sectionIds.map((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return Number.POSITIVE_INFINITY;
|
||||
return Math.max(0, el.getBoundingClientRect().top + scrollY - offset);
|
||||
});
|
||||
|
||||
const activations: number[] = [];
|
||||
let prevAdjusted = 0;
|
||||
for (let i = 0; i < naturalScrolls.length; i++) {
|
||||
const natural = naturalScrolls[i];
|
||||
if (natural <= maxScroll) {
|
||||
activations.push(natural);
|
||||
prevAdjusted = natural;
|
||||
continue;
|
||||
}
|
||||
const remaining = naturalScrolls.length - i;
|
||||
const range = Math.max(0, maxScroll - prevAdjusted);
|
||||
const step = range / remaining;
|
||||
for (let j = 0; j < remaining; j++) {
|
||||
activations.push(prevAdjusted + step * (j + 1));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let current = sectionIds[0] ?? "";
|
||||
for (let i = 0; i < activations.length; i++) {
|
||||
if (activations[i] <= scrollY) current = sectionIds[i];
|
||||
}
|
||||
setActiveId((prev) => (prev === current ? prev : current));
|
||||
};
|
||||
|
||||
pickActive();
|
||||
window.addEventListener("scroll", pickActive, { passive: true });
|
||||
window.addEventListener("resize", pickActive, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", pickActive);
|
||||
window.removeEventListener("resize", pickActive);
|
||||
};
|
||||
}, [sectionIds]);
|
||||
|
||||
return activeId;
|
||||
}
|
||||
@@ -1,36 +1,20 @@
|
||||
"use client";
|
||||
import { LocationIcon } from "@/assets/icons";
|
||||
|
||||
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import Loading from "@/components/loading";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import Status from "@/components/ui/Status";
|
||||
import { Languages } from "@/constants/languages";
|
||||
import {
|
||||
useGetTenderDocumentsQuery,
|
||||
useTenderDetailQuery,
|
||||
useTranslateTenderMutation,
|
||||
} 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 { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { RefObject } from "react";
|
||||
import {
|
||||
use,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { use, useCallback, useMemo } from "react";
|
||||
import { TenderDetailsContent } from "./components/tender-details-content";
|
||||
import { TenderDetailsError } from "./components/tender-details-error";
|
||||
import { TenderDetailsHeader } from "./components/tender-details-header";
|
||||
import { useTenderSectionScrollSpy } from "./hooks/use-tender-section-scroll-spy";
|
||||
import { getTenderNavItems } from "./tender-nav-utils";
|
||||
|
||||
interface IProps {
|
||||
params: Promise<{
|
||||
@@ -38,284 +22,6 @@ interface IProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const TENDER_SECTION_IDS = {
|
||||
procurement: "tender-section-procurement",
|
||||
timeline: "tender-section-timeline",
|
||||
location: "tender-section-location",
|
||||
translation: "tender-section-translation",
|
||||
description: "tender-section-description",
|
||||
aiSummary: "tender-section-ai-summary",
|
||||
} as const;
|
||||
|
||||
type TenderNavItem = { id: string; label: string };
|
||||
|
||||
const HEADER_SCROLL_MARGIN = "scroll-mt-[5.5rem]";
|
||||
|
||||
function useTenderSectionScrollSpy(sectionIds: string[]) {
|
||||
const [activeId, setActiveId] = useState(sectionIds[0] ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionIds.length === 0) {
|
||||
setActiveId("");
|
||||
return;
|
||||
}
|
||||
setActiveId((prev) =>
|
||||
sectionIds.includes(prev) ? prev : (sectionIds[0] ?? ""),
|
||||
);
|
||||
}, [sectionIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionIds.length === 0) return;
|
||||
|
||||
const pickActive = () => {
|
||||
const offset = 96;
|
||||
const scrollY = window.scrollY;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const documentHeight = Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight,
|
||||
);
|
||||
const maxScroll = Math.max(1, documentHeight - viewportHeight);
|
||||
|
||||
// Safety snap: at the very bottom of the page the last section is the
|
||||
// only thing the user can possibly be looking at.
|
||||
if (scrollY + viewportHeight >= documentHeight - 2) {
|
||||
const last = sectionIds[sectionIds.length - 1] ?? "";
|
||||
setActiveId((prev) => (prev === last ? prev : last));
|
||||
return;
|
||||
}
|
||||
|
||||
// Natural scroll position at which each section's heading reaches the
|
||||
// sticky-header offset line (i.e. would normally become "active").
|
||||
const naturalScrolls = sectionIds.map((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return Number.POSITIVE_INFINITY;
|
||||
return Math.max(0, el.getBoundingClientRect().top + scrollY - offset);
|
||||
});
|
||||
|
||||
// On short pages some sections never reach the offset line because the
|
||||
// page can't scroll far enough. Squeeze the unreachable tail evenly into
|
||||
// the remaining scroll range so each section still gets its own slice.
|
||||
const activations: number[] = [];
|
||||
let prevAdjusted = 0;
|
||||
for (let i = 0; i < naturalScrolls.length; i++) {
|
||||
const natural = naturalScrolls[i];
|
||||
if (natural <= maxScroll) {
|
||||
activations.push(natural);
|
||||
prevAdjusted = natural;
|
||||
continue;
|
||||
}
|
||||
const remaining = naturalScrolls.length - i;
|
||||
const range = Math.max(0, maxScroll - prevAdjusted);
|
||||
const step = range / remaining;
|
||||
for (let j = 0; j < remaining; j++) {
|
||||
activations.push(prevAdjusted + step * (j + 1));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let current = sectionIds[0] ?? "";
|
||||
for (let i = 0; i < activations.length; i++) {
|
||||
if (activations[i] <= scrollY) current = sectionIds[i];
|
||||
}
|
||||
setActiveId((prev) => (prev === current ? prev : current));
|
||||
};
|
||||
|
||||
pickActive();
|
||||
window.addEventListener("scroll", pickActive, { passive: true });
|
||||
window.addEventListener("resize", pickActive, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", pickActive);
|
||||
window.removeEventListener("resize", pickActive);
|
||||
};
|
||||
}, [sectionIds]);
|
||||
|
||||
return activeId;
|
||||
}
|
||||
|
||||
function TenderSectionNavList({
|
||||
items,
|
||||
activeId,
|
||||
onSelect,
|
||||
variant,
|
||||
}: {
|
||||
items: TenderNavItem[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
variant: "rail" | "strip";
|
||||
}) {
|
||||
if (variant === "strip") {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Jump to section"
|
||||
className="no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:-mx-6 sm:px-6"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isActive = activeId === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(item.id)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3.5 py-2 text-xs font-semibold tracking-wide transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
|
||||
isActive
|
||||
? "border-primary/35 bg-primary/10 text-primary shadow-sm dark:border-primary/40 dark:bg-primary/15"
|
||||
: "border-stroke/70 bg-white/80 text-dark-5 hover:border-primary/25 hover:text-dark dark:border-dark-3 dark:bg-dark-2/60 dark:text-dark-6 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="On this page" className="space-y-1">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase tracking-[0.22em] text-dark-5 dark:text-dark-6">
|
||||
On this page
|
||||
</p>
|
||||
<ul className="relative space-y-0.5 border-l border-stroke/60 pl-3 dark:border-dark-3">
|
||||
{items.map((item) => {
|
||||
const isActive = activeId === item.id;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item.id)}
|
||||
className={cn(
|
||||
"group relative flex w-full items-center rounded-lg py-2 pl-2.5 pr-2 text-left text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
|
||||
isActive
|
||||
? "bg-primary/[0.08] text-primary dark:bg-primary/10"
|
||||
: "text-dark-5 hover:bg-gray-2/80 hover:text-dark dark:text-dark-6 dark:hover:bg-dark-3/50 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -left-px top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full transition",
|
||||
isActive
|
||||
? "bg-primary"
|
||||
: "bg-transparent group-hover:bg-stroke dark:group-hover:bg-dark-3",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="line-clamp-2">{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches the tender title `<h1>` in the header — keep in sync for layout probe */
|
||||
const TENDER_HEADER_TITLE_CLASS =
|
||||
"text-balance text-xl font-bold leading-tight text-dark dark:text-white sm:text-2xl lg:text-[1.65rem]";
|
||||
|
||||
/** Reserved width for the actions column when deciding side‑by‑side vs stacked (lg+) */
|
||||
const TENDER_HEADER_ACTIONS_RESERVE_PX = 360;
|
||||
|
||||
function useStackTenderHeaderActions(
|
||||
title: string,
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
probeSizerRef: RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
const [stackBelow, setStackBelow] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const probeSizer = probeSizerRef.current;
|
||||
if (!container || !probeSizer) return;
|
||||
|
||||
const run = () => {
|
||||
if (!window.matchMedia("(min-width: 1024px)").matches) {
|
||||
setStackBelow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = window.getComputedStyle(container);
|
||||
const gapRaw = parseFloat(styles.columnGap || styles.gap);
|
||||
const gap = Number.isFinite(gapRaw) ? gapRaw : 32;
|
||||
const containerW = container.getBoundingClientRect().width;
|
||||
const titleColumnW = Math.max(
|
||||
160,
|
||||
containerW - TENDER_HEADER_ACTIONS_RESERVE_PX - gap,
|
||||
);
|
||||
probeSizer.style.width = `${titleColumnW}px`;
|
||||
|
||||
const probeTitle = probeSizer.querySelector("h1");
|
||||
if (!probeTitle) return;
|
||||
|
||||
const titleStyles = window.getComputedStyle(probeTitle);
|
||||
const lineHeightRaw = parseFloat(titleStyles.lineHeight);
|
||||
const fontSize = parseFloat(titleStyles.fontSize);
|
||||
const lineHeight =
|
||||
Number.isFinite(lineHeightRaw) && lineHeightRaw > 0
|
||||
? lineHeightRaw
|
||||
: fontSize * 1.25;
|
||||
const lines = probeTitle.scrollHeight / lineHeight;
|
||||
const next = lines > 1.35;
|
||||
setStackBelow((p) => (p === next ? p : next));
|
||||
};
|
||||
|
||||
run();
|
||||
const ro = new ResizeObserver(run);
|
||||
ro.observe(container);
|
||||
window.addEventListener("resize", run);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", run);
|
||||
};
|
||||
}, [title]);
|
||||
|
||||
return stackBelow;
|
||||
}
|
||||
|
||||
/** 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 router = useRouter();
|
||||
@@ -327,15 +33,7 @@ const TenderDetails = ({ params }: IProps) => {
|
||||
const { data, isLoading, isError, refetch } = useTenderDetailQuery(details, {
|
||||
lang: selectedLanguage,
|
||||
});
|
||||
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" },
|
||||
@@ -343,99 +41,49 @@ const TenderDetails = ({ params }: IProps) => {
|
||||
];
|
||||
|
||||
const tenderForNav = data?.data;
|
||||
const tenderNavItems = useMemo((): TenderNavItem[] => {
|
||||
if (!tenderForNav) return [];
|
||||
const countryNorm = tenderForNav.country_code?.trim().toUpperCase() ?? "";
|
||||
const hasCountry = isValidDisplayCountryCode(countryNorm);
|
||||
const currency = tenderForNav.currency?.trim().toUpperCase() ?? "";
|
||||
const hasCurrency = Boolean(currency);
|
||||
const hasEst =
|
||||
typeof tenderForNav.estimated_value === "number" &&
|
||||
Number.isFinite(tenderForNav.estimated_value) &&
|
||||
tenderForNav.estimated_value > 0;
|
||||
const showLocation = hasCountry || hasCurrency || hasEst;
|
||||
|
||||
const items: TenderNavItem[] = [
|
||||
{ id: TENDER_SECTION_IDS.procurement, label: "Procurement" },
|
||||
{ id: TENDER_SECTION_IDS.timeline, label: "Timeline" },
|
||||
];
|
||||
if (showLocation) {
|
||||
items.push({
|
||||
id: TENDER_SECTION_IDS.location,
|
||||
label: "Location & currency",
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
{ id: TENDER_SECTION_IDS.translation, label: "Translation" },
|
||||
{ id: TENDER_SECTION_IDS.description, label: "Description" },
|
||||
{ id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" },
|
||||
);
|
||||
return items;
|
||||
}, [tenderForNav]);
|
||||
const tenderNavItems = useMemo(
|
||||
() => (tenderForNav ? getTenderNavItems(tenderForNav) : []),
|
||||
[tenderForNav],
|
||||
);
|
||||
|
||||
const sectionIds = useMemo(
|
||||
() => tenderNavItems.map((item) => item.id),
|
||||
[tenderNavItems],
|
||||
);
|
||||
const activeSectionId = useTenderSectionScrollSpy(sectionIds);
|
||||
|
||||
const scrollToTenderSection = useCallback((id: string) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const reduceMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
el.scrollIntoView({
|
||||
behavior: reduceMotion ? "auto" : "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const headerLayoutContainerRef = useRef<HTMLDivElement>(null);
|
||||
const headerTitleProbeSizerRef = useRef<HTMLDivElement>(null);
|
||||
const headerTitleForLayout = data?.data.title ?? "";
|
||||
const stackActionsBelow = useStackTenderHeaderActions(
|
||||
headerTitleForLayout,
|
||||
headerLayoutContainerRef,
|
||||
headerTitleProbeSizerRef,
|
||||
const { data: documents } = useGetTenderDocumentsQuery(details);
|
||||
const handleLanguageChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
const nextLanguage = event.target.value;
|
||||
if (nextLanguage) {
|
||||
nextParams.set("lang", nextLanguage);
|
||||
} else {
|
||||
nextParams.delete("lang");
|
||||
}
|
||||
const queryString = nextParams.toString();
|
||||
router.replace(queryString ? `${pathname}?${queryString}` : pathname);
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const handleTranslate = useCallback(() => {
|
||||
translateTender(
|
||||
{ id: details, language: selectedLanguage },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [details, refetch, selectedLanguage, translateTender]);
|
||||
|
||||
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'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>
|
||||
</>
|
||||
);
|
||||
return <TenderDetailsError breadcrumbItems={breadcrumbItems} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
@@ -443,383 +91,22 @@ const TenderDetails = ({ params }: IProps) => {
|
||||
<ShowcaseSection
|
||||
rootClassName="overflow-clip"
|
||||
title={
|
||||
<div ref={headerLayoutContainerRef} className="relative">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 max-h-0 overflow-hidden opacity-0"
|
||||
aria-hidden
|
||||
>
|
||||
<div ref={headerTitleProbeSizerRef}>
|
||||
<h1 className={TENDER_HEADER_TITLE_CLASS}>{tender.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-5",
|
||||
!stackActionsBelow &&
|
||||
"lg:flex-row lg:items-start lg:justify-between lg:gap-8",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 space-y-2",
|
||||
!stackActionsBelow && "lg:flex-1",
|
||||
stackActionsBelow && "lg:w-full",
|
||||
)}
|
||||
>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||
Tender
|
||||
</p>
|
||||
<h1 className={TENDER_HEADER_TITLE_CLASS}>{tender.title}</h1>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-w-0 shrink-0 flex-col gap-3",
|
||||
!stackActionsBelow && "lg:max-w-2xl lg:items-end",
|
||||
stackActionsBelow && "lg:w-full lg:items-stretch",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] font-semibold uppercase tracking-[0.18em] text-dark-5 dark:text-dark-6 lg:w-full",
|
||||
stackActionsBelow ? "lg:text-start" : "lg:text-end",
|
||||
)}
|
||||
>
|
||||
Language & translation
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center",
|
||||
stackActionsBelow
|
||||
? "sm:justify-start lg:justify-start"
|
||||
: "sm:justify-end lg:justify-end",
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
name="lang"
|
||||
label="Display language"
|
||||
items={Languages}
|
||||
className={cn(
|
||||
"w-full shrink-0 space-y-0 sm:w-[min(100%,11.5rem)]",
|
||||
"[&_label]:sr-only",
|
||||
)}
|
||||
value={selectedLanguage}
|
||||
onChange={async (event) => {
|
||||
const nextParams = new URLSearchParams(
|
||||
searchParams.toString(),
|
||||
);
|
||||
const nextLanguage = event.target.value;
|
||||
if (nextLanguage) {
|
||||
nextParams.set("lang", nextLanguage);
|
||||
} else {
|
||||
nextParams.delete("lang");
|
||||
}
|
||||
const queryString = nextParams.toString();
|
||||
router.replace(
|
||||
queryString ? `${pathname}?${queryString}` : pathname,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
shape="rounded"
|
||||
className="w-full shrink-0 whitespace-nowrap sm:min-h-[3.375rem] sm:w-auto"
|
||||
label={
|
||||
isTranslating ? "Translating..." : "Translate metadata"
|
||||
}
|
||||
disabled={isTranslating}
|
||||
onClick={() => {
|
||||
translateTender(
|
||||
{ id: details, language: selectedLanguage },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex shrink-0 items-center sm:min-h-[3.375rem]">
|
||||
<Status status={tender.status ?? "Unknown"}>
|
||||
{tender.status}
|
||||
</Status>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TenderDetailsHeader
|
||||
title={tender.title}
|
||||
selectedLanguage={selectedLanguage}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
onTranslate={handleTranslate}
|
||||
isTranslating={isTranslating}
|
||||
status={tender.status}
|
||||
/>
|
||||
}
|
||||
className="!p-0"
|
||||
>
|
||||
<div className="px-4 pb-8 pt-2 sm:px-6 sm:pb-10 xl:px-8 xl:pb-10 xl:pt-3 2xl:px-10">
|
||||
<div className="xl:grid xl:grid-cols-[minmax(0,1fr)_13.5rem] xl:items-start xl:gap-10 2xl:grid-cols-[minmax(0,1fr)_15rem] 2xl:gap-12">
|
||||
<div className="min-w-0 space-y-6 xl:space-y-10">
|
||||
<div className="xl:hidden">
|
||||
<div className="rounded-2xl border border-stroke/60 bg-white/70 p-3 shadow-sm backdrop-blur-sm dark:border-dark-3 dark:bg-dark-2/40">
|
||||
<TenderSectionNavList
|
||||
items={tenderNavItems}
|
||||
activeId={activeSectionId}
|
||||
onSelect={scrollToTenderSection}
|
||||
variant="strip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={TENDER_SECTION_IDS.procurement}
|
||||
className={HEADER_SCROLL_MARGIN}
|
||||
>
|
||||
<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-primary underline-offset-4 hover:underline">
|
||||
<Link
|
||||
href={`https://ted.europa.eu/en/notice/-/detail/${tender.notice_publication_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{tender.notice_publication_id}
|
||||
</Link>
|
||||
</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
|
||||
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}
|
||||
>
|
||||
<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
|
||||
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">
|
||||
{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
|
||||
id={TENDER_SECTION_IDS.translation}
|
||||
className={HEADER_SCROLL_MARGIN}
|
||||
>
|
||||
<SectionHeading>Translation</SectionHeading>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="sticky top-24 z-10 mt-2 hidden min-w-0 self-start xl:block">
|
||||
<div className="shadow-theme-xs max-h-[calc(100vh-7rem)] overflow-y-auto rounded-2xl border border-stroke/60 bg-white/80 p-4 backdrop-blur-md dark:border-dark-3 dark:bg-dark-2/50">
|
||||
<TenderSectionNavList
|
||||
items={tenderNavItems}
|
||||
activeId={activeSectionId}
|
||||
onSelect={scrollToTenderSection}
|
||||
variant="rail"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<TenderDetailsContent
|
||||
tender={tender}
|
||||
tenderNavItems={tenderNavItems}
|
||||
activeSectionId={activeSectionId}
|
||||
/>
|
||||
</ShowcaseSection>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export function scrollToTenderSection(id: string) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const reduceMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
el.scrollIntoView({
|
||||
behavior: reduceMotion ? "auto" : "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { TTenderDetails } from "@/lib/api/types/Tenders";
|
||||
import { TENDER_SECTION_IDS } from "./constants";
|
||||
import type { TenderNavItem } from "./types";
|
||||
|
||||
export const isValidDisplayCountryCode = (code: string) =>
|
||||
/^[A-Za-z]{2,3}$/.test(code);
|
||||
|
||||
function analyzeLocationVisibility(tender: TTenderDetails) {
|
||||
const countryNorm = tender.country_code?.trim().toUpperCase() ?? "";
|
||||
const hasCountry = isValidDisplayCountryCode(countryNorm);
|
||||
const currency = tender.currency?.trim().toUpperCase() ?? "";
|
||||
const hasCurrency = Boolean(currency);
|
||||
const hasEst =
|
||||
typeof tender.estimated_value === "number" &&
|
||||
Number.isFinite(tender.estimated_value) &&
|
||||
tender.estimated_value > 0;
|
||||
const showLocation = hasCountry || hasCurrency || hasEst;
|
||||
return { showLocation };
|
||||
}
|
||||
|
||||
export function getTenderNavItems(tender: TTenderDetails): TenderNavItem[] {
|
||||
const { showLocation } = analyzeLocationVisibility(tender);
|
||||
|
||||
const items: TenderNavItem[] = [
|
||||
{ id: TENDER_SECTION_IDS.procurement, label: "Procurement" },
|
||||
{ id: TENDER_SECTION_IDS.timeline, label: "Timeline" },
|
||||
];
|
||||
if (showLocation) {
|
||||
items.push({
|
||||
id: TENDER_SECTION_IDS.location,
|
||||
label: "Location & currency",
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
{ id: TENDER_SECTION_IDS.translation, label: "Translation" },
|
||||
{ id: TENDER_SECTION_IDS.description, label: "Description" },
|
||||
{ id: TENDER_SECTION_IDS.aiSummary, label: "AI summary" },
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function shouldShowLocationSection(tender: TTenderDetails): boolean {
|
||||
return analyzeLocationVisibility(tender).showLocation;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type TenderNavItem = { id: string; label: string };
|
||||
Reference in New Issue
Block a user