From f397158a296207490339df5132aef9ba96bff03b Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Mon, 11 May 2026 16:11:09 +0330 Subject: [PATCH] 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. --- .../e2e/tenders/tender-translation-page.cy.ts | 40 +- .../components/external-link-icon.tsx | 20 + .../[details]/components/section-heading.tsx | 13 + .../components/tender-ai-summary-section.tsx | 22 + .../components/tender-description-section.tsx | 22 + .../components/tender-details-content.tsx | 64 ++ .../components/tender-details-error.tsx | 34 + .../components/tender-details-header.tsx | 125 +++ .../components/tender-location-section.tsx | 104 +++ .../components/tender-procurement-section.tsx | 65 ++ .../components/tender-section-nav-list.tsx | 80 ++ .../components/tender-timeline-section.tsx | 72 ++ .../components/tender-translation-section.tsx | 27 + src/app/tenders/[details]/constants.ts | 24 + .../hooks/use-stack-tender-header-actions.ts | 58 ++ .../hooks/use-tender-section-scroll-spy.ts | 77 ++ src/app/tenders/[details]/page.tsx | 817 ++---------------- .../tenders/[details]/scroll-to-section.ts | 11 + src/app/tenders/[details]/tender-nav-utils.ts | 44 + src/app/tenders/[details]/types.ts | 1 + .../FormElements/DatePicker/DatePicker.tsx | 15 +- .../companies/company-categories/index.tsx | 25 +- .../useCompanyCategoriesPresenter.ts | 10 +- src/components/Tables/companies/index.tsx | 25 +- .../companies/useCompanyListPresenter.ts | 43 +- .../Tables/tenders/TenderListFilters.tsx | 104 ++- src/components/Tables/tenders/index.tsx | 2 + .../Tables/tenders/useTenderListPresenter.ts | 110 ++- src/components/ui/modal.tsx | 7 +- src/constants/Api_Params.ts | 2 +- src/css/style.css | 75 +- src/hooks/queries/useCompaniesQueries.ts | 11 + src/hooks/queries/useTenders.ts | 10 + src/lib/api/endpoints.ts | 2 + src/lib/api/services/tenders-service.ts | 10 + src/lib/api/types/Tenders.ts | 8 + 36 files changed, 1325 insertions(+), 854 deletions(-) create mode 100644 src/app/tenders/[details]/components/external-link-icon.tsx create mode 100644 src/app/tenders/[details]/components/section-heading.tsx create mode 100644 src/app/tenders/[details]/components/tender-ai-summary-section.tsx create mode 100644 src/app/tenders/[details]/components/tender-description-section.tsx create mode 100644 src/app/tenders/[details]/components/tender-details-content.tsx create mode 100644 src/app/tenders/[details]/components/tender-details-error.tsx create mode 100644 src/app/tenders/[details]/components/tender-details-header.tsx create mode 100644 src/app/tenders/[details]/components/tender-location-section.tsx create mode 100644 src/app/tenders/[details]/components/tender-procurement-section.tsx create mode 100644 src/app/tenders/[details]/components/tender-section-nav-list.tsx create mode 100644 src/app/tenders/[details]/components/tender-timeline-section.tsx create mode 100644 src/app/tenders/[details]/components/tender-translation-section.tsx create mode 100644 src/app/tenders/[details]/constants.ts create mode 100644 src/app/tenders/[details]/hooks/use-stack-tender-header-actions.ts create mode 100644 src/app/tenders/[details]/hooks/use-tender-section-scroll-spy.ts create mode 100644 src/app/tenders/[details]/scroll-to-section.ts create mode 100644 src/app/tenders/[details]/tender-nav-utils.ts create mode 100644 src/app/tenders/[details]/types.ts diff --git a/cypress/e2e/tenders/tender-translation-page.cy.ts b/cypress/e2e/tenders/tender-translation-page.cy.ts index ec6bcf5..ee1d79d 100644 --- a/cypress/e2e/tenders/tender-translation-page.cy.ts +++ b/cypress/e2e/tenders/tender-translation-page.cy.ts @@ -2,6 +2,9 @@ const tenderId = "507f1f77bcf86cd799439055"; +/** Visible title only — layout probe duplicates `

` off-screen for measurement */ +const tenderTitleHeading = () => cy.get("main h1").filter(":visible"); + const makeTenderDetailsResponse = ( overrides: Partial> = {}, ) => ({ @@ -54,12 +57,18 @@ describe("tender details - translation and summary", () => { cy.visit(`/tenders/${tenderId}?lang=en`); cy.wait("@getTenderDetailsEn"); - cy.contains("h1", "Translated title EN").should("be.visible"); - cy.contains("Resolved language").should("be.visible"); - cy.contains("en").should("be.visible"); - cy.contains("AI summary").should("be.visible"); - cy.contains("Summary not ready yet").should("be.visible"); - cy.contains("Translated description EN").should("be.visible"); + tenderTitleHeading().should("contain.text", "Translated title EN"); + cy.get("#tender-section-translation") + .should("be.visible") + .and("contain.text", "Resolved language") + .and("contain.text", "en"); + cy.get("#tender-section-ai-summary") + .should("be.visible") + .and("contain.text", "AI summary") + .and("contain.text", "Summary not ready yet"); + cy.get("#tender-section-description") + .should("be.visible") + .and("contain.text", "Translated description EN"); }); it("switches language from selector and reloads details with new lang query", () => { @@ -83,7 +92,7 @@ describe("tender details - translation and summary", () => { cy.get('select[name="lang"]').select("fr"); cy.location("search").should("include", "lang=fr"); cy.wait("@getTenderDetailsFr"); - cy.contains("h1", "Titre FR").should("be.visible"); + tenderTitleHeading().should("contain.text", "Titre FR"); cy.contains("Resume FR").should("be.visible"); }); @@ -124,22 +133,19 @@ describe("tender details - translation and summary", () => { cy.wait("@translateTender"); }); - it("redirects legacy /translation URL to tender details with lang preserved", () => { + it("loads tender details with lang query on the canonical URL", () => { cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, { statusCode: 200, body: makeTenderDetailsResponse({ - title: "Redirect check", + title: "Lang query check", language: "fa", }), - }).as("getTenderAfterRedirect"); + }).as("getTenderWithLang"); - cy.visit(`/tenders/${tenderId}/translation?lang=fa`); - cy.location("pathname", { timeout: 15000 }).should( - "eq", - `/tenders/${tenderId}`, - ); + cy.visit(`/tenders/${tenderId}?lang=fa`); + cy.location("pathname").should("eq", `/tenders/${tenderId}`); cy.location("search").should("include", "lang=fa"); - cy.wait("@getTenderAfterRedirect"); - cy.contains("h1", "Redirect check").should("be.visible"); + cy.wait("@getTenderWithLang"); + tenderTitleHeading().should("contain.text", "Lang query check"); }); }); diff --git a/src/app/tenders/[details]/components/external-link-icon.tsx b/src/app/tenders/[details]/components/external-link-icon.tsx new file mode 100644 index 0000000..5af8cde --- /dev/null +++ b/src/app/tenders/[details]/components/external-link-icon.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/lib/utils"; + +export function ExternalLinkIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/app/tenders/[details]/components/section-heading.tsx b/src/app/tenders/[details]/components/section-heading.tsx new file mode 100644 index 0000000..729eaf9 --- /dev/null +++ b/src/app/tenders/[details]/components/section-heading.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +export function SectionHeading({ children }: { children: ReactNode }) { + return ( +
+ +

+ {children} +

+ +
+ ); +} diff --git a/src/app/tenders/[details]/components/tender-ai-summary-section.tsx b/src/app/tenders/[details]/components/tender-ai-summary-section.tsx new file mode 100644 index 0000000..f8c67ec --- /dev/null +++ b/src/app/tenders/[details]/components/tender-ai-summary-section.tsx @@ -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 ( +
+ AI summary +
+

+ {tender.overall_summary || "Summary not ready yet"} +

+
+
+ ); +} diff --git a/src/app/tenders/[details]/components/tender-description-section.tsx b/src/app/tenders/[details]/components/tender-description-section.tsx new file mode 100644 index 0000000..91d5f83 --- /dev/null +++ b/src/app/tenders/[details]/components/tender-description-section.tsx @@ -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 ( +
+ Description +
+

+ {tender.description || "—"} +

+
+
+ ); +} diff --git a/src/app/tenders/[details]/components/tender-details-content.tsx b/src/app/tenders/[details]/components/tender-details-content.tsx new file mode 100644 index 0000000..58ccd5c --- /dev/null +++ b/src/app/tenders/[details]/components/tender-details-content.tsx @@ -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 ( +
+
+
+
+
+ +
+
+ + + + + {showLocation && } + + + + +
+ + +
+
+ ); +} diff --git a/src/app/tenders/[details]/components/tender-details-error.tsx b/src/app/tenders/[details]/components/tender-details-error.tsx new file mode 100644 index 0000000..762d6f2 --- /dev/null +++ b/src/app/tenders/[details]/components/tender-details-error.tsx @@ -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 ( + <> + + +
+

+ We couldn't load this tender +

+

+ Check your connection or try again. You can go back to the tender + list anytime. +

+ + Back to tenders + +
+
+ + ); +} diff --git a/src/app/tenders/[details]/components/tender-details-header.tsx b/src/app/tenders/[details]/components/tender-details-header.tsx new file mode 100644 index 0000000..c7e01a4 --- /dev/null +++ b/src/app/tenders/[details]/components/tender-details-header.tsx @@ -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>["onChange"] +>; + +type TenderDetailsHeaderProps = { + title: string; + selectedLanguage: string; + onLanguageChange: (event: React.ChangeEvent) => void; + onTranslate: () => void; + isTranslating: boolean; + status: string | undefined; +}; + +export function TenderDetailsHeader({ + title, + selectedLanguage, + onLanguageChange, + onTranslate, + isTranslating, + status, +}: TenderDetailsHeaderProps) { + const headerLayoutContainerRef = useRef(null); + const headerTitleProbeSizerRef = useRef(null); + const stackActionsBelow = useStackTenderHeaderActions( + title, + headerLayoutContainerRef, + headerTitleProbeSizerRef, + ); + + return ( +
+
+
+

{title}

+
+
+
+
+

+ Tender +

+

{title}

+
+
+

+ Language & translation +

+
+ + 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} + /> +
+
+
+
+ ); +} diff --git a/src/app/tenders/[details]/components/tender-location-section.tsx b/src/app/tenders/[details]/components/tender-location-section.tsx new file mode 100644 index 0000000..93e1493 --- /dev/null +++ b/src/app/tenders/[details]/components/tender-location-section.tsx @@ -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 ( +
+ Location & currency +
+ {hasValidCountryCodeForDisplay && ( +
+
+

+ Country +

+
+ + + +
+ + {countryCodeNormalized} + + {flagData && ( +
+ )} +
+
+
+ )} + + {hasValidCurrency && ( +
+
+

+ Currency +

+
+ + {currencyCode} + + + {getSymbolFromCurrency(currencyCode || "USD")} + +
+
+ )} + + {hasValidEstimatedValue && ( +
+
+

+ Estimated value +

+
+ + {new Intl.NumberFormat().format(tender.estimated_value)} + + {hasValidCurrency && ( + + {currencyCode} + + )} +
+
+ )} +
+
+ ); +} diff --git a/src/app/tenders/[details]/components/tender-procurement-section.tsx b/src/app/tenders/[details]/components/tender-procurement-section.tsx new file mode 100644 index 0000000..5de4c15 --- /dev/null +++ b/src/app/tenders/[details]/components/tender-procurement-section.tsx @@ -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 ( +
+ Procurement +
+ + + {tender.tender_id} + + + + + {tender.buyer_organization.name} + + + + + + {tender.notice_publication_id} + + + + + + {tender.procedure_code} + + + + + {tender.procurement_type_code} + + +
+
+ ); +} diff --git a/src/app/tenders/[details]/components/tender-section-nav-list.tsx b/src/app/tenders/[details]/components/tender-section-nav-list.tsx new file mode 100644 index 0000000..c571bbf --- /dev/null +++ b/src/app/tenders/[details]/components/tender-section-nav-list.tsx @@ -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 ( + + ); + } + + return ( + + ); +} diff --git a/src/app/tenders/[details]/components/tender-timeline-section.tsx b/src/app/tenders/[details]/components/tender-timeline-section.tsx new file mode 100644 index 0000000..3e33640 --- /dev/null +++ b/src/app/tenders/[details]/components/tender-timeline-section.tsx @@ -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 ( +
+ Timeline +
+ + + + + + + + + + + + + {submissionUrl && ( + + + + {truncateString(submissionUrl, 42)} + + + )} +
+
+ ); +} diff --git a/src/app/tenders/[details]/components/tender-translation-section.tsx b/src/app/tenders/[details]/components/tender-translation-section.tsx new file mode 100644 index 0000000..ad9fd92 --- /dev/null +++ b/src/app/tenders/[details]/components/tender-translation-section.tsx @@ -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 ( +
+ Translation +
+
+

+ Resolved language +

+

+ {tender.language || "original"} +

+
+
+
+ ); +} diff --git a/src/app/tenders/[details]/constants.ts b/src/app/tenders/[details]/constants.ts new file mode 100644 index 0000000..a843253 --- /dev/null +++ b/src/app/tenders/[details]/constants.ts @@ -0,0 +1,24 @@ +/** Matches the tender title `

` 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; diff --git a/src/app/tenders/[details]/hooks/use-stack-tender-header-actions.ts b/src/app/tenders/[details]/hooks/use-stack-tender-header-actions.ts new file mode 100644 index 0000000..e256fcb --- /dev/null +++ b/src/app/tenders/[details]/hooks/use-stack-tender-header-actions.ts @@ -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, + probeSizerRef: RefObject, +) { + 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; +} diff --git a/src/app/tenders/[details]/hooks/use-tender-section-scroll-spy.ts b/src/app/tenders/[details]/hooks/use-tender-section-scroll-spy.ts new file mode 100644 index 0000000..32f5a6e --- /dev/null +++ b/src/app/tenders/[details]/hooks/use-tender-section-scroll-spy.ts @@ -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; +} diff --git a/src/app/tenders/[details]/page.tsx b/src/app/tenders/[details]/page.tsx index 7faf4d0..850f903 100644 --- a/src/app/tenders/[details]/page.tsx +++ b/src/app/tenders/[details]/page.tsx @@ -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 ( -
- -

- {children} -

- -
- ); -} - -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 ( - - ); - } - - return ( - - ); -} - -function ExternalLinkIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -/** Matches the tender title `

` 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, - probeSizerRef: RefObject, -) { - 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(null); - const headerTitleProbeSizerRef = useRef(null); - const headerTitleForLayout = data?.data.title ?? ""; - const stackActionsBelow = useStackTenderHeaderActions( - headerTitleForLayout, - headerLayoutContainerRef, - headerTitleProbeSizerRef, + const { data: documents } = useGetTenderDocumentsQuery(details); + const handleLanguageChange = useCallback( + (event: React.ChangeEvent) => { + 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 ; if (isError) { - return ( - <> - - -
-

- We couldn't load this tender -

-

- Check your connection or try again. You can go back to the tender - list anytime. -

- - Back to tenders - -
-
- - ); + return ; } 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) => { -
-
-

{tender.title}

-
-
-
-
-

- Tender -

-

{tender.title}

-
-
-

- Language & translation -

-
- setValue("status", undefined)} /> + setValue("lang", undefined)} @@ -83,10 +171,10 @@ const TenderListFilters = ({ className="mt-6 w-fit capitalize" label={ isMutating ? ( -
- ) : ( - Search - ) +
+ ) : ( + Search + ) } />