feat(dashboard): add scrape portals widget to dashboard
continuous-integration/drone/push Build is passing
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:
@@ -4,6 +4,7 @@ import { ClosingSoonCard } from "./closing-soon";
|
|||||||
import { CountryDistribution } from "./country-distribution";
|
import { CountryDistribution } from "./country-distribution";
|
||||||
import { DashboardHero } from "./hero";
|
import { DashboardHero } from "./hero";
|
||||||
import { NoticeTypeBreakdown } from "./notice-types";
|
import { NoticeTypeBreakdown } from "./notice-types";
|
||||||
|
import { ScrapePortals } from "./portals";
|
||||||
import { RecentTenders } from "./recent-tenders";
|
import { RecentTenders } from "./recent-tenders";
|
||||||
import { StatisticsSection } from "./statistics-section";
|
import { StatisticsSection } from "./statistics-section";
|
||||||
import { TenderStatCards } from "./stat-cards";
|
import { TenderStatCards } from "./stat-cards";
|
||||||
@@ -19,11 +20,11 @@ export function TenderDashboard() {
|
|||||||
totalValue,
|
totalValue,
|
||||||
valueCurrency,
|
valueCurrency,
|
||||||
countries,
|
countries,
|
||||||
noticeTypes,
|
|
||||||
trend,
|
trend,
|
||||||
recent,
|
recent,
|
||||||
recentIsLoading,
|
recentIsLoading,
|
||||||
closingSoonList,
|
portals,
|
||||||
|
portalsIsLoading,
|
||||||
} = useDashboardData();
|
} = useDashboardData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,9 +64,12 @@ export function TenderDashboard() {
|
|||||||
<ClosingSoonCard tenders={closingSoonList} isLoading={isPending} />
|
<ClosingSoonCard tenders={closingSoonList} isLoading={isPending} />
|
||||||
</div> */}
|
</div> */}
|
||||||
|
|
||||||
<div className="col-span-12">
|
<div className="col-span-12 xl:col-span-8">
|
||||||
<RecentTenders tenders={recent} isLoading={recentIsLoading} />
|
<RecentTenders tenders={recent} isLoading={recentIsLoading} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-span-12 xl:col-span-4">
|
||||||
|
<ScrapePortals portals={portals} isLoading={portalsIsLoading} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatisticsSection />
|
<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,
|
useDashboardTrendQuery,
|
||||||
useTendersQuery,
|
useTendersQuery,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
|
import { useGetPortalsQuery } from "@/hooks/queries/usePortalQueries";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { toLabeledPoints } from "./to-labeled-points";
|
import { toLabeledPoints } from "./to-labeled-points";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export function useDashboardData() {
|
|||||||
const countriesQuery = useDashboardCountriesQuery({ limit: 6 });
|
const countriesQuery = useDashboardCountriesQuery({ limit: 6 });
|
||||||
const noticeTypesQuery = useDashboardNoticeTypesQuery();
|
const noticeTypesQuery = useDashboardNoticeTypesQuery();
|
||||||
const closingSoonQuery = useDashboardClosingSoonQuery({ limit: 5 });
|
const closingSoonQuery = useDashboardClosingSoonQuery({ limit: 5 });
|
||||||
|
const portalsQuery = useGetPortalsQuery();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: recentData,
|
data: recentData,
|
||||||
@@ -87,6 +89,8 @@ export function useDashboardData() {
|
|||||||
|
|
||||||
const recent = recentData?.data?.tenders ?? [];
|
const recent = recentData?.data?.tenders ?? [];
|
||||||
const recentIsLoading = recentPending && recent.length === 0;
|
const recentIsLoading = recentPending && recent.length === 0;
|
||||||
|
const portals = portalsQuery.data?.data ?? [];
|
||||||
|
const portalsIsLoading = portalsQuery.isPending && portals.length === 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isPending,
|
isPending,
|
||||||
@@ -104,5 +108,7 @@ export function useDashboardData() {
|
|||||||
recent,
|
recent,
|
||||||
recentIsLoading,
|
recentIsLoading,
|
||||||
closingSoonList,
|
closingSoonList,
|
||||||
|
portals,
|
||||||
|
portalsIsLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { API_ENDPOINTS, portalsService } from "@/lib/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export const useGetPortalsQuery = () => {
|
||||||
|
const queryKey = useMemo(() => [API_ENDPOINTS.PORTALS.GET], []);
|
||||||
|
return useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => portalsService.getPortals(),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -92,4 +92,7 @@ export const API_ENDPOINTS = {
|
|||||||
CLOSING_SOON: "dashboard/closing-soon",
|
CLOSING_SOON: "dashboard/closing-soon",
|
||||||
RECENT: "dashboard/recent",
|
RECENT: "dashboard/recent",
|
||||||
},
|
},
|
||||||
|
PORTALS: {
|
||||||
|
GET: "ai-pipeline/scrape/portals",
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export * from "./companies-service";
|
|||||||
export * from "./dashboard-service";
|
export * from "./dashboard-service";
|
||||||
export * from "./file-service";
|
export * from "./file-service";
|
||||||
export * from "./inquiries-service";
|
export * from "./inquiries-service";
|
||||||
|
export * from "./portals-service";
|
||||||
export * from "./profile-service";
|
export * from "./profile-service";
|
||||||
export * from "./tenders-service";
|
export * from "./tenders-service";
|
||||||
export * from "./user-services";
|
export * from "./user-services";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import api from "../axios";
|
||||||
|
import { API_ENDPOINTS } from "../endpoints";
|
||||||
|
import { ApiResponse, PortalsListResponseSchema, TPortal } from "../types";
|
||||||
|
|
||||||
|
export const portalsService = {
|
||||||
|
getPortals: async (): Promise<ApiResponse<TPortal[]>> => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(API_ENDPOINTS.PORTALS.GET);
|
||||||
|
return PortalsListResponseSchema.parse(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ERROR caught in portals service => getPortals", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { createApiResponseSchema } from "./Factory";
|
||||||
|
|
||||||
|
export const PortalSchema = z.string();
|
||||||
|
|
||||||
|
export const PortalsListResponseSchema = createApiResponseSchema(
|
||||||
|
z.array(PortalSchema),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TPortal = z.infer<typeof PortalSchema>;
|
||||||
|
export type TPortalsListResponse = z.infer<typeof PortalsListResponseSchema>;
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
export * from "./CompanyCategories";
|
export * from "./CompanyCategories";
|
||||||
export * from "./CompanyDocuments";
|
export * from "./CompanyDocuments";
|
||||||
export * from "./Customers";
|
export * from "./Customers";
|
||||||
|
export * from "./Dashboard";
|
||||||
export * from "./Factory";
|
export * from "./Factory";
|
||||||
|
export * from "./Feedback";
|
||||||
export * from "./Posts";
|
export * from "./Posts";
|
||||||
export * from "./Profile";
|
export * from "./Profile";
|
||||||
export * from "./shared";
|
export * from "./shared";
|
||||||
export * from "./TCompany";
|
|
||||||
export * from "./User";
|
|
||||||
export * from "./TInquiries";
|
|
||||||
export * from "./TCms";
|
export * from "./TCms";
|
||||||
export * from "./Feedback";
|
export * from "./TCompany";
|
||||||
export * from "./Tenders";
|
export * from "./Tenders";
|
||||||
export * from "./Dashboard";
|
export * from "./TInquiries";
|
||||||
|
export * from "./TPortals";
|
||||||
|
export * from "./User";
|
||||||
|
|||||||
Reference in New Issue
Block a user