diff --git a/src/app/tenders/[details]/page.tsx b/src/app/tenders/[details]/page.tsx index d50b759..7faf4d0 100644 --- a/src/app/tenders/[details]/page.tsx +++ b/src/app/tenders/[details]/page.tsx @@ -4,8 +4,8 @@ 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 Status from "@/components/ui/Status"; import { Button } from "@/components/ui-elements/button"; +import Status from "@/components/ui/Status"; import { Languages } from "@/constants/languages"; import { useTenderDetailQuery, @@ -21,7 +21,16 @@ import { import getSymbolFromCurrency from "currency-symbol-map"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { use } from "react"; +import type { RefObject } from "react"; +import { + use, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; interface IProps { params: Promise<{ @@ -29,7 +38,8 @@ interface IProps { }>; } -const isValidDisplayCountryCode = (code: string) => /^[A-Za-z]{2,3}$/.test(code); +const isValidDisplayCountryCode = (code: string) => + /^[A-Za-z]{2,3}$/.test(code); function SectionHeading({ children }: { children: React.ReactNode }) { return ( @@ -43,6 +53,180 @@ function SectionHeading({ children }: { children: React.ReactNode }) { ); } +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 ( ` 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", @@ -82,8 +329,9 @@ const TenderDetails = ({ params }: IProps) => { }); const countryCodeNormalized = data?.data.country_code?.trim().toUpperCase() ?? ""; - const hasValidCountryCodeForDisplay = - isValidDisplayCountryCode(countryCodeNormalized); + const hasValidCountryCodeForDisplay = isValidDisplayCountryCode( + countryCodeNormalized, + ); const canLoadCountryFlag = isValidAlpha2CountryCode(countryCodeNormalized); const { data: flagData } = useGetFlagQuery(countryCodeNormalized, { enabled: canLoadCountryFlag, @@ -94,6 +342,64 @@ const TenderDetails = ({ params }: IProps) => { { name: "Tender details", href: `/tenders/${details}` }, ]; + 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 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, + ); + if (isLoading) return ; if (isError) { return ( @@ -135,298 +441,383 @@ const TenderDetails = ({ params }: IProps) => { <> -
-

- Tender -

-

- {tender.title} -

+
+
+
+

{tender.title}

+
-
-

- Language & translation -

-
- { + 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, + ); + }} + /> +
} className="!p-0" > -
-
- Procurement -
- - - {tender.tender_id} - - - - - {tender.buyer_organization.name} - - - - - {tender.notice_publication_id} - - - - - {tender.procedure_code} - - - - - {tender.procurement_type_code} - - -
-
+
+
+
+
+
+ +
+
-
- Timeline -
- - - - - - - - - - - - - {submissionUrl && ( - - Procurement +
+ - - {truncateString(submissionUrl, 42)} - - - )} -
-
+ + {tender.tender_id} + + + + + {tender.buyer_organization.name} + + + + + + {tender.notice_publication_id} + + + + + + {tender.procedure_code} + + + + + {tender.procurement_type_code} + + +
+
- {hasValidLocationCurrencyOrValue && ( -
- Location & currency -
- {hasValidCountryCodeForDisplay && ( -
-
-

- Country -

-
- - - -
- - {countryCodeNormalized} - - {flagData && ( -
- )} +
+ Timeline +
+ + + + + + + + + + + + + {submissionUrl && ( + + + + {truncateString(submissionUrl, 42)} + + + )} +
+
+ + {hasValidLocationCurrencyOrValue && ( +
+ Location & currency +
+ {hasValidCountryCodeForDisplay && ( +
+
+

+ Country +

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

- Currency -

-
- - {currencyCode} - - - {getSymbolFromCurrency(currencyCode || "USD")} - -
-
- )} + {hasValidCurrency && ( +
+
+

+ Currency +

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

- Estimated value -

-
- - {new Intl.NumberFormat().format(tender.estimated_value)} - - {hasValidCurrency && ( - - {currencyCode} - - )} -
+ {hasValidEstimatedValue && ( +
+
+

+ Estimated value +

+
+ + {new Intl.NumberFormat().format( + tender.estimated_value, + )} + + {hasValidCurrency && ( + + {currencyCode} + + )} +
+
+ )}
- )} +
+ )} + +
+ Translation +
+
+

+ Resolved language +

+

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

+
+
+
+ +
+ Description +
+

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

+
+
+ +
+ AI summary +
+

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

+
- )} -
- Translation -
-
-

- Resolved language -

-

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

+
-
- -
- Description -
-

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

-
-
- -
- AI summary -
-

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

-
+
diff --git a/src/components/Layouts/conditional-layout.tsx b/src/components/Layouts/conditional-layout.tsx index 7a33055..589a1ec 100644 --- a/src/components/Layouts/conditional-layout.tsx +++ b/src/components/Layouts/conditional-layout.tsx @@ -18,7 +18,7 @@ function MainLayout({ children }: PropsWithChildren) { {isMobile &&
}
-
+
{children}
diff --git a/src/components/Tables/tenders/index.tsx b/src/components/Tables/tenders/index.tsx index 8ae7fd4..07c4dca 100644 --- a/src/components/Tables/tenders/index.tsx +++ b/src/components/Tables/tenders/index.tsx @@ -50,6 +50,7 @@ const TendersTable = () => { setIsFilterModalOpen(true)} createButtonText="Create Tender" + hasCreate={false} /> @@ -142,7 +143,9 @@ const TendersTable = () => { const queryString = params?.lang ? `?lang=${encodeURIComponent(params.lang)}` : ""; - router.push(`${pathName}/feedback/${item.id}${queryString}`); + router.push( + `${pathName}/feedback/${item.id}${queryString}`, + ); }} >