refactor(tender-details): enhance tender section navigation and layout
- Introduced a scroll spy feature for tender sections to improve navigation experience. - Added TenderSectionNavList component for better section navigation and user interaction. - Updated MainLayout to adjust main content overflow behavior for improved layout consistency. - Refactored TendersTable to remove unnecessary create button and streamline feedback navigation.
This commit is contained in:
@@ -4,8 +4,8 @@ import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
|||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
import Loading from "@/components/loading";
|
import Loading from "@/components/loading";
|
||||||
import Status from "@/components/ui/Status";
|
|
||||||
import { Button } from "@/components/ui-elements/button";
|
import { Button } from "@/components/ui-elements/button";
|
||||||
|
import Status from "@/components/ui/Status";
|
||||||
import { Languages } from "@/constants/languages";
|
import { Languages } from "@/constants/languages";
|
||||||
import {
|
import {
|
||||||
useTenderDetailQuery,
|
useTenderDetailQuery,
|
||||||
@@ -21,7 +21,16 @@ import {
|
|||||||
import getSymbolFromCurrency from "currency-symbol-map";
|
import getSymbolFromCurrency from "currency-symbol-map";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
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 {
|
interface IProps {
|
||||||
params: Promise<{
|
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 }) {
|
function SectionHeading({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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 (
|
||||||
|
<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 }) {
|
function ExternalLinkIcon({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -62,6 +246,69 @@ function ExternalLinkIcon({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 */
|
/** Body area only: center content in the card; ShowcaseSection `title` is unchanged */
|
||||||
const SHOWCASE_CENTERED_BODY = {
|
const SHOWCASE_CENTERED_BODY = {
|
||||||
rootClassName: "flex h-full min-h-0 flex-col",
|
rootClassName: "flex h-full min-h-0 flex-col",
|
||||||
@@ -82,8 +329,9 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
});
|
});
|
||||||
const countryCodeNormalized =
|
const countryCodeNormalized =
|
||||||
data?.data.country_code?.trim().toUpperCase() ?? "";
|
data?.data.country_code?.trim().toUpperCase() ?? "";
|
||||||
const hasValidCountryCodeForDisplay =
|
const hasValidCountryCodeForDisplay = isValidDisplayCountryCode(
|
||||||
isValidDisplayCountryCode(countryCodeNormalized);
|
countryCodeNormalized,
|
||||||
|
);
|
||||||
const canLoadCountryFlag = isValidAlpha2CountryCode(countryCodeNormalized);
|
const canLoadCountryFlag = isValidAlpha2CountryCode(countryCodeNormalized);
|
||||||
const { data: flagData } = useGetFlagQuery(countryCodeNormalized, {
|
const { data: flagData } = useGetFlagQuery(countryCodeNormalized, {
|
||||||
enabled: canLoadCountryFlag,
|
enabled: canLoadCountryFlag,
|
||||||
@@ -94,6 +342,64 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
{ name: "Tender details", href: `/tenders/${details}` },
|
{ 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<HTMLDivElement>(null);
|
||||||
|
const headerTitleProbeSizerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const headerTitleForLayout = data?.data.title ?? "";
|
||||||
|
const stackActionsBelow = useStackTenderHeaderActions(
|
||||||
|
headerTitleForLayout,
|
||||||
|
headerLayoutContainerRef,
|
||||||
|
headerTitleProbeSizerRef,
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) return <Loading />;
|
if (isLoading) return <Loading />;
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
@@ -135,21 +441,59 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
<>
|
<>
|
||||||
<Breadcrumb items={breadcrumbItems} />
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
<ShowcaseSection
|
<ShowcaseSection
|
||||||
|
rootClassName="overflow-clip"
|
||||||
title={
|
title={
|
||||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
<div ref={headerLayoutContainerRef} className="relative">
|
||||||
<div className="min-w-0 space-y-2">
|
<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">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||||
Tender
|
Tender
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-balance text-xl font-bold leading-tight text-dark dark:text-white sm:text-2xl lg:text-[1.65rem]">
|
<h1 className={TENDER_HEADER_TITLE_CLASS}>{tender.title}</h1>
|
||||||
{tender.title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full min-w-0 shrink-0 flex-col gap-3 lg:max-w-2xl lg:items-end">
|
<div
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-dark-5 dark:text-dark-6 lg:w-full lg:text-end">
|
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
|
Language & translation
|
||||||
</p>
|
</p>
|
||||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
|
<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
|
<Select
|
||||||
name="lang"
|
name="lang"
|
||||||
label="Display language"
|
label="Display language"
|
||||||
@@ -179,8 +523,10 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
size="small"
|
size="small"
|
||||||
shape="rounded"
|
shape="rounded"
|
||||||
className="w-full shrink-0 whitespace-nowrap sm:w-auto sm:min-h-[3.375rem]"
|
className="w-full shrink-0 whitespace-nowrap sm:min-h-[3.375rem] sm:w-auto"
|
||||||
label={isTranslating ? "Translating..." : "Translate metadata"}
|
label={
|
||||||
|
isTranslating ? "Translating..." : "Translate metadata"
|
||||||
|
}
|
||||||
disabled={isTranslating}
|
disabled={isTranslating}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
translateTender(
|
translateTender(
|
||||||
@@ -198,28 +544,37 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
{tender.status}
|
{tender.status}
|
||||||
</Status>
|
</Status>
|
||||||
</div>
|
</div>
|
||||||
{submissionUrl && (
|
</div>
|
||||||
<Link
|
|
||||||
href={submissionUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="shadow-theme-xs inline-flex min-h-[3.375rem] w-full shrink-0 items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:bg-primary/90 hover:shadow-md sm:w-auto"
|
|
||||||
>
|
|
||||||
<span>Open submission</span>
|
|
||||||
<ExternalLinkIcon className="opacity-90" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
className="!p-0"
|
className="!p-0"
|
||||||
>
|
>
|
||||||
<div className="space-y-10 px-4 pb-8 pt-2 sm:px-6 sm:pb-10 xl:px-10">
|
<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>
|
<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>
|
<SectionHeading>Procurement</SectionHeading>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||||
<ShowcaseSection title="Tender ID" {...SHOWCASE_CENTERED_BODY}>
|
<ShowcaseSection
|
||||||
|
title="Tender ID"
|
||||||
|
{...SHOWCASE_CENTERED_BODY}
|
||||||
|
>
|
||||||
<span className="font-medium text-dark dark:text-white">
|
<span className="font-medium text-dark dark:text-white">
|
||||||
{tender.tender_id}
|
{tender.tender_id}
|
||||||
</span>
|
</span>
|
||||||
@@ -240,8 +595,14 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
title="Notice publication ID"
|
title="Notice publication ID"
|
||||||
{...SHOWCASE_CENTERED_BODY}
|
{...SHOWCASE_CENTERED_BODY}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-dark dark:text-white">
|
<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}
|
{tender.notice_publication_id}
|
||||||
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
<ShowcaseSection
|
<ShowcaseSection
|
||||||
@@ -263,7 +624,10 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div
|
||||||
|
id={TENDER_SECTION_IDS.timeline}
|
||||||
|
className={HEADER_SCROLL_MARGIN}
|
||||||
|
>
|
||||||
<SectionHeading>Timeline</SectionHeading>
|
<SectionHeading>Timeline</SectionHeading>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||||
<ShowcaseSection
|
<ShowcaseSection
|
||||||
@@ -330,9 +694,12 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasValidLocationCurrencyOrValue && (
|
{hasValidLocationCurrencyOrValue && (
|
||||||
<div>
|
<div
|
||||||
|
id={TENDER_SECTION_IDS.location}
|
||||||
|
className={HEADER_SCROLL_MARGIN}
|
||||||
|
>
|
||||||
<SectionHeading>Location & currency</SectionHeading>
|
<SectionHeading>Location & currency</SectionHeading>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:max-w-5xl">
|
<div className="grid gap-4 sm:grid-cols-2 lg:max-w-5xl lg:grid-cols-3">
|
||||||
{hasValidCountryCodeForDisplay && (
|
{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="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" />
|
<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" />
|
||||||
@@ -383,7 +750,9 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-2xl font-bold tabular-nums text-dark dark:text-white">
|
<span className="text-2xl font-bold tabular-nums text-dark dark:text-white">
|
||||||
{new Intl.NumberFormat().format(tender.estimated_value)}
|
{new Intl.NumberFormat().format(
|
||||||
|
tender.estimated_value,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{hasValidCurrency && (
|
{hasValidCurrency && (
|
||||||
<span className="text-sm font-medium uppercase text-dark-5 dark:text-dark-6">
|
<span className="text-sm font-medium uppercase text-dark-5 dark:text-dark-6">
|
||||||
@@ -397,7 +766,10 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div
|
||||||
|
id={TENDER_SECTION_IDS.translation}
|
||||||
|
className={HEADER_SCROLL_MARGIN}
|
||||||
|
>
|
||||||
<SectionHeading>Translation</SectionHeading>
|
<SectionHeading>Translation</SectionHeading>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<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">
|
<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">
|
||||||
@@ -411,7 +783,10 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div
|
||||||
|
id={TENDER_SECTION_IDS.description}
|
||||||
|
className={HEADER_SCROLL_MARGIN}
|
||||||
|
>
|
||||||
<SectionHeading>Description</SectionHeading>
|
<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">
|
<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">
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-dark dark:text-white/90">
|
||||||
@@ -420,7 +795,10 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div
|
||||||
|
id={TENDER_SECTION_IDS.aiSummary}
|
||||||
|
className={HEADER_SCROLL_MARGIN}
|
||||||
|
>
|
||||||
<SectionHeading>AI summary</SectionHeading>
|
<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">
|
<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">
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-dark dark:text-white/90">
|
||||||
@@ -429,6 +807,19 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function MainLayout({ children }: PropsWithChildren) {
|
|||||||
{isMobile && <div className="w-[70px] shrink-0" />}
|
{isMobile && <div className="w-[70px] shrink-0" />}
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-gray-2 dark:bg-[#020d1a]">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-gray-2 dark:bg-[#020d1a]">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="mx-auto isolate flex min-h-0 w-full max-w-screen-2xl flex-1 flex-col overflow-hidden px-3 py-4 md:px-4 md:py-6 2xl:px-6 2xl:py-4">
|
<main className="isolate mx-auto flex min-h-0 w-full max-w-screen-2xl flex-1 flex-col overflow-x-clip px-3 py-4 md:px-4 md:py-6 2xl:px-6 2xl:py-4">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const TendersTable = () => {
|
|||||||
<ListHeader
|
<ListHeader
|
||||||
onFilter={() => setIsFilterModalOpen(true)}
|
onFilter={() => setIsFilterModalOpen(true)}
|
||||||
createButtonText="Create Tender"
|
createButtonText="Create Tender"
|
||||||
|
hasCreate={false}
|
||||||
/>
|
/>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -142,7 +143,9 @@ const TendersTable = () => {
|
|||||||
const queryString = params?.lang
|
const queryString = params?.lang
|
||||||
? `?lang=${encodeURIComponent(params.lang)}`
|
? `?lang=${encodeURIComponent(params.lang)}`
|
||||||
: "";
|
: "";
|
||||||
router.push(`${pathName}/feedback/${item.id}${queryString}`);
|
router.push(
|
||||||
|
`${pathName}/feedback/${item.id}${queryString}`,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
|
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
|
||||||
|
|||||||
Reference in New Issue
Block a user