feat(dashboard): add scrape portals widget to dashboard
continuous-integration/drone/push Build is passing

Surface AI pipeline portal sources on the dashboard instead of a standalone page.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
AmirReza Jamali
2026-06-23 10:22:58 +03:30
parent 31d80cd6ae
commit 9202f9a271
9 changed files with 174 additions and 8 deletions
@@ -4,6 +4,7 @@ import { ClosingSoonCard } from "./closing-soon";
import { CountryDistribution } from "./country-distribution";
import { DashboardHero } from "./hero";
import { NoticeTypeBreakdown } from "./notice-types";
import { ScrapePortals } from "./portals";
import { RecentTenders } from "./recent-tenders";
import { StatisticsSection } from "./statistics-section";
import { TenderStatCards } from "./stat-cards";
@@ -19,11 +20,11 @@ export function TenderDashboard() {
totalValue,
valueCurrency,
countries,
noticeTypes,
trend,
recent,
recentIsLoading,
closingSoonList,
portals,
portalsIsLoading,
} = useDashboardData();
return (
@@ -63,9 +64,12 @@ export function TenderDashboard() {
<ClosingSoonCard tenders={closingSoonList} isLoading={isPending} />
</div> */}
<div className="col-span-12">
<div className="col-span-12 xl:col-span-8">
<RecentTenders tenders={recent} isLoading={recentIsLoading} />
</div>
<div className="col-span-12 xl:col-span-4">
<ScrapePortals portals={portals} isLoading={portalsIsLoading} />
</div>
</div>
<StatisticsSection />
@@ -0,0 +1,113 @@
"use client";
import { TPortal } from "@/lib/api";
import { truncateString } from "@/utils/shared";
import gsap from "gsap";
import { useLayoutEffect, useRef } from "react";
import { GlobeIcon } from "./icons";
type Props = {
portals: TPortal[];
isLoading: boolean;
};
export function ScrapePortals({ portals, isLoading }: Props) {
const rootRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => {
if (reduced) return;
gsap.from(root, {
opacity: 0,
y: 24,
duration: 0.6,
ease: "power3.out",
delay: 0.35,
});
const rows = root.querySelectorAll("[data-portal-row]");
if (rows.length > 0) {
gsap.from(rows, {
x: 24,
opacity: 0,
duration: 0.5,
stagger: 0.06,
delay: 0.55,
ease: "power2.out",
});
}
}, root);
return () => ctx.revert();
}, [portals.length, isLoading]);
return (
<div
ref={rootRef}
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:shadow-card dark:ring-stroke-dark"
>
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
<div className="flex items-center gap-2">
<span className="inline-flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<GlobeIcon className="size-5" />
</span>
<div>
<h2 className="text-base font-bold text-dark dark:text-white">
Scrape Portals
</h2>
<p className="text-xs text-dark-6">
Tender sources monitored by the pipeline
</p>
</div>
</div>
{!isLoading && portals.length > 0 && (
<span className="rounded-full bg-gray-2 px-2.5 py-1 text-xs font-semibold tabular-nums text-dark-5 dark:bg-dark-3 dark:text-dark-6">
{portals.length}
</span>
)}
</div>
<ul className="max-h-[420px] flex-1 divide-y divide-stroke overflow-y-auto dark:divide-stroke-dark">
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<li
key={i}
className="flex animate-pulse items-center gap-3 px-5 py-3.5"
>
<div className="size-8 rounded-lg bg-gray-2 dark:bg-dark-3" />
<div className="h-3 flex-1 rounded bg-gray-2 dark:bg-dark-3" />
</li>
))
: portals.map((portal, index) => (
<li key={`${portal}-${index}`} data-portal-row>
<p
rel="noopener noreferrer"
className="group flex items-center gap-3 px-5 py-3.5 transition-colors hover:bg-gray-2 dark:hover:bg-dark-2"
>
<span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-bold text-primary">
{index + 1}
</span>
<span className="min-w-0 flex-1 text-sm font-medium text-dark transition-colors group-hover:text-primary dark:text-white">
{portal.length > 60
? truncateString(portal, 28, 28)
: portal}
</span>
</p>
</li>
))}
{!isLoading && portals.length === 0 && (
<li className="px-5 py-10 text-center text-sm text-dark-6">
No portals configured.
</li>
)}
</ul>
</div>
);
}
@@ -8,6 +8,7 @@ import {
useDashboardTrendQuery,
useTendersQuery,
} from "@/hooks/queries";
import { useGetPortalsQuery } from "@/hooks/queries/usePortalQueries";
import { useMemo } from "react";
import { toLabeledPoints } from "./to-labeled-points";
@@ -19,6 +20,7 @@ export function useDashboardData() {
const countriesQuery = useDashboardCountriesQuery({ limit: 6 });
const noticeTypesQuery = useDashboardNoticeTypesQuery();
const closingSoonQuery = useDashboardClosingSoonQuery({ limit: 5 });
const portalsQuery = useGetPortalsQuery();
const {
data: recentData,
@@ -87,6 +89,8 @@ export function useDashboardData() {
const recent = recentData?.data?.tenders ?? [];
const recentIsLoading = recentPending && recent.length === 0;
const portals = portalsQuery.data?.data ?? [];
const portalsIsLoading = portalsQuery.isPending && portals.length === 0;
return {
isPending,
@@ -104,5 +108,7 @@ export function useDashboardData() {
recent,
recentIsLoading,
closingSoonList,
portals,
portalsIsLoading,
};
}