Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a62963a93d | |||
| 72948a9b55 | |||
| da1e2f89e7 | |||
| 88f223bf08 | |||
| 5d01d66ea3 | |||
| a99f86b2f9 | |||
| bb3695ee74 | |||
| 9424758af5 | |||
| de0e31bac5 | |||
| f0902d7536 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
NEXT_PUBLIC_APP_VERSION=2.2.5
|
||||
NEXT_PUBLIC_APP_VERSION=2.3.1
|
||||
NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
|
||||
|
||||
@@ -11,6 +11,12 @@ COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
# next.config.mjs evaluates rewrites() at build time, so the optional
|
||||
# BACKEND_API_BASE_URL override must be available here to be baked into the
|
||||
# routes manifest. Left unset, the host-based routing (opplens.com vs
|
||||
# opplenz.com) applies and no env is needed.
|
||||
ARG BACKEND_API_BASE_URL
|
||||
ENV BACKEND_API_BASE_URL=${BACKEND_API_BASE_URL}
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
+25
-1
@@ -34,10 +34,34 @@ const nextConfig = {
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
// The same build is deployed to two domains, each with its own backend:
|
||||
// - test: opplenz.com -> https://admin.opplenz.com/admin/v1
|
||||
// - production: opplens.com -> https://admin.opplens.com/admin/v1
|
||||
// Routing is host-based so neither deploy needs a custom env. A
|
||||
// `BACKEND_API_BASE_URL` env var (if set) overrides both — handy for local
|
||||
// dev pointing at e.g. http://localhost:8080/admin/v1.
|
||||
// ! ⚠️ IMPORTANT: Adding BACKEND_API_BASE_URL to env file will break the multi-domain feature.
|
||||
const override = process.env.BACKEND_API_BASE_URL;
|
||||
if (override) {
|
||||
return [
|
||||
{
|
||||
source: "/api/proxy/:path*",
|
||||
destination: `https://admin.opplenz.com/admin/v1/:path*`,
|
||||
destination: `${override}/:path*`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
// Production: any opplens.com host (apex or subdomain like www.).
|
||||
{
|
||||
source: "/api/proxy/:path*",
|
||||
has: [{ type: "host", value: "(.*\\.)?opplens\\.com" }],
|
||||
destination: "https://admin.opplens.com/admin/v1/:path*",
|
||||
},
|
||||
// Default (test opplenz.com + local dev).
|
||||
{
|
||||
source: "/api/proxy/:path*",
|
||||
destination: "https://admin.opplenz.com/admin/v1/:path*",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import Loading from "@/components/loading";
|
||||
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
||||
import DocumentItem from "@/components/forms/companies/documents/DocumentItem";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import Status from "@/components/ui/Status";
|
||||
import { useCompanyDetails } from "@/hooks/queries";
|
||||
@@ -72,6 +73,9 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
||||
if (isLoading) return <Loading />;
|
||||
const company = data.data;
|
||||
const website = company.website?.trim();
|
||||
const documentFileIds: string[] = Array.isArray(company.document_file_ids)
|
||||
? company.document_file_ids
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -215,6 +219,17 @@ const CompanyDetailsPage = ({ params }: IProps) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{documentFileIds.length > 0 && (
|
||||
<div>
|
||||
<SectionHeading>Documents</SectionHeading>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{documentFileIds.map((fileId) => (
|
||||
<DocumentItem key={fileId} fileId={fileId} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ShowcaseSection>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import gsap from "gsap";
|
||||
import { useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { AnimatedCounter } from "./animated-counter";
|
||||
import { PulseIcon, TrendUpIcon } from "./icons";
|
||||
|
||||
export type DailyPoint = { date: string; label: string; count: number };
|
||||
|
||||
export type DailyChartAccent = {
|
||||
/** Three stops for the line gradient (left → middle → right). */
|
||||
line: [string, string, string];
|
||||
/** Solid colour used for the area fill, dots and pulse. */
|
||||
fill: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
data: DailyPoint[];
|
||||
isLoading: boolean;
|
||||
accent?: DailyChartAccent;
|
||||
unit?: { singular: string; plural: string };
|
||||
loadingLabel?: string;
|
||||
};
|
||||
|
||||
const W = 1000;
|
||||
const H = 240;
|
||||
const PAD_L = 0;
|
||||
const PAD_R = 0;
|
||||
const PAD_T = 24;
|
||||
const PAD_B = 36;
|
||||
|
||||
const DEFAULT_ACCENT: DailyChartAccent = {
|
||||
line: ["#5750F1", "#8155FF", "#0ABEF9"],
|
||||
fill: "#5750F1",
|
||||
};
|
||||
|
||||
export function DailyTrendChart({
|
||||
title,
|
||||
subtitle,
|
||||
data,
|
||||
isLoading,
|
||||
accent = DEFAULT_ACCENT,
|
||||
unit = { singular: "item", plural: "items" },
|
||||
loadingLabel = "Loading…",
|
||||
}: Props) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const pathRef = useRef<SVGPathElement>(null);
|
||||
const areaRef = useRef<SVGPathElement>(null);
|
||||
const dotsRef = useRef<SVGGElement>(null);
|
||||
|
||||
// Unique gradient/filter ids so multiple charts can live on one page.
|
||||
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
|
||||
const fillId = `dtFill-${uid}`;
|
||||
const lineId = `dtLine-${uid}`;
|
||||
const glowId = `dtGlow-${uid}`;
|
||||
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
|
||||
const { linePath, areaPath, points, sumY } = useMemo(() => {
|
||||
const max = Math.max(1, ...data.map((d) => d.count));
|
||||
const innerW = W - PAD_L - PAD_R;
|
||||
const innerH = H - PAD_T - PAD_B;
|
||||
const step = data.length > 1 ? innerW / (data.length - 1) : innerW;
|
||||
|
||||
const pts = data.map((d, i) => ({
|
||||
x: PAD_L + i * step,
|
||||
y: PAD_T + innerH - (d.count / max) * innerH,
|
||||
d,
|
||||
}));
|
||||
|
||||
if (pts.length === 0) {
|
||||
return { linePath: "", areaPath: "", points: pts, sumY: 0 };
|
||||
}
|
||||
|
||||
const line = pts
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
|
||||
.join(" ");
|
||||
|
||||
const area = `${line} L ${pts[pts.length - 1].x.toFixed(2)} ${PAD_T + innerH} L ${pts[0].x.toFixed(2)} ${PAD_T + innerH} Z`;
|
||||
|
||||
return {
|
||||
linePath: line,
|
||||
areaPath: area,
|
||||
points: pts,
|
||||
sumY: data.reduce((acc, p) => acc + p.count, 0),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
if (reduced) return;
|
||||
gsap.from(rootRef.current, {
|
||||
y: 24,
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
ease: "power3.out",
|
||||
delay: 0.2,
|
||||
});
|
||||
|
||||
if (pathRef.current && linePath) {
|
||||
const length = pathRef.current.getTotalLength();
|
||||
gsap.fromTo(
|
||||
pathRef.current,
|
||||
{ strokeDasharray: length, strokeDashoffset: length },
|
||||
{
|
||||
strokeDashoffset: 0,
|
||||
duration: 1.4,
|
||||
ease: "power2.inOut",
|
||||
delay: 0.3,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (areaRef.current) {
|
||||
gsap.fromTo(
|
||||
areaRef.current,
|
||||
{ opacity: 0, y: 12 },
|
||||
{ opacity: 1, y: 0, duration: 1, delay: 0.5, ease: "power2.out" },
|
||||
);
|
||||
}
|
||||
if (dotsRef.current) {
|
||||
gsap.fromTo(
|
||||
dotsRef.current.querySelectorAll("circle"),
|
||||
{ scale: 0, transformOrigin: "center" },
|
||||
{
|
||||
scale: 1,
|
||||
duration: 0.4,
|
||||
stagger: 0.05,
|
||||
ease: "back.out(2)",
|
||||
delay: 0.8,
|
||||
},
|
||||
);
|
||||
}
|
||||
}, rootRef);
|
||||
return () => ctx.revert();
|
||||
}, [linePath]);
|
||||
|
||||
const last = points[points.length - 1]?.d.count ?? 0;
|
||||
const prev = points[points.length - 2]?.d.count ?? 0;
|
||||
const dayDelta = last - prev;
|
||||
const avg = data.length ? Math.round(sumY / data.length) : 0;
|
||||
|
||||
const handleMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!svgRef.current || !points.length) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const px = ((e.clientX - rect.left) / rect.width) * W;
|
||||
let bestI = 0;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const d = Math.abs(points[i].x - px);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
bestI = i;
|
||||
}
|
||||
}
|
||||
setHoverIdx(bestI);
|
||||
};
|
||||
|
||||
const hovered = hoverIdx !== null ? points[hoverIdx] : null;
|
||||
const innerH = H - PAD_T - PAD_B;
|
||||
const tooltipLeftPct = hovered ? (hovered.x / W) * 100 : 0;
|
||||
const tooltipTopPct = hovered ? (hovered.y / H) * 100 : 0;
|
||||
const flipLeft = hovered ? hovered.x > W * 0.7 : false;
|
||||
const flipRight = hovered ? hovered.x < W * 0.12 : false;
|
||||
const flipDown = hovered ? hovered.y < H * 0.35 : false;
|
||||
const tooltipTX = flipLeft ? "-100%" : flipRight ? "0%" : "-50%";
|
||||
const tooltipTY = flipDown ? "calc(0% + 14px)" : "calc(-100% - 14px)";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="relative overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card md:p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-dark dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-dark-5 dark:text-dark-6">{subtitle}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl border border-stroke px-3 py-1.5 dark:border-stroke-dark">
|
||||
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
||||
Today
|
||||
</div>
|
||||
<div className="text-base font-bold text-dark dark:text-white">
|
||||
<AnimatedCounter value={last} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stroke px-3 py-1.5 dark:border-stroke-dark">
|
||||
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
||||
Avg/day
|
||||
</div>
|
||||
<div className="text-base font-bold text-dark dark:text-white">
|
||||
<AnimatedCounter value={avg} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="hidden items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium sm:inline-flex"
|
||||
style={{ backgroundColor: `${accent.fill}1a`, color: accent.fill }}
|
||||
>
|
||||
<TrendUpIcon className="size-3.5" />
|
||||
{dayDelta >= 0 ? "+" : ""}
|
||||
{dayDelta} vs yesterday
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 w-full overflow-x-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex h-[240px] items-center justify-center text-sm text-dark-6">
|
||||
<PulseIcon className="mr-2 size-4 animate-pulse" /> {loadingLabel}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-[240px] min-w-[600px]">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-full w-full"
|
||||
style={{ overflow: "visible" }}
|
||||
onMouseMove={handleMove}
|
||||
onMouseLeave={() => setHoverIdx(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={fillId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={accent.fill} stopOpacity="0.5" />
|
||||
<stop offset="100%" stopColor={accent.fill} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id={lineId} x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stopColor={accent.line[0]} />
|
||||
<stop offset="50%" stopColor={accent.line[1]} />
|
||||
<stop offset="100%" stopColor={accent.line[2]} />
|
||||
</linearGradient>
|
||||
<filter id={glowId} x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{[0.25, 0.5, 0.75].map((p) => (
|
||||
<line
|
||||
key={p}
|
||||
x1={PAD_L}
|
||||
x2={W - PAD_R}
|
||||
y1={PAD_T + innerH * p}
|
||||
y2={PAD_T + innerH * p}
|
||||
stroke="currentColor"
|
||||
className="text-stroke dark:text-stroke-dark"
|
||||
strokeDasharray="4 6"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
|
||||
{areaPath && (
|
||||
<path ref={areaRef} d={areaPath} fill={`url(#${fillId})`} />
|
||||
)}
|
||||
{linePath && (
|
||||
<>
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={`url(#${lineId})`}
|
||||
strokeWidth="6"
|
||||
strokeOpacity="0.35"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter={`url(#${glowId})`}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
ref={pathRef}
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={`url(#${lineId})`}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hovered && (
|
||||
<line
|
||||
x1={hovered.x}
|
||||
x2={hovered.x}
|
||||
y1={PAD_T}
|
||||
y2={PAD_T + innerH}
|
||||
stroke={accent.fill}
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="3 4"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
|
||||
<g ref={dotsRef}>
|
||||
{points.map((p, i) => {
|
||||
const isHover = hoverIdx === i;
|
||||
const isLatest = i === points.length - 1;
|
||||
return (
|
||||
<g key={i}>
|
||||
{isLatest && (
|
||||
<>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="11"
|
||||
fill={accent.fill}
|
||||
opacity="0.25"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="6;18;6"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.35;0;0.35"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="7"
|
||||
fill={accent.fill}
|
||||
opacity="0.35"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={isHover ? 7 : isLatest ? 5 : 4}
|
||||
fill={isHover || isLatest ? accent.fill : "white"}
|
||||
stroke={accent.fill}
|
||||
strokeWidth={isHover ? 3 : 2}
|
||||
className="transition-[r] duration-150 drop-shadow-sm"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{points.map((p, i) => {
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === points.length - 1;
|
||||
if (!(isFirst || isLast || i % 2 === 0)) return null;
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={p.x}
|
||||
y={H - 12}
|
||||
textAnchor={isFirst ? "start" : isLast ? "end" : "middle"}
|
||||
className="fill-dark-6 text-[10px]"
|
||||
>
|
||||
{p.d.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{hovered && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 rounded-xl border border-stroke bg-white px-3 py-2 text-xs shadow-lg dark:border-stroke-dark dark:bg-gray-dark"
|
||||
style={{
|
||||
left: `${tooltipLeftPct}%`,
|
||||
top: `${tooltipTopPct}%`,
|
||||
transform: `translate(${tooltipTX}, ${tooltipTY})`,
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold uppercase tracking-wider text-dark-6">
|
||||
{hovered.d.label}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-1.5">
|
||||
<span className="text-lg font-bold text-dark dark:text-white tabular-nums">
|
||||
{hovered.d.count}
|
||||
</span>
|
||||
<span className="text-[11px] text-dark-6">
|
||||
{hovered.d.count === 1 ? unit.singular : unit.plural}
|
||||
</span>
|
||||
</div>
|
||||
{hoverIdx !== null && hoverIdx > 0 && (
|
||||
<div className="mt-1 text-[10px] text-dark-6">
|
||||
{(() => {
|
||||
const diff =
|
||||
hovered.d.count - points[hoverIdx - 1].d.count;
|
||||
const sign = diff > 0 ? "+" : "";
|
||||
return `${sign}${diff} vs previous day`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { CountryDistribution } from "./country-distribution";
|
||||
import { DashboardHero } from "./hero";
|
||||
import { NoticeTypeBreakdown } from "./notice-types";
|
||||
import { RecentTenders } from "./recent-tenders";
|
||||
import { StatisticsSection } from "./statistics-section";
|
||||
import { TenderStatCards } from "./stat-cards";
|
||||
import { TenderTrendsChart } from "./trends-chart";
|
||||
import { useDashboardData } from "./use-dashboard-data";
|
||||
@@ -66,6 +67,8 @@ export function TenderDashboard() {
|
||||
<RecentTenders tenders={recent} isLoading={recentIsLoading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatisticsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnimatedCounter } from "./animated-counter";
|
||||
import { DailyChartAccent, DailyTrendChart } from "./daily-trend-chart";
|
||||
import { SparkleIcon, TenderIcon } from "./icons";
|
||||
import {
|
||||
STATISTICS_DAY_OPTIONS,
|
||||
StatisticsDays,
|
||||
useStatistics,
|
||||
} from "./use-statistics";
|
||||
|
||||
const TED_ACCENT: DailyChartAccent = {
|
||||
line: ["#5750F1", "#8155FF", "#0ABEF9"],
|
||||
fill: "#5750F1",
|
||||
};
|
||||
const DOCS_ACCENT: DailyChartAccent = {
|
||||
line: ["#0ABEF9", "#3C9DF8", "#22AD5C"],
|
||||
fill: "#0ABEF9",
|
||||
};
|
||||
const TRANSLATE_ACCENT: DailyChartAccent = {
|
||||
line: ["#F59460", "#F97316", "#EF4444"],
|
||||
fill: "#F59460",
|
||||
};
|
||||
|
||||
export function StatisticsSection() {
|
||||
const {
|
||||
days,
|
||||
setDays,
|
||||
isLoading,
|
||||
scrapedTed,
|
||||
scrapedDocuments,
|
||||
translatedNotices,
|
||||
totalScrapedDocuments,
|
||||
totalTranslatedTenders,
|
||||
} = useStatistics();
|
||||
|
||||
return (
|
||||
<section className="space-y-4 md:space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-dark dark:text-white">
|
||||
Scraping & Translation
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-dark-5 dark:text-dark-6">
|
||||
Daily ingestion and translation activity
|
||||
</p>
|
||||
</div>
|
||||
<DaysSelector value={days} onChange={setDays} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<TotalCard
|
||||
label="Total Scraped Documents"
|
||||
hint="All documents across every tender"
|
||||
value={totalScrapedDocuments}
|
||||
Icon={TenderIcon}
|
||||
color="#0ABEF9"
|
||||
accentGradient="from-[#0ABEF9] to-[#3C50E0]"
|
||||
loading={isLoading}
|
||||
/>
|
||||
<TotalCard
|
||||
label="Total Translated Tenders"
|
||||
hint="Lifetime successful AI translations"
|
||||
value={totalTranslatedTenders}
|
||||
Icon={SparkleIcon}
|
||||
color="#F59460"
|
||||
accentGradient="from-orange-500 to-amber-400"
|
||||
loading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<DailyTrendChart
|
||||
title="Scraped TED per day"
|
||||
subtitle="TED notices imported into the system"
|
||||
data={scrapedTed}
|
||||
isLoading={isLoading}
|
||||
accent={TED_ACCENT}
|
||||
unit={{ singular: "notice", plural: "notices" }}
|
||||
loadingLabel="Loading TED activity…"
|
||||
/>
|
||||
<DailyTrendChart
|
||||
title="Scraped documents per day"
|
||||
subtitle="Individual tender documents scraped"
|
||||
data={scrapedDocuments}
|
||||
isLoading={isLoading}
|
||||
accent={DOCS_ACCENT}
|
||||
unit={{ singular: "document", plural: "documents" }}
|
||||
loadingLabel="Loading documents…"
|
||||
/>
|
||||
<DailyTrendChart
|
||||
title="Translated notices per day"
|
||||
subtitle="Successful AI translation requests"
|
||||
data={translatedNotices}
|
||||
isLoading={isLoading}
|
||||
accent={TRANSLATE_ACCENT}
|
||||
unit={{ singular: "notice", plural: "notices" }}
|
||||
loadingLabel="Loading translations…"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DaysSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: StatisticsDays;
|
||||
onChange: (days: StatisticsDays) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1 rounded-xl border border-stroke bg-white p-1 dark:border-stroke-dark dark:bg-gray-dark">
|
||||
{STATISTICS_DAY_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => onChange(option)}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
value === option
|
||||
? "bg-primary text-white"
|
||||
: "text-dark-5 hover:bg-gray-2 dark:text-dark-6 dark:hover:bg-dark-3",
|
||||
)}
|
||||
>
|
||||
{option}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalCard({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
Icon,
|
||||
color,
|
||||
accentGradient,
|
||||
loading,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
value: number;
|
||||
Icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
color: string;
|
||||
accentGradient: string;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative isolate overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card">
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute -right-12 -top-12 z-0 size-32 rounded-full bg-gradient-to-br opacity-25 blur-2xl",
|
||||
accentGradient,
|
||||
)}
|
||||
/>
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex size-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br text-white shadow-lg",
|
||||
accentGradient,
|
||||
)}
|
||||
style={{ boxShadow: `0 10px 30px -10px ${color}88` }}
|
||||
>
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[30px] font-bold leading-none" dir="ltr" lang="en">
|
||||
{loading ? (
|
||||
<span className="inline-block h-8 w-24 animate-pulse rounded bg-gray-2 dark:bg-dark-3" />
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block bg-gradient-to-br bg-clip-text text-transparent",
|
||||
accentGradient,
|
||||
)}
|
||||
>
|
||||
<AnimatedCounter value={value} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-dark dark:text-white">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-dark-6">{hint}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TDashboardTrendPoint } from "@/lib/api/types";
|
||||
import type { DailyPoint } from "./daily-trend-chart";
|
||||
|
||||
/**
|
||||
* Convert an API daily series (`{ date: "YYYY-MM-DD", count }`) into chart
|
||||
* points with a locale-formatted axis label. API dates are UTC calendar days
|
||||
* and are kept verbatim in `date` for lookups.
|
||||
*/
|
||||
export function toLabeledPoints(series: TDashboardTrendPoint[]): DailyPoint[] {
|
||||
return series.map((point) => {
|
||||
const [y, m, d] = point.date.split("-").map(Number);
|
||||
const date = new Date(Date.UTC(y, (m ?? 1) - 1, d ?? 1));
|
||||
return {
|
||||
date: point.date,
|
||||
label: date.toLocaleDateString("en", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
count: point.count,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,382 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import gsap from "gsap";
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { AnimatedCounter } from "./animated-counter";
|
||||
import { PulseIcon, TrendUpIcon } from "./icons";
|
||||
|
||||
type Point = { date: string; label: string; count: number };
|
||||
import { DailyPoint, DailyTrendChart } from "./daily-trend-chart";
|
||||
|
||||
type Props = {
|
||||
data: Point[];
|
||||
data: DailyPoint[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const W = 1000;
|
||||
const H = 240;
|
||||
const PAD_L = 0;
|
||||
const PAD_R = 0;
|
||||
const PAD_T = 24;
|
||||
const PAD_B = 36;
|
||||
|
||||
export function TenderTrendsChart({ data, isLoading }: Props) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const pathRef = useRef<SVGPathElement>(null);
|
||||
const areaRef = useRef<SVGPathElement>(null);
|
||||
const dotsRef = useRef<SVGGElement>(null);
|
||||
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
|
||||
const { linePath, areaPath, points, sumY } = useMemo(() => {
|
||||
const max = Math.max(1, ...data.map((d) => d.count));
|
||||
const innerW = W - PAD_L - PAD_R;
|
||||
const innerH = H - PAD_T - PAD_B;
|
||||
const step = data.length > 1 ? innerW / (data.length - 1) : innerW;
|
||||
|
||||
const pts = data.map((d, i) => ({
|
||||
x: PAD_L + i * step,
|
||||
y: PAD_T + innerH - (d.count / max) * innerH,
|
||||
d,
|
||||
}));
|
||||
|
||||
if (pts.length === 0) {
|
||||
return { linePath: "", areaPath: "", points: pts, sumY: 0 };
|
||||
}
|
||||
|
||||
const line = pts
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
|
||||
.join(" ");
|
||||
|
||||
const area = `${line} L ${pts[pts.length - 1].x.toFixed(2)} ${PAD_T + innerH} L ${pts[0].x.toFixed(2)} ${PAD_T + innerH} Z`;
|
||||
|
||||
return {
|
||||
linePath: line,
|
||||
areaPath: area,
|
||||
points: pts,
|
||||
sumY: data.reduce((acc, p) => acc + p.count, 0),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
if (reduced) return;
|
||||
gsap.from(rootRef.current, {
|
||||
y: 24,
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
ease: "power3.out",
|
||||
delay: 0.2,
|
||||
});
|
||||
|
||||
if (pathRef.current && linePath) {
|
||||
const length = pathRef.current.getTotalLength();
|
||||
gsap.fromTo(
|
||||
pathRef.current,
|
||||
{ strokeDasharray: length, strokeDashoffset: length },
|
||||
{
|
||||
strokeDashoffset: 0,
|
||||
duration: 1.4,
|
||||
ease: "power2.inOut",
|
||||
delay: 0.3,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (areaRef.current) {
|
||||
gsap.fromTo(
|
||||
areaRef.current,
|
||||
{ opacity: 0, y: 12 },
|
||||
{ opacity: 1, y: 0, duration: 1, delay: 0.5, ease: "power2.out" },
|
||||
);
|
||||
}
|
||||
if (dotsRef.current) {
|
||||
gsap.fromTo(
|
||||
dotsRef.current.querySelectorAll("circle"),
|
||||
{ scale: 0, transformOrigin: "center" },
|
||||
{
|
||||
scale: 1,
|
||||
duration: 0.4,
|
||||
stagger: 0.05,
|
||||
ease: "back.out(2)",
|
||||
delay: 0.8,
|
||||
},
|
||||
);
|
||||
}
|
||||
}, rootRef);
|
||||
return () => ctx.revert();
|
||||
}, [linePath]);
|
||||
|
||||
const last = points[points.length - 1]?.d.count ?? 0;
|
||||
const prev = points[points.length - 2]?.d.count ?? 0;
|
||||
const dayDelta = last - prev;
|
||||
const avg = data.length ? Math.round(sumY / data.length) : 0;
|
||||
|
||||
const handleMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!svgRef.current || !points.length) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const px = ((e.clientX - rect.left) / rect.width) * W;
|
||||
let bestI = 0;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const d = Math.abs(points[i].x - px);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
bestI = i;
|
||||
}
|
||||
}
|
||||
setHoverIdx(bestI);
|
||||
};
|
||||
|
||||
const hovered = hoverIdx !== null ? points[hoverIdx] : null;
|
||||
const innerH = H - PAD_T - PAD_B;
|
||||
const tooltipLeftPct = hovered ? (hovered.x / W) * 100 : 0;
|
||||
const tooltipTopPct = hovered ? (hovered.y / H) * 100 : 0;
|
||||
const flipLeft = hovered ? hovered.x > W * 0.7 : false;
|
||||
const flipRight = hovered ? hovered.x < W * 0.12 : false;
|
||||
const flipDown = hovered ? hovered.y < H * 0.35 : false;
|
||||
const tooltipTX = flipLeft ? "-100%" : flipRight ? "0%" : "-50%";
|
||||
const tooltipTY = flipDown ? "calc(0% + 14px)" : "calc(-100% - 14px)";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="relative overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card md:p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-dark dark:text-white">
|
||||
Tender Flow — Last 14 days
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-dark-5 dark:text-dark-6">
|
||||
New tender notices received per day
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl border border-stroke px-3 py-1.5 dark:border-stroke-dark">
|
||||
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
||||
Today
|
||||
</div>
|
||||
<div className="text-base font-bold text-dark dark:text-white">
|
||||
<AnimatedCounter value={last} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stroke px-3 py-1.5 dark:border-stroke-dark">
|
||||
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
||||
Avg/day
|
||||
</div>
|
||||
<div className="text-base font-bold text-dark dark:text-white">
|
||||
<AnimatedCounter value={avg} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary sm:inline-flex">
|
||||
<TrendUpIcon className="size-3.5" />
|
||||
{dayDelta >= 0 ? "+" : ""}
|
||||
{dayDelta} vs yesterday
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 w-full overflow-x-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex h-[240px] items-center justify-center text-sm text-dark-6">
|
||||
<PulseIcon className="mr-2 size-4 animate-pulse" /> Loading flow…
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-[240px] min-w-[600px]">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-full w-full"
|
||||
style={{ overflow: "visible" }}
|
||||
onMouseMove={handleMove}
|
||||
onMouseLeave={() => setHoverIdx(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="tenderTrendFill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#5750F1" stopOpacity="0.5" />
|
||||
<stop offset="100%" stopColor="#5750F1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="tenderTrendLine" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stopColor="#5750F1" />
|
||||
<stop offset="50%" stopColor="#8155FF" />
|
||||
<stop offset="100%" stopColor="#0ABEF9" />
|
||||
</linearGradient>
|
||||
<filter id="tenderTrendGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{[0.25, 0.5, 0.75].map((p) => (
|
||||
<line
|
||||
key={p}
|
||||
x1={PAD_L}
|
||||
x2={W - PAD_R}
|
||||
y1={PAD_T + innerH * p}
|
||||
y2={PAD_T + innerH * p}
|
||||
stroke="currentColor"
|
||||
className="text-stroke dark:text-stroke-dark"
|
||||
strokeDasharray="4 6"
|
||||
strokeWidth="1"
|
||||
<DailyTrendChart
|
||||
title="Tender Flow — Last 14 days"
|
||||
subtitle="New tender notices received per day"
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
unit={{ singular: "tender", plural: "tenders" }}
|
||||
loadingLabel="Loading flow…"
|
||||
/>
|
||||
))}
|
||||
|
||||
{areaPath && (
|
||||
<path ref={areaRef} d={areaPath} fill="url(#tenderTrendFill)" />
|
||||
)}
|
||||
{linePath && (
|
||||
<>
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="url(#tenderTrendLine)"
|
||||
strokeWidth="6"
|
||||
strokeOpacity="0.35"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#tenderTrendGlow)"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
ref={pathRef}
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="url(#tenderTrendLine)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hovered && (
|
||||
<line
|
||||
x1={hovered.x}
|
||||
x2={hovered.x}
|
||||
y1={PAD_T}
|
||||
y2={PAD_T + innerH}
|
||||
stroke="#5750F1"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="3 4"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
|
||||
<g ref={dotsRef}>
|
||||
{points.map((p, i) => {
|
||||
const isHover = hoverIdx === i;
|
||||
const isLatest = i === points.length - 1;
|
||||
return (
|
||||
<g key={i}>
|
||||
{isLatest && (
|
||||
<>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="11"
|
||||
fill="#5750F1"
|
||||
opacity="0.25"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="6;18;6"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.35;0;0.35"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="7"
|
||||
fill="#5750F1"
|
||||
opacity="0.35"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={isHover ? 7 : isLatest ? 5 : 4}
|
||||
fill={isHover || isLatest ? "#5750F1" : "white"}
|
||||
stroke="#5750F1"
|
||||
strokeWidth={isHover ? 3 : 2}
|
||||
className="transition-[r] duration-150 drop-shadow-sm"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{points.map((p, i) => {
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === points.length - 1;
|
||||
if (!(isFirst || isLast || i % 2 === 0)) return null;
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={p.x}
|
||||
y={H - 12}
|
||||
textAnchor={isFirst ? "start" : isLast ? "end" : "middle"}
|
||||
className="fill-dark-6 text-[10px]"
|
||||
>
|
||||
{p.d.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{hovered && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 rounded-xl border border-stroke bg-white px-3 py-2 text-xs shadow-lg dark:border-stroke-dark dark:bg-gray-dark"
|
||||
style={{
|
||||
left: `${tooltipLeftPct}%`,
|
||||
top: `${tooltipTopPct}%`,
|
||||
transform: `translate(${tooltipTX}, ${tooltipTY})`,
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold uppercase tracking-wider text-dark-6">
|
||||
{hovered.d.label}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-1.5">
|
||||
<span className="text-lg font-bold text-dark dark:text-white tabular-nums">
|
||||
{hovered.d.count}
|
||||
</span>
|
||||
<span className="text-[11px] text-dark-6">
|
||||
{hovered.d.count === 1 ? "tender" : "tenders"}
|
||||
</span>
|
||||
</div>
|
||||
{hoverIdx !== null && hoverIdx > 0 && (
|
||||
<div className="mt-1 text-[10px] text-dark-6">
|
||||
{(() => {
|
||||
const diff =
|
||||
hovered.d.count - points[hoverIdx - 1].d.count;
|
||||
const sign = diff > 0 ? "+" : "";
|
||||
return `${sign}${diff} vs previous day`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useTendersQuery,
|
||||
} from "@/hooks/queries";
|
||||
import { useMemo } from "react";
|
||||
import { toLabeledPoints } from "./to-labeled-points";
|
||||
|
||||
export type DashboardMetrics = ReturnType<typeof useDashboardData>;
|
||||
|
||||
@@ -61,21 +62,10 @@ export function useDashboardData() {
|
||||
refetchRecent();
|
||||
};
|
||||
|
||||
const trend = useMemo(() => {
|
||||
const series = trendResponse?.series ?? [];
|
||||
return series.map((point) => {
|
||||
const [y, m, d] = point.date.split("-").map(Number);
|
||||
const date = new Date(Date.UTC(y, (m ?? 1) - 1, d ?? 1));
|
||||
return {
|
||||
date: point.date,
|
||||
label: date.toLocaleDateString("en", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
count: point.count,
|
||||
};
|
||||
});
|
||||
}, [trendResponse]);
|
||||
const trend = useMemo(
|
||||
() => toLabeledPoints(trendResponse?.series ?? []),
|
||||
[trendResponse],
|
||||
);
|
||||
|
||||
const countries = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardStatisticsQuery } from "@/hooks/queries";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toLabeledPoints } from "./to-labeled-points";
|
||||
|
||||
export const STATISTICS_DAY_OPTIONS = [7, 14, 30, 90] as const;
|
||||
|
||||
export type StatisticsDays = (typeof STATISTICS_DAY_OPTIONS)[number];
|
||||
|
||||
export function useStatistics() {
|
||||
const [days, setDays] = useState<StatisticsDays>(14);
|
||||
|
||||
const query = useDashboardStatisticsQuery({ days });
|
||||
const stats = query.data?.data;
|
||||
|
||||
const daily = stats?.daily;
|
||||
|
||||
const scrapedTed = useMemo(
|
||||
() => toLabeledPoints(daily?.scraped_ted ?? []),
|
||||
[daily],
|
||||
);
|
||||
const scrapedDocuments = useMemo(
|
||||
() => toLabeledPoints(daily?.scraped_documents ?? []),
|
||||
[daily],
|
||||
);
|
||||
const translatedNotices = useMemo(
|
||||
() => toLabeledPoints(daily?.translated_notices ?? []),
|
||||
[daily],
|
||||
);
|
||||
|
||||
return {
|
||||
days,
|
||||
setDays,
|
||||
isLoading: query.isPending,
|
||||
isError: query.isError,
|
||||
refetch: query.refetch,
|
||||
scrapedTed,
|
||||
scrapedDocuments,
|
||||
translatedNotices,
|
||||
totalScrapedDocuments: stats?.totals.scraped_documents ?? 0,
|
||||
totalTranslatedTenders: stats?.totals.translated_tenders ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -1,39 +1,188 @@
|
||||
"use client";
|
||||
import Loading from "@/components/loading";
|
||||
|
||||
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
||||
import { NotificationDetailsCard } from "@/components/ui/notification-details-card";
|
||||
import { useGetNotificationDetails } from "@/hooks/queries/useNotificationQueries";
|
||||
import { BreadcrumbItem } from "@/types/shared";
|
||||
import { use } from "react";
|
||||
import Loading from "@/components/loading";
|
||||
import Status from "@/components/ui/Status";
|
||||
import { BellIcon } from "@/components/Layouts/header/notification/icons";
|
||||
import Link from "next/link";
|
||||
import { useNotificationDetailsPresenter } from "./useNotificationDetailsPresenter";
|
||||
|
||||
interface IProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const NotificationDetailsPage = ({ params }: IProps) => {
|
||||
const { id } = use(params);
|
||||
const { data, isLoading } = useGetNotificationDetails(id);
|
||||
const { isLoading, rootRef, breadcrumbItems, meta, details } =
|
||||
useNotificationDetailsPresenter({ params });
|
||||
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{
|
||||
href: "/",
|
||||
name: "Dashboard",
|
||||
},
|
||||
{
|
||||
href: "/notification-history",
|
||||
name: "Notification history",
|
||||
},
|
||||
{
|
||||
href: `/notification-history/${id}`,
|
||||
name: "Notification details",
|
||||
},
|
||||
];
|
||||
if (isLoading) return <Loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={rootRef} className="flex flex-col gap-6">
|
||||
<div data-anim="breadcrumb">
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
{data?.data && <NotificationDetailsCard data={data.data} />}
|
||||
</>
|
||||
</div>
|
||||
|
||||
{/* Hero header */}
|
||||
<section
|
||||
data-anim="hero"
|
||||
className="relative isolate overflow-hidden rounded-3xl border border-stroke/60 bg-gradient-to-br from-primary via-primary to-blue-700 p-6 text-white shadow-1 md:p-9 dark:border-transparent dark:shadow-card"
|
||||
>
|
||||
<div
|
||||
data-anim="aurora"
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -inset-[40%] z-0 opacity-50"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"conic-gradient(from 0deg at 50% 50%, #5750F1 0deg, #0ABEF9 80deg, transparent 140deg, #8155FF 200deg, #5750F1 280deg, transparent 320deg, #5750F1 360deg)",
|
||||
filter: "blur(64px)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
data-orb="a"
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-24 -top-32 z-0 h-72 w-72 rounded-full bg-white/20 blur-3xl"
|
||||
/>
|
||||
<div
|
||||
data-orb="b"
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -bottom-32 -left-16 z-0 h-64 w-64 rounded-full bg-[#0ABEF9]/40 blur-3xl"
|
||||
/>
|
||||
<div
|
||||
data-orb="c"
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-1/2 top-1/3 z-0 h-52 w-52 -translate-x-1/2 rounded-full bg-[#8155FF]/30 blur-3xl"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0 rounded-3xl ring-1 ring-inset ring-white/10"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex flex-col gap-6 md:flex-row md:items-start">
|
||||
<span
|
||||
data-anim="hero-icon"
|
||||
className="grid size-16 shrink-0 place-items-center rounded-2xl bg-white/20 text-white shadow-theme-sm ring-1 ring-white/30 backdrop-blur-md"
|
||||
>
|
||||
<BellIcon />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1
|
||||
className="relative z-[1] text-2xl font-bold leading-tight md:text-3xl"
|
||||
aria-label={meta?.title}
|
||||
>
|
||||
<span
|
||||
data-title-inner
|
||||
className="relative z-[1] inline-block will-change-transform"
|
||||
>
|
||||
{meta?.title}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0 overflow-hidden"
|
||||
>
|
||||
<span
|
||||
data-anim="shine"
|
||||
className="absolute inset-y-0 block w-1/3 opacity-0 blur-[10px] [background:linear-gradient(100deg,transparent_0%,rgba(255,255,255,0.55)_50%,transparent_100%)]"
|
||||
/>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2.5">
|
||||
{meta?.priority && (
|
||||
<span data-anim="badge" className="inline-flex">
|
||||
<Status status={meta.priority}>{meta.priority}</Status>
|
||||
</span>
|
||||
)}
|
||||
{meta?.type && (
|
||||
<span
|
||||
data-anim="badge"
|
||||
className="rounded-full bg-white/20 px-3 py-1 text-xs font-semibold capitalize text-white ring-1 ring-white/25 backdrop-blur-sm"
|
||||
>
|
||||
{meta.type}
|
||||
</span>
|
||||
)}
|
||||
{meta?.createdAt && (
|
||||
<span
|
||||
data-anim="badge"
|
||||
className="rounded-full bg-white/15 px-3 py-1 text-xs font-medium text-white/85 ring-1 ring-white/15"
|
||||
>
|
||||
{meta.createdAt}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meta?.link && (
|
||||
<Link
|
||||
data-anim="badge"
|
||||
href={meta.link}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-white/95 px-4 py-2 text-sm font-semibold text-primary shadow-theme-sm transition-transform hover:scale-[1.03]"
|
||||
>
|
||||
Open link
|
||||
<span aria-hidden>↗</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Detail grid */}
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{details.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
data-anim="card"
|
||||
className="group relative overflow-hidden rounded-2xl border border-stroke/60 bg-white p-5 shadow-theme-xs transition-shadow hover:shadow-1 dark:border-dark-3 dark:bg-gray-dark"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-6 -top-6 size-16 rounded-full bg-primary/5 blur-xl transition-opacity group-hover:opacity-100 dark:bg-primary/10"
|
||||
/>
|
||||
<small className="text-xs font-medium uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||
{item.label}
|
||||
</small>
|
||||
<div className="mt-2">
|
||||
{item.kind === "status" ? (
|
||||
<Status status={item.tone ?? ""}>{item.value}</Status>
|
||||
) : (
|
||||
<p className="break-words text-base font-semibold text-dark dark:text-white">
|
||||
{item.value}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Image + message */}
|
||||
{(meta?.image || meta?.hasMessage) && (
|
||||
<section
|
||||
data-anim="message"
|
||||
className="overflow-hidden rounded-3xl border border-stroke/60 bg-white shadow-theme-xs dark:border-dark-3 dark:bg-gray-dark"
|
||||
>
|
||||
{meta?.image && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={meta.image}
|
||||
alt={meta.title}
|
||||
className="max-h-72 w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{meta?.hasMessage && (
|
||||
<div className="p-6 md:p-8">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-bold text-dark dark:text-white">
|
||||
<span className="inline-block h-5 w-1 rounded-full bg-gradient-to-b from-primary to-blue-700" />
|
||||
Message
|
||||
</h2>
|
||||
<div
|
||||
className="text-base leading-relaxed text-dark-4 dark:text-dark-6 [&_a]:text-primary [&_a]:underline"
|
||||
dangerouslySetInnerHTML={{ __html: meta.messageHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useGetNotificationDetails } from "@/hooks/queries/useNotificationQueries";
|
||||
import { sanitizeHtmlWithStyles } from "@/lib/utils";
|
||||
import { BreadcrumbItem } from "@/types/shared";
|
||||
import { capitalize, unixToDate } from "@/utils/shared";
|
||||
import gsap from "gsap";
|
||||
import { use, useLayoutEffect, useMemo, useRef } from "react";
|
||||
|
||||
interface IParams {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export interface IDetailItem {
|
||||
label: string;
|
||||
value: string;
|
||||
kind?: "text" | "status";
|
||||
tone?: string;
|
||||
}
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
export const useNotificationDetailsPresenter = ({ params }: IParams) => {
|
||||
const { id } = use(params);
|
||||
const { data, isLoading } = useGetNotificationDetails(id);
|
||||
const notification = data?.data;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
{ href: "/", name: "Dashboard" },
|
||||
{ href: "/notification-history", name: "Notification history" },
|
||||
{ href: `/notification-history/${id}`, name: "Notification details" },
|
||||
];
|
||||
|
||||
const meta = useMemo(() => {
|
||||
if (!notification) return null;
|
||||
return {
|
||||
title: notification.title || "Notification",
|
||||
messageHtml: sanitizeHtmlWithStyles(notification.message || ""),
|
||||
hasMessage: !!notification.message,
|
||||
image: notification.image,
|
||||
priority: notification.priority,
|
||||
type: notification.type,
|
||||
status: notification.status,
|
||||
link: notification.link,
|
||||
createdAt: unixToDate({ unix: notification.created_at, hasTime: true }),
|
||||
recipient: notification.recipient?.full_name,
|
||||
};
|
||||
}, [notification]);
|
||||
|
||||
const details: IDetailItem[] = useMemo(() => {
|
||||
if (!notification) return [];
|
||||
return [
|
||||
{
|
||||
label: "Recipient",
|
||||
value: notification.recipient?.full_name || "—",
|
||||
},
|
||||
{ label: "Event type", value: notification.event_type || "—" },
|
||||
{
|
||||
label: "Status",
|
||||
value: capitalize(notification.status || "—"),
|
||||
kind: "status",
|
||||
tone: notification.status,
|
||||
},
|
||||
{
|
||||
label: "Priority",
|
||||
value: capitalize(notification.priority || "—"),
|
||||
kind: "status",
|
||||
tone: notification.priority,
|
||||
},
|
||||
{
|
||||
label: "Channel type",
|
||||
value: capitalize(notification.type || "—"),
|
||||
kind: "status",
|
||||
tone: notification.type,
|
||||
},
|
||||
{
|
||||
label: "Seen",
|
||||
value: notification.seen ? "Yes" : "No",
|
||||
kind: "status",
|
||||
tone: notification.seen ? "success" : "failed",
|
||||
},
|
||||
{
|
||||
label: "Seen at",
|
||||
value: unixToDate({ unix: notification.seen_at ?? 0, hasTime: true }),
|
||||
},
|
||||
{
|
||||
label: "Scheduled",
|
||||
value: notification.is_scheduled ? "Yes" : "No",
|
||||
},
|
||||
];
|
||||
}, [notification]);
|
||||
|
||||
// Complex GSAP intro + ambient motion, scoped to the page root.
|
||||
useLayoutEffect(() => {
|
||||
if (isLoading || !notification) return;
|
||||
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const ctx = gsap.context((self) => {
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
const q = self.selector!;
|
||||
|
||||
// Entrance timeline
|
||||
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
|
||||
|
||||
tl.from(root, { y: 28, duration: 0.7 }, 0)
|
||||
.from(
|
||||
q("[data-anim='breadcrumb']"),
|
||||
{ y: -12, opacity: 0, duration: 0.5 },
|
||||
0.05,
|
||||
)
|
||||
.from(
|
||||
q("[data-anim='hero']"),
|
||||
{ y: 40, opacity: 0, duration: 0.8 },
|
||||
0.1,
|
||||
)
|
||||
.from(
|
||||
q("[data-anim='hero-icon']"),
|
||||
{
|
||||
scale: 0,
|
||||
rotate: -120,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
ease: "back.out(2)",
|
||||
},
|
||||
0.3,
|
||||
)
|
||||
.from(
|
||||
q("[data-title-inner]"),
|
||||
{ y: 32, opacity: 0, duration: 0.7, ease: "back.out(1.6)" },
|
||||
0.4,
|
||||
)
|
||||
.from(
|
||||
q("[data-anim='badge']"),
|
||||
{
|
||||
y: 16,
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
stagger: 0.08,
|
||||
ease: "back.out(1.8)",
|
||||
},
|
||||
0.55,
|
||||
)
|
||||
.from(
|
||||
q("[data-anim='card']"),
|
||||
{
|
||||
y: 36,
|
||||
opacity: 0,
|
||||
scale: 0.94,
|
||||
duration: 0.6,
|
||||
stagger: 0.07,
|
||||
ease: "back.out(1.4)",
|
||||
},
|
||||
0.5,
|
||||
)
|
||||
.from(
|
||||
q("[data-anim='message']"),
|
||||
{ y: 30, opacity: 0, duration: 0.7 },
|
||||
0.75,
|
||||
);
|
||||
|
||||
// Ambient aurora rotation
|
||||
const aurora = q("[data-anim='aurora']");
|
||||
if (aurora.length) {
|
||||
gsap.to(aurora, {
|
||||
rotate: 360,
|
||||
duration: 44,
|
||||
ease: "none",
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
|
||||
// Floating orbs
|
||||
gsap.to(q("[data-orb='a']"), {
|
||||
x: 40,
|
||||
y: -28,
|
||||
scale: 1.18,
|
||||
duration: 7,
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
ease: "sine.inOut",
|
||||
});
|
||||
gsap.to(q("[data-orb='b']"), {
|
||||
x: -34,
|
||||
y: 26,
|
||||
scale: 1.12,
|
||||
duration: 8.5,
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
ease: "sine.inOut",
|
||||
});
|
||||
gsap.to(q("[data-orb='c']"), {
|
||||
x: 24,
|
||||
y: 18,
|
||||
scale: 1.08,
|
||||
duration: 9.5,
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
ease: "sine.inOut",
|
||||
});
|
||||
|
||||
// Title shine sweep
|
||||
const shine = q("[data-anim='shine']");
|
||||
if (shine.length) {
|
||||
const shineTl = gsap.timeline({ repeat: -1, repeatDelay: 3.5 });
|
||||
shineTl
|
||||
.set(shine, { xPercent: -160, opacity: 0 })
|
||||
.to(shine, { opacity: 1, duration: 0.2, delay: 1.6 })
|
||||
.to(shine, { xPercent: 260, duration: 1.8, ease: "power2.inOut" }, "<")
|
||||
.to(shine, { opacity: 0, duration: 0.2 });
|
||||
}
|
||||
|
||||
// Card hover lift + magnetic mouse parallax on the hero orbs
|
||||
const cards = q("[data-anim='card']");
|
||||
cards.forEach((card: Element) => {
|
||||
const el = card as HTMLElement;
|
||||
const enter = () =>
|
||||
gsap.to(el, { y: -6, duration: 0.35, ease: "power2.out" });
|
||||
const leave = () =>
|
||||
gsap.to(el, { y: 0, duration: 0.45, ease: "power2.out" });
|
||||
el.addEventListener("mouseenter", enter);
|
||||
el.addEventListener("mouseleave", leave);
|
||||
});
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const r = root.getBoundingClientRect();
|
||||
const px = (e.clientX - r.left) / r.width - 0.5;
|
||||
const py = (e.clientY - r.top) / r.height - 0.5;
|
||||
gsap.to(q("[data-orb='a']"), {
|
||||
xPercent: px * 22,
|
||||
yPercent: py * 18,
|
||||
duration: 1.2,
|
||||
ease: "power2.out",
|
||||
overwrite: "auto",
|
||||
});
|
||||
gsap.to(q("[data-orb='b']"), {
|
||||
xPercent: px * -18,
|
||||
yPercent: py * 20,
|
||||
duration: 1.3,
|
||||
ease: "power2.out",
|
||||
overwrite: "auto",
|
||||
});
|
||||
gsap.to(q("[data-orb='c']"), {
|
||||
xPercent: px * 14,
|
||||
yPercent: py * -14,
|
||||
duration: 1.4,
|
||||
ease: "power2.out",
|
||||
overwrite: "auto",
|
||||
});
|
||||
};
|
||||
root.addEventListener("mousemove", onMove);
|
||||
|
||||
return () => root.removeEventListener("mousemove", onMove);
|
||||
}, rootRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [isLoading, notification]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
rootRef,
|
||||
breadcrumbItems,
|
||||
notification,
|
||||
meta,
|
||||
details,
|
||||
};
|
||||
};
|
||||
@@ -7,9 +7,10 @@ import useBenefitsStepPresenter, {
|
||||
|
||||
interface BenefitsStepProps {
|
||||
initialData?: IBenefitsStepFields;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
}
|
||||
|
||||
const BenefitsStep = forwardRef<unknown, BenefitsStepProps>(({ initialData }, ref) => {
|
||||
const BenefitsStep = forwardRef<unknown, BenefitsStepProps>(({ initialData, onValidityChange }, ref) => {
|
||||
const {
|
||||
errors,
|
||||
handleSubmit,
|
||||
@@ -19,7 +20,7 @@ const BenefitsStep = forwardRef<unknown, BenefitsStepProps>(({ initialData }, re
|
||||
append,
|
||||
remove,
|
||||
control,
|
||||
} = useBenefitsStepPresenter(initialData, ref);
|
||||
} = useBenefitsStepPresenter(initialData, ref, onValidityChange);
|
||||
|
||||
return (
|
||||
<SectionStep<IBenefitsStepFields>
|
||||
|
||||
@@ -7,10 +7,11 @@ import useChallengesStepPresenter, {
|
||||
|
||||
interface ChallengesStepProps {
|
||||
initialData?: IChallengesStepFields;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
}
|
||||
|
||||
const ChallengesStep = forwardRef<unknown, ChallengesStepProps>(
|
||||
({ initialData }, ref) => {
|
||||
({ initialData, onValidityChange }, ref) => {
|
||||
const {
|
||||
errors,
|
||||
handleSubmit,
|
||||
@@ -20,7 +21,7 @@ const ChallengesStep = forwardRef<unknown, ChallengesStepProps>(
|
||||
append,
|
||||
remove,
|
||||
control,
|
||||
} = useChallengesStepPresenter(initialData, ref);
|
||||
} = useChallengesStepPresenter(initialData, ref, onValidityChange);
|
||||
|
||||
return (
|
||||
<SectionStep<IChallengesStepFields>
|
||||
|
||||
@@ -10,10 +10,11 @@ import useChartStepPresenter from "./useChartStepPresenter";
|
||||
|
||||
interface ChartStepProps {
|
||||
initialData?: any;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
}
|
||||
|
||||
const ChartStep = forwardRef<unknown, ChartStepProps>(
|
||||
({ initialData }, ref) => {
|
||||
({ initialData, onValidityChange }, ref) => {
|
||||
const {
|
||||
errors,
|
||||
handleSubmit,
|
||||
@@ -23,7 +24,7 @@ const ChartStep = forwardRef<unknown, ChartStepProps>(
|
||||
append,
|
||||
remove,
|
||||
watch,
|
||||
} = useChartStepPresenter(initialData, ref);
|
||||
} = useChartStepPresenter(initialData, ref, onValidityChange);
|
||||
|
||||
const chartTitle = watch("title");
|
||||
const chartData = watch("data");
|
||||
|
||||
@@ -9,12 +9,13 @@ import useContactsStepPresenter from "./useContactsStepPresenter";
|
||||
|
||||
interface ContactsStepProps {
|
||||
initialData?: any;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
}
|
||||
|
||||
const ContactsStep = forwardRef<unknown, ContactsStepProps>(
|
||||
({ initialData }, ref) => {
|
||||
({ initialData, onValidityChange }, ref) => {
|
||||
const { errors, handleSubmit, onSubmit, register, fields } =
|
||||
useContactsStepPresenter(initialData, ref);
|
||||
useContactsStepPresenter(initialData, ref, onValidityChange);
|
||||
|
||||
return (
|
||||
<form
|
||||
|
||||
@@ -7,10 +7,11 @@ import useFeaturesStepPresenter, {
|
||||
|
||||
interface FeaturesStepProps {
|
||||
initialData?: IFeaturesStepFields;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
}
|
||||
|
||||
const FeaturesStep = forwardRef<unknown, FeaturesStepProps>(
|
||||
({ initialData }, ref) => {
|
||||
({ initialData, onValidityChange }, ref) => {
|
||||
const {
|
||||
errors,
|
||||
handleSubmit,
|
||||
@@ -20,7 +21,7 @@ const FeaturesStep = forwardRef<unknown, FeaturesStepProps>(
|
||||
append,
|
||||
remove,
|
||||
control,
|
||||
} = useFeaturesStepPresenter(initialData, ref);
|
||||
} = useFeaturesStepPresenter(initialData, ref, onValidityChange);
|
||||
|
||||
return (
|
||||
<SectionStep<IFeaturesStepFields>
|
||||
|
||||
@@ -8,13 +8,15 @@ import useFooterStepPresenter from "./useFooterStepPresenter";
|
||||
|
||||
interface FooterStepProps {
|
||||
initialData?: any;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
}
|
||||
|
||||
const FooterStep = forwardRef<unknown, FooterStepProps>(
|
||||
({ initialData }, ref) => {
|
||||
({ initialData, onValidityChange }, ref) => {
|
||||
const { errors, handleSubmit, onSubmit, register } = useFooterStepPresenter(
|
||||
initialData,
|
||||
ref,
|
||||
onValidityChange,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,12 +9,14 @@ import useHeroStepPresenter from "./useHeroStepPresenter";
|
||||
|
||||
interface HeroStepProps {
|
||||
initialData?: any;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
}
|
||||
|
||||
const HeroStep = forwardRef<unknown, HeroStepProps>(({ initialData }, ref) => {
|
||||
const HeroStep = forwardRef<unknown, HeroStepProps>(({ initialData, onValidityChange }, ref) => {
|
||||
const { errors, handleSubmit, onSubmit, register } = useHeroStepPresenter(
|
||||
initialData,
|
||||
ref,
|
||||
onValidityChange,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -43,6 +43,8 @@ const MarketingForm = ({
|
||||
watchPageType,
|
||||
pageTypeErrors,
|
||||
isCompany,
|
||||
isCurrentStepValid,
|
||||
stepValidityHandlers,
|
||||
setValue,
|
||||
heroRef,
|
||||
chartRef,
|
||||
@@ -177,28 +179,53 @@ const MarketingForm = ({
|
||||
</div>
|
||||
|
||||
<div style={{ display: step === 0 ? "block" : "none" }}>
|
||||
<HeroStep ref={heroRef} initialData={formData.hero} />
|
||||
<HeroStep
|
||||
ref={heroRef}
|
||||
initialData={formData.hero}
|
||||
onValidityChange={stepValidityHandlers[0]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: step === 1 ? "block" : "none" }}>
|
||||
<ChartStep ref={chartRef} initialData={formData.chart} />
|
||||
<ChartStep
|
||||
ref={chartRef}
|
||||
initialData={formData.chart}
|
||||
onValidityChange={stepValidityHandlers[1]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: step === 2 ? "block" : "none" }}>
|
||||
<FeaturesStep ref={featuresRef} initialData={formData.features} />
|
||||
<FeaturesStep
|
||||
ref={featuresRef}
|
||||
initialData={formData.features}
|
||||
onValidityChange={stepValidityHandlers[2]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: step === 3 ? "block" : "none" }}>
|
||||
<ChallengesStep
|
||||
ref={challengesRef}
|
||||
initialData={formData.challenges}
|
||||
onValidityChange={stepValidityHandlers[3]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: step === 4 ? "block" : "none" }}>
|
||||
<BenefitsStep ref={benefitsRef} initialData={formData.advantages} />
|
||||
<BenefitsStep
|
||||
ref={benefitsRef}
|
||||
initialData={formData.advantages}
|
||||
onValidityChange={stepValidityHandlers[4]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: step === 5 ? "block" : "none" }}>
|
||||
<ContactsStep ref={contactsRef} initialData={formData.contact} />
|
||||
<ContactsStep
|
||||
ref={contactsRef}
|
||||
initialData={formData.contact}
|
||||
onValidityChange={stepValidityHandlers[5]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: step === 6 ? "block" : "none" }}>
|
||||
<FooterStep ref={footerRef} initialData={formData.footer} />
|
||||
<FooterStep
|
||||
ref={footerRef}
|
||||
initialData={formData.footer}
|
||||
onValidityChange={stepValidityHandlers[6]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Stepper
|
||||
@@ -207,6 +234,7 @@ const MarketingForm = ({
|
||||
onStepChange={handleStepChange}
|
||||
onSubmit={handleSubmitAll}
|
||||
isSubmitting={isPending}
|
||||
nextDisabled={!isCurrentStepValid}
|
||||
/>
|
||||
</ShowcaseSection>
|
||||
</>
|
||||
|
||||
@@ -3,10 +3,11 @@ import { TrashIcon } from "@/assets/icons";
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import { REGEX } from "@/constants/regex";
|
||||
import ImageUploadField from "@/components/ui/ImageUploadField";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
import {
|
||||
Control,
|
||||
Controller,
|
||||
FieldErrors,
|
||||
FieldValues,
|
||||
Path,
|
||||
@@ -16,6 +17,20 @@ import {
|
||||
UseFormRegister,
|
||||
} from "react-hook-form";
|
||||
|
||||
const ICON_ACCEPT =
|
||||
"image/png,image/jpeg,image/jpg,image/webp,image/svg+xml,image/gif";
|
||||
const ICON_ALLOWED_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
];
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||
|
||||
interface IProps<T extends FieldValues> {
|
||||
title: string;
|
||||
fields: any[];
|
||||
@@ -39,6 +54,7 @@ const SectionStep = <T extends FieldValues>({
|
||||
errors,
|
||||
append,
|
||||
remove,
|
||||
control,
|
||||
fieldName,
|
||||
defaultValues,
|
||||
}: IProps<T>) => {
|
||||
@@ -82,22 +98,30 @@ const SectionStep = <T extends FieldValues>({
|
||||
key={field.id}
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||
>
|
||||
<InputGroup
|
||||
{...register(`cards.${index}.icon` as Path<T>, {
|
||||
<Controller
|
||||
control={control}
|
||||
name={`cards.${index}.icon` as Path<T>}
|
||||
rules={{
|
||||
required: {
|
||||
message: FormErrorMessages.required,
|
||||
value: true,
|
||||
},
|
||||
pattern: {
|
||||
message: FormErrorMessages.invalidPattern,
|
||||
value: REGEX.URL,
|
||||
},
|
||||
})}
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<ImageUploadField
|
||||
value={(field.value as string) ?? ""}
|
||||
onChange={field.onChange}
|
||||
label="Icon"
|
||||
name={`cards.${index}.icon`}
|
||||
required
|
||||
type="text"
|
||||
placeholder="Enter Icon URL"
|
||||
inputId={`${slugify(title)}-card-icon-${index}`}
|
||||
title="Click to upload an icon"
|
||||
hint="PNG, JPG, WEBP, SVG, GIF up to 5MB"
|
||||
recommendation="Recommended size: 128 x 128"
|
||||
fallbackFileName="Current icon"
|
||||
accept={ICON_ACCEPT}
|
||||
allowedTypes={ICON_ALLOWED_TYPES}
|
||||
errorMessage={fieldState.error?.message as string}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<InputGroup
|
||||
{...register(`cards.${index}.title` as Path<T>, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { ForwardedRef, useImperativeHandle } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import useReportStepValidity from "./useReportStepValidity";
|
||||
|
||||
export interface IBenefitsStepFields {
|
||||
title: string;
|
||||
@@ -15,21 +16,25 @@ export interface IBenefitsStepFields {
|
||||
const useBenefitsStepPresenter = (
|
||||
initialData?: IBenefitsStepFields,
|
||||
ref?: ForwardedRef<unknown>,
|
||||
onValidityChange?: (valid: boolean) => void,
|
||||
) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
formState: { errors },
|
||||
formState: { errors, isValid },
|
||||
getValues,
|
||||
trigger,
|
||||
} = useForm<IBenefitsStepFields>({
|
||||
defaultValues: initialData || {
|
||||
cards: [{ icon: "", title: "", description: "" }],
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
useReportStepValidity(isValid, onValidityChange);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "cards",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { ForwardedRef, useImperativeHandle } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import useReportStepValidity from "./useReportStepValidity";
|
||||
|
||||
export interface IChallengesStepFields {
|
||||
title: string;
|
||||
@@ -15,21 +16,25 @@ export interface IChallengesStepFields {
|
||||
const useChallengesStepPresenter = (
|
||||
initialData?: IChallengesStepFields,
|
||||
ref?: ForwardedRef<unknown>,
|
||||
onValidityChange?: (valid: boolean) => void,
|
||||
) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
formState: { errors },
|
||||
formState: { errors, isValid },
|
||||
getValues,
|
||||
trigger,
|
||||
} = useForm<IChallengesStepFields>({
|
||||
defaultValues: initialData || {
|
||||
cards: [{ icon: "", title: "", description: "" }],
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
useReportStepValidity(isValid, onValidityChange);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "cards",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { ForwardedRef, useImperativeHandle } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import useReportStepValidity from "./useReportStepValidity";
|
||||
|
||||
export interface IChartStepFields {
|
||||
title: string;
|
||||
@@ -11,21 +12,25 @@ export interface IChartStepFields {
|
||||
const useChartStepPresenter = (
|
||||
initialData?: IChartStepFields,
|
||||
ref?: ForwardedRef<unknown>,
|
||||
onValidityChange?: (valid: boolean) => void,
|
||||
) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
formState: { errors },
|
||||
formState: { errors, isValid },
|
||||
getValues,
|
||||
trigger,
|
||||
} = useForm<IChartStepFields>({
|
||||
defaultValues: initialData || {
|
||||
data: [{ key: "", value: 0 }],
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
useReportStepValidity(isValid, onValidityChange);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "data",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { ForwardedRef, useImperativeHandle } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import useReportStepValidity from "./useReportStepValidity";
|
||||
|
||||
export interface IContactsStepFields {
|
||||
title: string;
|
||||
@@ -17,15 +18,17 @@ export interface IContactsStepFields {
|
||||
const useContactsStepPresenter = (
|
||||
initialData?: IContactsStepFields,
|
||||
ref?: ForwardedRef<unknown>,
|
||||
onValidityChange?: (valid: boolean) => void,
|
||||
) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
getValues,
|
||||
formState: { errors },
|
||||
formState: { errors, isValid },
|
||||
trigger,
|
||||
} = useForm<IContactsStepFields>({
|
||||
mode: "onChange",
|
||||
defaultValues: initialData || {
|
||||
fields: [
|
||||
{
|
||||
@@ -51,6 +54,8 @@ const useContactsStepPresenter = (
|
||||
name: "fields",
|
||||
});
|
||||
|
||||
useReportStepValidity(isValid, onValidityChange);
|
||||
|
||||
const onSubmit = (data: IContactsStepFields) => {
|
||||
console.log(data);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { ForwardedRef, useImperativeHandle } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import useReportStepValidity from "./useReportStepValidity";
|
||||
|
||||
export interface IFeaturesStepFields {
|
||||
title: string;
|
||||
@@ -15,21 +16,25 @@ export interface IFeaturesStepFields {
|
||||
const useFeaturesStepPresenter = (
|
||||
initialData?: IFeaturesStepFields,
|
||||
ref?: ForwardedRef<unknown>,
|
||||
onValidityChange?: (valid: boolean) => void,
|
||||
) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
formState: { errors },
|
||||
formState: { errors, isValid },
|
||||
getValues,
|
||||
trigger,
|
||||
} = useForm<IFeaturesStepFields>({
|
||||
defaultValues: initialData || {
|
||||
cards: [{ icon: "", title: "", description: "" }],
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
useReportStepValidity(isValid, onValidityChange);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "cards",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ForwardedRef, useImperativeHandle } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import useReportStepValidity from "./useReportStepValidity";
|
||||
|
||||
interface FooterFormValues {
|
||||
email: string;
|
||||
@@ -12,17 +13,21 @@ interface FooterFormValues {
|
||||
const useFooterStepPresenter = (
|
||||
initialData?: FooterFormValues,
|
||||
ref?: ForwardedRef<unknown>,
|
||||
onValidityChange?: (valid: boolean) => void,
|
||||
) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
formState: { errors, isValid },
|
||||
getValues,
|
||||
trigger,
|
||||
} = useForm<FooterFormValues>({
|
||||
defaultValues: initialData,
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
useReportStepValidity(isValid, onValidityChange);
|
||||
|
||||
const onSubmit = (data: FooterFormValues) => {
|
||||
console.log(data);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { ForwardedRef, useImperativeHandle } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import useReportStepValidity from "./useReportStepValidity";
|
||||
|
||||
export interface IHeroStepFields {
|
||||
title: string;
|
||||
@@ -13,17 +14,21 @@ export interface IHeroStepFields {
|
||||
const useHeroStepPresenter = (
|
||||
initialData?: IHeroStepFields,
|
||||
ref?: ForwardedRef<unknown>,
|
||||
onValidityChange?: (valid: boolean) => void,
|
||||
) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
formState: { errors, isValid },
|
||||
getValues,
|
||||
trigger,
|
||||
} = useForm<IHeroStepFields>({
|
||||
defaultValues: initialData,
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
useReportStepValidity(isValid, onValidityChange);
|
||||
|
||||
const onSubmit = (data: IHeroStepFields) => {
|
||||
console.log(data);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Step } from "@/components/ui/Stepper";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ interface IPageTypeFields {
|
||||
country?: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
const TOTAL_STEPS = 7;
|
||||
|
||||
const useMarketingForm = (
|
||||
initialData: any,
|
||||
onSubmit: (data: any) => void,
|
||||
@@ -20,6 +23,9 @@ const useMarketingForm = (
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [formData, setFormData] = useState<any>(initialData || {});
|
||||
const [stepValidity, setStepValidity] = useState<boolean[]>(() =>
|
||||
new Array(TOTAL_STEPS).fill(false),
|
||||
);
|
||||
const heroRef = useRef<any>(null);
|
||||
const chartRef = useRef<any>(null);
|
||||
const featuresRef = useRef<any>(null);
|
||||
@@ -31,12 +37,13 @@ const useMarketingForm = (
|
||||
const {
|
||||
register: registerPageType,
|
||||
watch: watchPageType,
|
||||
formState: { errors: pageTypeErrors },
|
||||
formState: { errors: pageTypeErrors, isValid: isPageTypeValid },
|
||||
getValues: getPageTypeValues,
|
||||
setValue,
|
||||
reset,
|
||||
trigger: triggerPageType,
|
||||
} = useForm<IPageTypeFields>({
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
type: "company",
|
||||
language: "en",
|
||||
@@ -46,6 +53,25 @@ const useMarketingForm = (
|
||||
},
|
||||
});
|
||||
|
||||
// Stable per-step callbacks so each step form can report its live validity
|
||||
// without recreating handlers (which would re-fire the reporting effect).
|
||||
const stepValidityHandlers = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
{ length: TOTAL_STEPS },
|
||||
(_, index) => (valid: boolean) =>
|
||||
setStepValidity((prev) => {
|
||||
if (prev[index] === valid) return prev;
|
||||
const next = [...prev];
|
||||
next[index] = valid;
|
||||
return next;
|
||||
}),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const isCurrentStepValid = isPageTypeValid && Boolean(stepValidity[step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const formValues = {
|
||||
@@ -245,6 +271,8 @@ const useMarketingForm = (
|
||||
watchPageType,
|
||||
pageTypeErrors,
|
||||
isCompany,
|
||||
isCurrentStepValid,
|
||||
stepValidityHandlers,
|
||||
setValue,
|
||||
heroRef,
|
||||
chartRef,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Bridges a step form's reactive `isValid` state up to the parent stepper.
|
||||
* Shared across every step presenter so the reporting logic lives in one place.
|
||||
*/
|
||||
const useReportStepValidity = (
|
||||
isValid: boolean,
|
||||
onValidityChange?: (valid: boolean) => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
onValidityChange?.(isValid);
|
||||
}, [isValid, onValidityChange]);
|
||||
};
|
||||
|
||||
export default useReportStepValidity;
|
||||
@@ -133,6 +133,20 @@ type PropsType<T extends FieldValues> = {
|
||||
searchQueryKey?: readonly unknown[];
|
||||
/** Loads options for the debounced search term. Required when `searchMode` is `"server"`. */
|
||||
searchFn?: (query: string) => Promise<ILabelValue[]>;
|
||||
/**
|
||||
* External lazy-load mode: the parent owns `items`, searching and pagination.
|
||||
* Providing `onSearch` and/or `onLoadMore` opts in — the dropdown renders a search
|
||||
* box, streams `items` as-is and requests more on scroll. Pairs with an infinite query.
|
||||
*/
|
||||
onSearch?: (query: string) => void;
|
||||
/** Fetch the next page; called when the option list is scrolled near its end. */
|
||||
onLoadMore?: () => void;
|
||||
/** Whether more pages are available to lazy-load. */
|
||||
hasMore?: boolean;
|
||||
/** Initial / search fetch in progress (external mode). */
|
||||
isLoading?: boolean;
|
||||
/** Next-page fetch in progress (external mode). */
|
||||
isLoadingMore?: boolean;
|
||||
} & (
|
||||
| { placeholder?: string; defaultValue?: string }
|
||||
| { placeholder: string; defaultValue?: string }
|
||||
@@ -163,11 +177,22 @@ export function Select<T extends FieldValues>({
|
||||
searchDebounceMs = 300,
|
||||
searchQueryKey,
|
||||
searchFn,
|
||||
onSearch,
|
||||
onLoadMore,
|
||||
hasMore = false,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
register: _register,
|
||||
...props
|
||||
}: PropsType<T>) {
|
||||
void _register;
|
||||
|
||||
/** Parent-controlled list: search + pagination handled outside, `items` consumed as-is. */
|
||||
const isExternal =
|
||||
typeof onSearch === "function" || typeof onLoadMore === "function";
|
||||
/** Both `searchable` and external mode render the in-dropdown search box. */
|
||||
const showSearch = searchable || isExternal;
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
searchable &&
|
||||
@@ -186,6 +211,7 @@ export function Select<T extends FieldValues>({
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue);
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
@@ -221,10 +247,21 @@ export function Select<T extends FieldValues>({
|
||||
});
|
||||
|
||||
const listItems = useMemo(() => {
|
||||
if (isExternal) return items;
|
||||
if (!searchable) return items;
|
||||
if (isServerMode && serverDisplayItems) return serverDisplayItems;
|
||||
return filterSelectItems(items, query);
|
||||
}, [searchable, isServerMode, serverDisplayItems, items, query]);
|
||||
}, [isExternal, searchable, isServerMode, serverDisplayItems, items, query]);
|
||||
|
||||
/** Lazy-load the next page once the option list nears its bottom (external mode). */
|
||||
const handleListScroll = useCallback(() => {
|
||||
if (!isExternal || !onLoadMore || !hasMore || isLoadingMore) return;
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 40) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [isExternal, onLoadMore, hasMore, isLoadingMore]);
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
const hit = listItems.find((i) => String(i.value) === String(value));
|
||||
@@ -287,13 +324,13 @@ export function Select<T extends FieldValues>({
|
||||
}, [open, listboxId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchable || !open) return;
|
||||
if (!showSearch || !open) return;
|
||||
const idFrame = window.requestAnimationFrame(() => {
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(idFrame);
|
||||
}, [open, searchable]);
|
||||
}, [open, showSearch]);
|
||||
|
||||
const emitChange = (next: string) => {
|
||||
if (controlledValue === undefined) {
|
||||
@@ -323,7 +360,9 @@ export function Select<T extends FieldValues>({
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
const dropdownItems = searchable ? listItems : items;
|
||||
const dropdownItems = showSearch ? listItems : items;
|
||||
/** Mono value chip next to labels only helps the client/server search modes, not external lists. */
|
||||
const showItemValue = searchable && !isExternal;
|
||||
|
||||
const dropdown =
|
||||
mounted &&
|
||||
@@ -345,7 +384,7 @@ export function Select<T extends FieldValues>({
|
||||
panelRect.placement === "above" ? "translateY(-100%)" : undefined,
|
||||
}}
|
||||
>
|
||||
{searchable ? (
|
||||
{showSearch ? (
|
||||
<div className="border-b border-stroke/50 p-2 dark:border-white/[0.08]">
|
||||
<label htmlFor={`${listboxId}-search`} className="sr-only">
|
||||
{searchPlaceholder}
|
||||
@@ -355,7 +394,10 @@ export function Select<T extends FieldValues>({
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
if (isExternal) onSearch?.(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
@@ -372,22 +414,27 @@ export function Select<T extends FieldValues>({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="max-h-[min(18rem,calc(100vh-10rem))] overflow-y-auto overscroll-contain p-1.5">
|
||||
{searchable && isSearchLoading ? (
|
||||
<ul
|
||||
ref={listRef}
|
||||
onScroll={isExternal ? handleListScroll : undefined}
|
||||
className="max-h-[min(18rem,calc(100vh-10rem))] overflow-y-auto overscroll-contain p-1.5"
|
||||
>
|
||||
{(isExternal ? isLoading : searchable && isSearchLoading) ? (
|
||||
<li className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-dark-5 dark:text-dark-6">
|
||||
<span className="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-primary/25 border-t-primary" />
|
||||
Searching…
|
||||
{isExternal ? "Loading…" : "Searching…"}
|
||||
</li>
|
||||
) : searchable && isSearchError ? (
|
||||
) : !isExternal && searchable && isSearchError ? (
|
||||
<li className="px-3 py-6 text-center text-sm text-error">
|
||||
Could not load options. Try again.
|
||||
</li>
|
||||
) : dropdownItems.length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-sm text-dark-5 dark:text-dark-6">
|
||||
{searchable ? "No matches." : "No options."}
|
||||
{showSearch ? "No matches." : "No options."}
|
||||
</li>
|
||||
) : (
|
||||
dropdownItems.map((item) => {
|
||||
<>
|
||||
{dropdownItems.map((item) => {
|
||||
const v = String(item.value);
|
||||
const isActive = v === String(value);
|
||||
return (
|
||||
@@ -401,14 +448,14 @@ export function Select<T extends FieldValues>({
|
||||
onClick={() => pickItem(v)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-start text-sm transition",
|
||||
searchable ? "justify-between" : "justify-start",
|
||||
showItemValue ? "justify-between" : "justify-start",
|
||||
isActive
|
||||
? "bg-primary/12 font-medium text-primary dark:bg-primary/20 dark:text-white"
|
||||
: "text-dark hover:bg-stroke/30 dark:text-white dark:hover:bg-white/[0.06]",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{item.label}</span>
|
||||
{searchable ? (
|
||||
{showItemValue ? (
|
||||
<span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6">
|
||||
{v}
|
||||
</span>
|
||||
@@ -416,7 +463,13 @@ export function Select<T extends FieldValues>({
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
})}
|
||||
{isExternal && isLoadingMore ? (
|
||||
<li className="flex items-center justify-center py-2">
|
||||
<span className="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-primary/25 border-t-primary" />
|
||||
</li>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>,
|
||||
@@ -508,7 +561,7 @@ export function Select<T extends FieldValues>({
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{value ? (
|
||||
searchable ? (
|
||||
showItemValue ? (
|
||||
<>
|
||||
<span className="text-dark dark:text-white">
|
||||
{selectedLabel}
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
DropdownTrigger,
|
||||
} from "@/components/ui/dropdown";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import Status from "@/components/ui/Status";
|
||||
import { useMarkNotificationAsSeen } from "@/hooks/queries/useNotificationQueries";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { TNotificationDetailsResponse } from "@/lib/api/types/NotificationHistory";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, sanitizeHtmlWithStyles } from "@/lib/utils";
|
||||
import { capitalize, unixToDate } from "@/utils/shared";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { BellIcon } from "./icons";
|
||||
@@ -24,10 +26,7 @@ export function Notification() {
|
||||
const [currentNotification, setCurrentNotification] =
|
||||
useState<TNotificationDetailsResponse | null>(null);
|
||||
|
||||
const { mutate } = useMarkNotificationAsSeen(
|
||||
currentNotification?.id ?? "",
|
||||
() => setIsModalOpen(false),
|
||||
);
|
||||
const { mutate } = useMarkNotificationAsSeen();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleNotificationClick = (
|
||||
@@ -36,7 +35,7 @@ export function Notification() {
|
||||
setCurrentNotification(notification);
|
||||
setIsModalOpen(true);
|
||||
setIsOpen(false);
|
||||
mutate();
|
||||
if (notification.id) mutate(notification.id);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -49,18 +48,20 @@ export function Notification() {
|
||||
}}
|
||||
>
|
||||
<DropdownTrigger
|
||||
className="grid size-12 place-items-center rounded-full border border-stroke/70 bg-white/75 text-dark shadow-theme-xs backdrop-blur-md outline-none transition-colors hover:text-primary focus-visible:border-primary focus-visible:text-primary dark:border-dark-3 dark:bg-dark-2/75 dark:text-white dark:focus-visible:border-primary"
|
||||
className="shadow-theme-xs grid size-12 place-items-center rounded-full border border-stroke/70 bg-white/75 text-dark outline-none backdrop-blur-md transition-colors hover:text-primary focus-visible:border-primary focus-visible:text-primary dark:border-dark-3 dark:bg-dark-2/75 dark:text-white dark:focus-visible:border-primary"
|
||||
aria-label="View Notifications"
|
||||
>
|
||||
<span className="relative">
|
||||
<BellIcon />
|
||||
{isDotVisible && (
|
||||
{isDotVisible && unreadNotificationCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"bg-red-light absolute right-0 top-0 z-1 size-2 rounded-full ring-2 ring-gray-2 dark:ring-dark-3",
|
||||
"absolute -right-1.5 -top-1.5 z-1 grid min-w-[18px] place-items-center rounded-full bg-gradient-to-br from-rose-500 to-red-600 px-1 text-[10px] font-bold leading-none text-white shadow-[0_2px_8px_rgba(239,68,68,0.55)] ring-2 ring-white dark:ring-dark-2",
|
||||
unreadNotificationCount > 9 ? "h-[18px]" : "size-[18px]",
|
||||
)}
|
||||
>
|
||||
<span className="bg-red-light absolute inset-0 -z-1 animate-ping rounded-full opacity-75" />
|
||||
<span className="absolute inset-0 -z-1 animate-ping rounded-full bg-rose-500/70" />
|
||||
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -68,13 +69,13 @@ export function Notification() {
|
||||
|
||||
<DropdownContent
|
||||
align={isMobile ? "end" : "center"}
|
||||
className="w-[15rem] max-w-[calc(100vw-1rem)] min-[360px]:w-[16rem] min-[390px]:w-[17rem] min-[430px]:w-[18rem] rounded-2xl border border-stroke/70 bg-white/95 px-2.5 py-3 shadow-theme-xs backdrop-blur-md sm:w-[22rem] sm:px-3.5 dark:border-dark-3 dark:bg-gray-dark/95"
|
||||
className="shadow-theme-xs w-[15rem] max-w-[calc(100vw-1rem)] rounded-2xl border border-stroke/70 bg-white/95 px-2.5 py-3 backdrop-blur-md dark:border-dark-3 dark:bg-gray-dark/95 min-[360px]:w-[16rem] min-[390px]:w-[17rem] min-[430px]:w-[18rem] sm:w-[22rem] sm:px-3.5"
|
||||
>
|
||||
<div className="mb-1 flex flex-wrap items-center justify-between gap-2 px-1 py-1.5 sm:px-2">
|
||||
<span className="text-base font-semibold text-dark sm:text-lg dark:text-white">
|
||||
<span className="text-base font-semibold text-dark dark:text-white sm:text-lg">
|
||||
Notifications
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-white/35 bg-gradient-to-br from-primary/85 to-primary/70 px-[9px] py-0.5 text-xs font-semibold text-white shadow-theme-xs dark:border-white/20">
|
||||
<span className="shadow-theme-xs shrink-0 rounded-full border border-white/35 bg-gradient-to-br from-primary/85 to-primary/70 px-[9px] py-0.5 text-xs font-semibold text-white dark:border-white/20">
|
||||
{unreadNotificationCount} new
|
||||
</span>
|
||||
</div>
|
||||
@@ -101,10 +102,86 @@ export function Notification() {
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
confirmText="Mark as read"
|
||||
classNames="relative !p-0 w-[34rem] max-w-[calc(100vw-2rem)] !rounded-3xl border border-stroke/60 dark:border-dark-3 [&>button]:absolute [&>button]:right-3 [&>button]:top-3 [&>button]:z-10 [&>button]:!mb-0 [&>button]:!text-white/80 [&>button]:hover:!text-white"
|
||||
>
|
||||
<div className="flex max-w-prose flex-col gap-3">
|
||||
<h2 className="font-lg font-bold">{currentNotification?.title}</h2>
|
||||
<p>{currentNotification?.message}</p>
|
||||
<div className="flex max-w-full flex-col">
|
||||
{/* Gradient header */}
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-primary via-primary to-blue-700 px-6 pb-6 pt-7">
|
||||
<div className="pointer-events-none absolute -right-8 -top-10 size-32 rounded-full bg-white/10 blur-md" />
|
||||
<div className="pointer-events-none absolute -bottom-12 -left-6 size-28 rounded-full bg-white/10 blur-md" />
|
||||
|
||||
<div className="relative flex items-start gap-4">
|
||||
<span className="grid size-12 shrink-0 place-items-center rounded-2xl bg-white/20 text-white shadow-theme-sm ring-1 ring-white/30 backdrop-blur-sm">
|
||||
<BellIcon />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||
{currentNotification?.priority && (
|
||||
<Status status={currentNotification.priority}>
|
||||
{capitalize(currentNotification.priority)}
|
||||
</Status>
|
||||
)}
|
||||
{currentNotification?.type && (
|
||||
<span className="rounded-full bg-white/20 px-2.5 py-0.5 text-xs font-medium text-white ring-1 ring-white/25">
|
||||
{capitalize(currentNotification.type)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-lg font-bold leading-snug text-white">
|
||||
{currentNotification?.title || "Notification"}
|
||||
</h2>
|
||||
{!!currentNotification?.created_at && (
|
||||
<p className="mt-1 text-xs font-medium text-white/75">
|
||||
{unixToDate({
|
||||
unix: currentNotification.created_at,
|
||||
hasTime: true,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col gap-4 px-6 py-5">
|
||||
{currentNotification?.image && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={currentNotification.image}
|
||||
alt={currentNotification.title || "Notification image"}
|
||||
className="max-h-52 w-full rounded-2xl object-cover ring-1 ring-stroke/60 dark:ring-dark-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="text-sm leading-relaxed text-dark-4 dark:text-dark-6 [&_a]:text-primary [&_a]:underline"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtmlWithStyles(
|
||||
currentNotification?.message || "No message content.",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-1 flex items-center justify-end gap-3 border-t border-stroke/60 pt-4 dark:border-dark-3">
|
||||
{currentNotification?.link && (
|
||||
<Link
|
||||
href={currentNotification.link}
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="rounded-xl border border-primary/40 bg-primary/5 px-4 py-2 text-sm font-semibold text-primary transition-colors hover:bg-primary/10"
|
||||
>
|
||||
Open link
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="rounded-xl bg-gradient-to-br from-primary to-blue-700 px-5 py-2 text-sm font-semibold text-white shadow-theme-sm transition-opacity hover:opacity-90"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -218,8 +218,15 @@ export const useAdminsPresenter = () => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
filterFormReset({
|
||||
...apiDefaultParams,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
search: "",
|
||||
status: "",
|
||||
});
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
}, [filterFormReset, pathName, router]);
|
||||
|
||||
const columns = [
|
||||
"row",
|
||||
|
||||
@@ -61,8 +61,9 @@ const useCmsTablePresenter = () => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
filterFormReset({ q: "", key: "" });
|
||||
router.push(pathname);
|
||||
}, [pathname, router]);
|
||||
}, [pathname, router, filterFormReset]);
|
||||
|
||||
const onConfirmDelete = () => {
|
||||
if (!currentCms?.id) return;
|
||||
|
||||
@@ -94,8 +94,9 @@ const useCompanyCategoriesPresenter = () => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
filterFormReset({ q: "", published: undefined });
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
}, [pathName, router, filterFormReset]);
|
||||
|
||||
const onConfirmDelete = () => {
|
||||
if (!currentCategory) return;
|
||||
|
||||
@@ -100,8 +100,15 @@ export const useCompanyListPresenter = () => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
filterFormReset({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
currency: undefined,
|
||||
employee_count: "",
|
||||
});
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
}, [pathName, router, filterFormReset]);
|
||||
|
||||
const onConfirmDelete = () => {
|
||||
if (currentCompany) {
|
||||
|
||||
@@ -90,8 +90,9 @@ export const useContactUsListPresenter = () => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
filterFormReset({ q: "", status: "" });
|
||||
router.push(pathname);
|
||||
}, [pathname, router]);
|
||||
}, [pathname, router, filterFormReset]);
|
||||
|
||||
const onConfirmDelete = () => {
|
||||
if (!currentContact) return;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||
import { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums";
|
||||
import { ILabelValue } from "@/types/shared";
|
||||
import { getEnumAsArray } from "@/utils/shared";
|
||||
import {
|
||||
FieldValues,
|
||||
@@ -22,11 +24,25 @@ interface CustomerListFiltersProps {
|
||||
watch: UseFormWatch<FieldValues>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
onClearAll: () => void;
|
||||
/** Lazy-loaded company options for the company dropdown. */
|
||||
companyItems: ILabelValue[];
|
||||
onCompanySearch: (query: string) => void;
|
||||
onCompanyLoadMore: () => void;
|
||||
companyHasMore?: boolean;
|
||||
companyIsLoading: boolean;
|
||||
companyIsLoadingMore: boolean;
|
||||
}
|
||||
|
||||
function countActive(values: Record<string, unknown>): number {
|
||||
let n = 0;
|
||||
for (const key of ["status", "role", "type"] as const) {
|
||||
for (const key of [
|
||||
"status",
|
||||
"role",
|
||||
"type",
|
||||
"full_name",
|
||||
"email",
|
||||
"company_id",
|
||||
] as const) {
|
||||
const v = values[key];
|
||||
if (v !== undefined && v !== null && v !== "" && String(v).trim()) n++;
|
||||
}
|
||||
@@ -41,6 +57,12 @@ const CustomerListFilters = ({
|
||||
watch,
|
||||
setValue,
|
||||
onClearAll,
|
||||
companyItems,
|
||||
onCompanySearch,
|
||||
onCompanyLoadMore,
|
||||
companyHasMore,
|
||||
companyIsLoading,
|
||||
companyIsLoadingMore,
|
||||
}: CustomerListFiltersProps) => {
|
||||
const watched = watch();
|
||||
const activeCount = useMemo(
|
||||
@@ -63,6 +85,36 @@ const CustomerListFilters = ({
|
||||
data-cy="customers-filter-form"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<InputGroup
|
||||
{...filterRegister("full_name")}
|
||||
name="full_name"
|
||||
label="Full name"
|
||||
type="text"
|
||||
placeholder="Search by full name"
|
||||
/>
|
||||
<InputGroup
|
||||
{...filterRegister("email")}
|
||||
name="email"
|
||||
label="Email"
|
||||
type="text"
|
||||
placeholder="Search by email"
|
||||
/>
|
||||
<Select
|
||||
{...filterRegister("company_id")}
|
||||
items={companyItems}
|
||||
label="Company"
|
||||
name="company_id"
|
||||
placeholder="Select company"
|
||||
clearable
|
||||
value={watch("company_id")}
|
||||
onClear={() => setValue("company_id", undefined)}
|
||||
searchPlaceholder="Search companies…"
|
||||
onSearch={onCompanySearch}
|
||||
onLoadMore={onCompanyLoadMore}
|
||||
hasMore={companyHasMore}
|
||||
isLoading={companyIsLoading}
|
||||
isLoadingMore={companyIsLoadingMore}
|
||||
/>
|
||||
<Select
|
||||
{...filterRegister("status")}
|
||||
items={getEnumAsArray(CustomerStatus)}
|
||||
|
||||
@@ -75,6 +75,12 @@ const CustomersTable = () => {
|
||||
openResetPasswordModalForCustomer,
|
||||
generatedPassword,
|
||||
isResettingPassword,
|
||||
companyItems,
|
||||
onCompanySearch,
|
||||
onCompanyLoadMore,
|
||||
companyHasMore,
|
||||
companyIsLoading,
|
||||
companyIsLoadingMore,
|
||||
} = useCustomerListPresenter();
|
||||
|
||||
return (
|
||||
@@ -88,6 +94,12 @@ const CustomersTable = () => {
|
||||
search={search}
|
||||
watch={watch}
|
||||
onClearAll={clearAllFilters}
|
||||
companyItems={companyItems}
|
||||
onCompanySearch={onCompanySearch}
|
||||
onCompanyLoadMore={onCompanyLoadMore}
|
||||
companyHasMore={companyHasMore}
|
||||
companyIsLoading={companyIsLoading}
|
||||
companyIsLoadingMore={companyIsLoadingMore}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<TableSortProvider
|
||||
@@ -206,7 +218,6 @@ const CustomersTable = () => {
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
|
||||
<span className="sr-only">Delete Company </span>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
@@ -219,7 +230,6 @@ const CustomersTable = () => {
|
||||
router.push(`${pathName}/edit/${customer.id}`)
|
||||
}
|
||||
>
|
||||
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
|
||||
<span className="sr-only">Edit Company </span>
|
||||
<PencilSquareIcon />
|
||||
</button>
|
||||
@@ -237,9 +247,6 @@ const CustomersTable = () => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
{..._TooltipDefaultParams({ id: "assign-company" })}
|
||||
/>
|
||||
<span className="sr-only">Assign To Company </span>
|
||||
<Building />
|
||||
</button>
|
||||
@@ -253,11 +260,6 @@ const CustomersTable = () => {
|
||||
openResetPasswordModalForCustomer(customer)
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
{..._TooltipDefaultParams({
|
||||
id: "reset-password",
|
||||
})}
|
||||
/>
|
||||
<span className="sr-only">
|
||||
Reset Customer Password
|
||||
</span>
|
||||
@@ -272,7 +274,6 @@ const CustomersTable = () => {
|
||||
router.push(`${pathName}/feedback/${customer.id}`);
|
||||
}}
|
||||
>
|
||||
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
|
||||
<ExclamationIcon />
|
||||
</button>
|
||||
</div>
|
||||
@@ -346,6 +347,11 @@ const CustomersTable = () => {
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
|
||||
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
|
||||
<Tooltip {..._TooltipDefaultParams({ id: "assign-company" })} />
|
||||
<Tooltip {..._TooltipDefaultParams({ id: "reset-password" })} />
|
||||
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
|
||||
</ListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { SortDirection } from "@/components/ui/tableSortContext";
|
||||
import {
|
||||
useAssignCompany,
|
||||
useCompaniesInfiniteQuery,
|
||||
useCompanyFullList,
|
||||
useCustomersQuery,
|
||||
useDeleteCustomerQuery,
|
||||
useResetCustomerPassword,
|
||||
} from "@/hooks/queries";
|
||||
import useDebounce from "@/hooks/useDebounce";
|
||||
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
|
||||
@@ -50,6 +52,35 @@ const useCustomerListPresenter = () => {
|
||||
setValue: setFilterValue,
|
||||
} = useForm();
|
||||
|
||||
// Lazy-loaded company options for the inline filter dropdown.
|
||||
const [companySearch, setCompanySearch] = useState("");
|
||||
const debouncedCompanySearch = useDebounce(companySearch, 400);
|
||||
const companiesQuery = useCompaniesInfiniteQuery(
|
||||
debouncedCompanySearch ? { search: debouncedCompanySearch } : undefined,
|
||||
);
|
||||
|
||||
const companyItems = useMemo(
|
||||
() =>
|
||||
companiesQuery.data?.pages.flatMap(
|
||||
(page) =>
|
||||
page.data.companies?.map((company) => ({
|
||||
label: company.name,
|
||||
value: company.id,
|
||||
})) ?? [],
|
||||
) ?? [],
|
||||
[companiesQuery.data],
|
||||
);
|
||||
|
||||
const onCompanySearch = useCallback((query: string) => {
|
||||
setCompanySearch(query);
|
||||
}, []);
|
||||
|
||||
const onCompanyLoadMore = useCallback(() => {
|
||||
if (companiesQuery.hasNextPage && !companiesQuery.isFetchingNextPage) {
|
||||
companiesQuery.fetchNextPage();
|
||||
}
|
||||
}, [companiesQuery]);
|
||||
|
||||
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
@@ -125,8 +156,17 @@ const useCustomerListPresenter = () => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
filterFormReset({
|
||||
status: undefined,
|
||||
role: undefined,
|
||||
type: undefined,
|
||||
full_name: undefined,
|
||||
email: undefined,
|
||||
company_id: undefined,
|
||||
});
|
||||
setCompanySearch("");
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
}, [pathName, router, filterFormReset]);
|
||||
|
||||
const columns = [
|
||||
"row",
|
||||
@@ -227,6 +267,13 @@ const useCustomerListPresenter = () => {
|
||||
openResetPasswordModalForCustomer,
|
||||
generatedPassword,
|
||||
isResettingPassword,
|
||||
companyItems,
|
||||
onCompanySearch,
|
||||
onCompanyLoadMore,
|
||||
companyHasMore: companiesQuery.hasNextPage,
|
||||
companyIsLoading:
|
||||
companiesQuery.isFetching && !companiesQuery.isFetchingNextPage,
|
||||
companyIsLoadingMore: companiesQuery.isFetchingNextPage,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -90,8 +90,9 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
filterFormReset({ q: "" });
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
}, [pathName, router, filterFormReset]);
|
||||
|
||||
const getShowcaseSectionTitle = () => {
|
||||
if (paramKey === "tender_id") {
|
||||
|
||||
@@ -88,8 +88,9 @@ const useInquiriesListPresenter = () => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
filterFormReset({ q: "", status: "" });
|
||||
router.push(pathname);
|
||||
}, [pathname, router]);
|
||||
}, [pathname, router, filterFormReset]);
|
||||
|
||||
const deleteInquiry = () => {
|
||||
if (!currentInquiry?.id) return;
|
||||
|
||||
@@ -83,8 +83,9 @@ const useNotificationHistoryTablePresenter = () => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
filterFormReset({ seen: undefined, search: "" });
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
}, [pathName, router, filterFormReset]);
|
||||
|
||||
return {
|
||||
notificationHistory: data?.data,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui-elements/button";
|
||||
import CollapsibleFilterSection from "@/components/ui/CollapsibleFilterSection";
|
||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||
import { Countries } from "@/constants/countries";
|
||||
import { isDateRangeActive } from "@/utils/shared";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Control,
|
||||
@@ -37,15 +38,6 @@ const RANGE_KEYS = [
|
||||
"submission_deadline_range",
|
||||
] as const;
|
||||
|
||||
function isRangeActive(value: unknown): boolean {
|
||||
if (!Array.isArray(value) || value.length === 0) return false;
|
||||
return value.some((x) => {
|
||||
if (x === undefined || x === null || x === "") return false;
|
||||
const n = typeof x === "number" ? x : Number(x);
|
||||
return Number.isFinite(n) && n > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function countActiveFilters(values: Record<string, unknown>): number {
|
||||
let n = 0;
|
||||
const textKeys = [
|
||||
@@ -66,7 +58,7 @@ function countActiveFilters(values: Record<string, unknown>): number {
|
||||
const country = values.country;
|
||||
if (typeof country === "string" && country.trim()) n++;
|
||||
for (const key of RANGE_KEYS) {
|
||||
if (isRangeActive(values[key])) n++;
|
||||
if (isDateRangeActive(values[key])) n++;
|
||||
}
|
||||
if (values.documents_scraped === true) n++;
|
||||
return n;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { ExclamationIcon, EyeIcon } from "@/assets/icons";
|
||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import Status from "@/components/ui/Status";
|
||||
import {
|
||||
Table,
|
||||
@@ -12,13 +16,10 @@ import {
|
||||
TableRow,
|
||||
TableSortProvider,
|
||||
} from "@/components/ui/table";
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { truncateString } from "@/utils/shared";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import TenderListFilters from "./TenderListFilters";
|
||||
import useTenderListPresenter from "./useTenderListPresenter";
|
||||
@@ -117,7 +118,18 @@ const TendersTable = () => {
|
||||
{getRowNumber(index)}
|
||||
</TableCell>
|
||||
<TableCell className="text-start" colSpan={100}>
|
||||
{item.title}
|
||||
{item.title.length > 40 ? (
|
||||
<span
|
||||
data-tooltip-id="tender-title"
|
||||
data-tooltip-content={item.title}
|
||||
data-tooltip-place="top"
|
||||
className="cursor-help underline decoration-dotted decoration-1 underline-offset-4"
|
||||
>
|
||||
{truncateString(item.title, 20, 20)}
|
||||
</span>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-start" colSpan={100}>
|
||||
{item.country_code}
|
||||
@@ -135,7 +147,10 @@ const TendersTable = () => {
|
||||
{formatDateTimeCell(item?.created_at ?? 0)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className={cn(`text capitalize`)} colSpan={100}>
|
||||
<TableCell
|
||||
className={cn(`text capitalize`)}
|
||||
colSpan={100}
|
||||
>
|
||||
<Status status={item.status}>{item.status}</Status>
|
||||
</TableCell>
|
||||
<TableCell className="capitalize" colSpan={100}>
|
||||
@@ -162,7 +177,9 @@ const TendersTable = () => {
|
||||
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
|
||||
onClick={() => navigateToTenderFeedback(item.id)}
|
||||
>
|
||||
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
|
||||
<Tooltip
|
||||
{..._TooltipDefaultParams({ id: "feedback" })}
|
||||
/>
|
||||
<ExclamationIcon />
|
||||
</button>
|
||||
</div>
|
||||
@@ -174,6 +191,17 @@ const TendersTable = () => {
|
||||
)}
|
||||
</Table>
|
||||
</TableSortProvider>
|
||||
<Tooltip
|
||||
{..._TooltipDefaultParams({
|
||||
id: "tender-title",
|
||||
styles: {
|
||||
maxWidth: "320px",
|
||||
whiteSpace: "normal",
|
||||
lineHeight: "1.5",
|
||||
textAlign: "start",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<IsVisible condition={shouldShowPagination}>
|
||||
<Pagination
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildQueryString,
|
||||
deleteEmptyKeys,
|
||||
getPaginatedRowNumber,
|
||||
isDateRangeActive,
|
||||
unixToDate,
|
||||
} from "@/utils/shared";
|
||||
import gsap from "gsap";
|
||||
@@ -293,6 +294,40 @@ const TENDER_FILTER_MODAL_KEYS = new Set<string>([
|
||||
...TENDER_RANGE_PARAM_KEYS,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Applying a date-range filter retargets the table sort to that same field, so the
|
||||
* results lead with the dates the user just narrowed to. Ordered by precedence — the
|
||||
* first active range wins when several are applied at once. `sortOrder` mirrors each
|
||||
* column's default click direction (see `columnDefaultOrder`).
|
||||
*/
|
||||
const SORT_TARGET_BY_RANGE_KEY: Array<{
|
||||
rangeKey: string;
|
||||
sortBy: string;
|
||||
sortOrder: SortDirection;
|
||||
}> = [
|
||||
{
|
||||
rangeKey: "publication_date_range",
|
||||
sortBy: "publication_date",
|
||||
sortOrder: "desc",
|
||||
},
|
||||
{
|
||||
rangeKey: "submission_deadline_range",
|
||||
sortBy: "submission_deadline",
|
||||
sortOrder: "asc",
|
||||
},
|
||||
{
|
||||
rangeKey: "tender_deadline_range",
|
||||
sortBy: "tender_deadline",
|
||||
sortOrder: "asc",
|
||||
},
|
||||
{ rangeKey: "created_at_range", sortBy: "created_at", sortOrder: "desc" },
|
||||
];
|
||||
|
||||
const resolveSortFromActiveDateFilters = (formData: Record<string, any>) =>
|
||||
SORT_TARGET_BY_RANGE_KEY.find((target) =>
|
||||
isDateRangeActive(formData[target.rangeKey]),
|
||||
) ?? null;
|
||||
|
||||
const useTenderListPresenter = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [currentTender, setCurrentTender] = useState<TCustomer | null>(null);
|
||||
@@ -482,7 +517,22 @@ const useTenderListPresenter = () => {
|
||||
delete merged[key];
|
||||
}
|
||||
}
|
||||
const newParams = buildFirstPageParams({ ...merged, ...normalizedData });
|
||||
|
||||
// Retarget the sort to a freshly applied date filter, but only while the user is
|
||||
// on the implicit default sort — never clobber a column they sorted on by hand.
|
||||
const sortTarget = resolveSortFromActiveDateFilters(data);
|
||||
const currentSortBy = params.sort_by as string | undefined;
|
||||
const isDefaultSort = !currentSortBy || currentSortBy === "created_at";
|
||||
const sortOverride =
|
||||
sortTarget && isDefaultSort
|
||||
? { sort_by: sortTarget.sortBy, sort_order: sortTarget.sortOrder }
|
||||
: {};
|
||||
|
||||
const newParams = buildFirstPageParams({
|
||||
...merged,
|
||||
...normalizedData,
|
||||
...sortOverride,
|
||||
});
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = buildQueryString(cleanedParams);
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
|
||||
@@ -10,6 +10,7 @@ import FormFooter from "@/components/ui/FormFooter";
|
||||
import { REGEX } from "@/constants/regex";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
import { ICreateCompanyCredentials } from "@/lib/api/types/Company";
|
||||
import CompanyDocuments from "./documents/CompanyDocuments";
|
||||
import useCreateCompanyPresenter from "./useCreateCompanyPresenter";
|
||||
|
||||
interface IProps {
|
||||
@@ -18,6 +19,12 @@ interface IProps {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** Inline error for nested fields whose dotted name can't use the inputs' built-in lookup. */
|
||||
const FieldError = ({ message }: { message?: string }) =>
|
||||
message ? (
|
||||
<p className="mt-1.5 text-sm font-medium text-error">{message}</p>
|
||||
) : null;
|
||||
|
||||
const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
const {
|
||||
errors,
|
||||
@@ -32,37 +39,41 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
defaultValues,
|
||||
id,
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid grid-cols-1 items-start gap-9 rounded-xl bg-gray-1 p-10 dark:bg-gray-7 md:grid-cols-2"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex flex-col gap-9">
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-bold text-dark dark:text-white">
|
||||
{editMode ? "Edit company" : "New company"}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-5 dark:text-dark-6">
|
||||
Profile, business details, address, tags, and supporting documents.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-2">
|
||||
<ShowcaseSection
|
||||
title="Company Information"
|
||||
className="flex flex-col gap-5"
|
||||
title="Company information"
|
||||
rootClassName="lg:col-span-2"
|
||||
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
|
||||
>
|
||||
<InputGroup
|
||||
{...register("name", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: FormErrorMessages.minLength(2),
|
||||
},
|
||||
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||
maxLength: {
|
||||
value: 200,
|
||||
message: FormErrorMessages.maxLength(200),
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
type="text"
|
||||
placeholder="Legal name as registered"
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.name.message}</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("email", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -71,15 +82,13 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
message: FormErrorMessages.invalidEmail,
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Email"
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("phone", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -96,19 +105,18 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
message: FormErrorMessages.invalidPattern,
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Phone"
|
||||
name="phone"
|
||||
required
|
||||
type="tel"
|
||||
placeholder="+971 50 123 4567"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.phone.message}</p>
|
||||
)}
|
||||
<Select
|
||||
{...register("type", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
})}
|
||||
errors={errors}
|
||||
name="type"
|
||||
label="Company type"
|
||||
required
|
||||
@@ -122,47 +130,6 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
defaultValue="Private"
|
||||
prefixIcon={<GlobeIcon />}
|
||||
/>
|
||||
{errors.type && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.type.message}</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("registration_number", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
|
||||
maxLength: {
|
||||
value: 50,
|
||||
message: FormErrorMessages.maxLength(50),
|
||||
},
|
||||
})}
|
||||
label="Registration Number"
|
||||
name="registration_number"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Registry or license number"
|
||||
/>
|
||||
{errors.registration_number && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.registration_number.message}
|
||||
</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("tax_id", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
|
||||
maxLength: {
|
||||
value: 50,
|
||||
message: FormErrorMessages.maxLength(50),
|
||||
},
|
||||
})}
|
||||
label="Tax id"
|
||||
name="tax_id"
|
||||
type="text"
|
||||
required
|
||||
placeholder="National tax identification number"
|
||||
/>
|
||||
{errors.tax_id && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.tax_id.message}</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("industry", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -172,17 +139,66 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
message: FormErrorMessages.maxLength(100),
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Industry"
|
||||
name="industry"
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. construction, software, logistics"
|
||||
/>
|
||||
{errors.industry && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.industry.message}
|
||||
</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("registration_number", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
|
||||
maxLength: {
|
||||
value: 50,
|
||||
message: FormErrorMessages.maxLength(50),
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Registration number"
|
||||
name="registration_number"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Registry or license number"
|
||||
/>
|
||||
<InputGroup
|
||||
{...register("tax_id", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
|
||||
maxLength: {
|
||||
value: 50,
|
||||
message: FormErrorMessages.maxLength(50),
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Tax id"
|
||||
name="tax_id"
|
||||
type="text"
|
||||
required
|
||||
placeholder="National tax identification number"
|
||||
/>
|
||||
<InputGroup
|
||||
{...register("website", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||
maxLength: {
|
||||
value: 100,
|
||||
message: FormErrorMessages.maxLength(100),
|
||||
},
|
||||
pattern: {
|
||||
value: REGEX.URL,
|
||||
message: FormErrorMessages.invalidPattern,
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Website"
|
||||
required
|
||||
name="website"
|
||||
type="text"
|
||||
placeholder="https://www.example.com"
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<TextAreaGroup
|
||||
label="Description"
|
||||
{...register("description", {
|
||||
@@ -195,38 +211,59 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
message: FormErrorMessages.maxLength(1000),
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
name="description"
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.description.message}
|
||||
</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("website", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
|
||||
maxLength: {
|
||||
value: 100,
|
||||
message: FormErrorMessages.maxLength(100),
|
||||
},
|
||||
})}
|
||||
label="Website"
|
||||
required
|
||||
name="website"
|
||||
type="url"
|
||||
placeholder="https://www.example.com"
|
||||
/>
|
||||
{errors.website && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.website.message}
|
||||
</p>
|
||||
)}
|
||||
</ShowcaseSection>
|
||||
|
||||
<ShowcaseSection title="Settings" className="grid grid-cols-1 gap-5">
|
||||
<ShowcaseSection
|
||||
title="Business information"
|
||||
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
|
||||
>
|
||||
<InputGroup
|
||||
{...register("employee_count", {
|
||||
min: { value: 1, message: FormErrorMessages.min(1) },
|
||||
max: { value: 100000, message: FormErrorMessages.max(100000) },
|
||||
})}
|
||||
errors={errors}
|
||||
label="Employee count"
|
||||
name="employee_count"
|
||||
type="number"
|
||||
/>
|
||||
<InputGroup
|
||||
{...register("annual_revenue", {
|
||||
min: { value: 0, message: FormErrorMessages.min(0) },
|
||||
max: {
|
||||
value: 999999999999,
|
||||
message: FormErrorMessages.max(999999999999),
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Annual revenue"
|
||||
name="annual_revenue"
|
||||
type="number"
|
||||
/>
|
||||
<InputGroup
|
||||
{...register("founded_year", {
|
||||
min: { value: 1800, message: FormErrorMessages.min(1800) },
|
||||
max: { value: 2100, message: FormErrorMessages.max(2100) },
|
||||
})}
|
||||
errors={errors}
|
||||
label="Founded year"
|
||||
name="founded_year"
|
||||
type="number"
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
</ShowcaseSection>
|
||||
|
||||
<ShowcaseSection
|
||||
title="Settings"
|
||||
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
|
||||
>
|
||||
<Select
|
||||
{...register("language")}
|
||||
errors={errors}
|
||||
name="language"
|
||||
label="Language"
|
||||
items={[
|
||||
@@ -242,13 +279,9 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
defaultValue="en"
|
||||
prefixIcon={<GlobeIcon />}
|
||||
/>
|
||||
{errors.language && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.language.message}
|
||||
</p>
|
||||
)}
|
||||
<Select
|
||||
{...register("currency")}
|
||||
errors={errors}
|
||||
name="currency"
|
||||
label="Currency"
|
||||
items={[
|
||||
@@ -266,11 +299,6 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
defaultValue="en"
|
||||
prefixIcon={<MoneyIcon />}
|
||||
/>
|
||||
{errors.currency && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.currency.message}
|
||||
</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("timezone", {
|
||||
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
|
||||
@@ -279,72 +307,19 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
message: FormErrorMessages.maxLength(50),
|
||||
},
|
||||
})}
|
||||
errors={errors}
|
||||
label="Timezone"
|
||||
name="timezone"
|
||||
type="text"
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
{errors.timezone && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.timezone.message}
|
||||
</p>
|
||||
)}
|
||||
</ShowcaseSection>
|
||||
</div>
|
||||
<div className="flex flex-col gap-9">
|
||||
|
||||
<ShowcaseSection
|
||||
title="Business Information"
|
||||
className="grid grid-cols-1 gap-5"
|
||||
title="Address"
|
||||
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
|
||||
>
|
||||
<InputGroup
|
||||
{...register("employee_count", {
|
||||
min: { value: 1, message: FormErrorMessages.min(1) },
|
||||
max: { value: 100000, message: FormErrorMessages.max(100000) },
|
||||
})}
|
||||
label="Employee Count"
|
||||
name="employee_count"
|
||||
type="number"
|
||||
/>
|
||||
{errors.employee_count && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.employee_count.message}
|
||||
</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("annual_revenue", {
|
||||
min: { value: 0, message: FormErrorMessages.min(0) },
|
||||
max: {
|
||||
value: 999999999999,
|
||||
message: FormErrorMessages.max(999999999999),
|
||||
},
|
||||
})}
|
||||
label="Annual Revenue"
|
||||
name="annual_revenue"
|
||||
type="number"
|
||||
/>
|
||||
{errors.annual_revenue && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.annual_revenue.message}
|
||||
</p>
|
||||
)}
|
||||
<InputGroup
|
||||
{...register("founded_year", {
|
||||
min: { value: 1800, message: FormErrorMessages.min(1800) },
|
||||
max: {
|
||||
value: 2100,
|
||||
message: FormErrorMessages.max(2100),
|
||||
},
|
||||
})}
|
||||
label="Founded Year"
|
||||
name="founded_year"
|
||||
type="number"
|
||||
/>
|
||||
{errors.founded_year && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.founded_year.message}
|
||||
</p>
|
||||
)}
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Address" className="grid grid-cols-1 gap-5">
|
||||
<div className="sm:col-span-2">
|
||||
<InputGroup
|
||||
{...register("address.street", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -363,11 +338,9 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
type="text"
|
||||
placeholder="Building, street, suite or unit"
|
||||
/>
|
||||
{errors.address?.street && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.address.street.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldError message={errors.address?.street?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<InputGroup
|
||||
{...register("address.city", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -386,11 +359,9 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
type="text"
|
||||
placeholder="City or town"
|
||||
/>
|
||||
{errors.address?.city && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.address.city.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldError message={errors.address?.city?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<InputGroup
|
||||
{...register("address.state", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -409,11 +380,9 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
required
|
||||
placeholder="State, province, or region"
|
||||
/>
|
||||
{errors.address?.state && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.address.state.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldError message={errors.address?.state?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<InputGroup
|
||||
{...register("address.postal_code", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -432,11 +401,9 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
required
|
||||
placeholder="Postal or ZIP code"
|
||||
/>
|
||||
{errors.address?.postal_code && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.address.postal_code.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldError message={errors.address?.postal_code?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<InputGroup
|
||||
{...register("address.country", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
@@ -455,13 +422,14 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
required
|
||||
placeholder="Full country name"
|
||||
/>
|
||||
{errors.address?.country && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.address.country.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldError message={errors.address?.country?.message} />
|
||||
</div>
|
||||
</ShowcaseSection>
|
||||
<ShowcaseSection title="Tags" className="grid grid-cols-1 gap-5">
|
||||
|
||||
<ShowcaseSection
|
||||
title="Tags"
|
||||
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
|
||||
>
|
||||
<TagInput
|
||||
{...register("tags.keywords")}
|
||||
name="tags.keywords"
|
||||
@@ -478,12 +446,10 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
label="Categories"
|
||||
items={
|
||||
companyCategories
|
||||
? companyCategories?.data?.categories?.map((c) => {
|
||||
return {
|
||||
? companyCategories?.data?.categories?.map((c) => ({
|
||||
label: c.name,
|
||||
value: c.id,
|
||||
};
|
||||
})
|
||||
}))
|
||||
: []
|
||||
}
|
||||
placeholder=""
|
||||
@@ -497,7 +463,6 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
setValue={setValue}
|
||||
placeholder=""
|
||||
/>
|
||||
|
||||
<TagInput
|
||||
{...register("tags.certifications")}
|
||||
label="Certifications"
|
||||
@@ -506,7 +471,7 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
setValue={setValue}
|
||||
placeholder=""
|
||||
/>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<TagInput
|
||||
{...register("tags.specializations")}
|
||||
label="Specializations"
|
||||
@@ -515,11 +480,16 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||
setValue={setValue}
|
||||
placeholder=""
|
||||
/>
|
||||
{errors.tags?.specializations && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{errors.tags.specializations.message}
|
||||
</p>
|
||||
)}
|
||||
<FieldError message={errors.tags?.specializations?.message} />
|
||||
</div>
|
||||
</ShowcaseSection>
|
||||
|
||||
<ShowcaseSection title="Documents" rootClassName="lg:col-span-2">
|
||||
<CompanyDocuments
|
||||
companyId={editMode ? id : undefined}
|
||||
value={watch("document_file_ids") ?? []}
|
||||
onChange={(ids) => setValue("document_file_ids", ids)}
|
||||
/>
|
||||
</ShowcaseSection>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
import { DOCUMENT_FILE_ACCEPT, MAX_DOCUMENT_FILES } from "@/utils/shared";
|
||||
import DocumentItem from "./DocumentItem";
|
||||
import useCompanyDocumentsPresenter from "./useCompanyDocumentsPresenter";
|
||||
|
||||
interface IProps {
|
||||
/** Present in edit mode; switches uploads to the company-documents endpoint. */
|
||||
companyId?: string;
|
||||
value: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
const CompanyDocuments = ({ companyId, value, onChange }: IProps) => {
|
||||
const {
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
failed,
|
||||
errorMessage,
|
||||
onSelectFiles,
|
||||
onRemove,
|
||||
} = useCompanyDocumentsPresenter({ companyId, value, onChange });
|
||||
|
||||
const inputId = "company-documents-input";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="relative overflow-hidden rounded-2xl border border-stroke/70 bg-gradient-to-br from-white via-white to-primary/[0.04] p-4 shadow-theme-xs dark:border-dark-3 dark:from-dark-2 dark:via-dark-2 dark:to-primary/[0.12]">
|
||||
<input
|
||||
id={inputId}
|
||||
type="file"
|
||||
multiple
|
||||
accept={DOCUMENT_FILE_ACCEPT}
|
||||
className="sr-only"
|
||||
onChange={onSelectFiles}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="group flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-stroke/80 bg-white/70 px-4 py-9 text-center transition-all duration-300 hover:-translate-y-0.5 hover:border-primary hover:bg-primary/[0.03] dark:border-dark-3 dark:bg-dark/20 dark:hover:bg-primary/[0.08]"
|
||||
>
|
||||
<div className="mb-3 rounded-2xl bg-primary/[0.12] p-3 text-primary shadow-theme-xs transition-transform duration-300 group-hover:scale-105">
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-dark dark:text-white">
|
||||
Click to upload documents
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-dark-5">
|
||||
PDF, Word, Excel, images, archives — up to 100MB each
|
||||
</p>
|
||||
<div className="mt-4 inline-flex items-center rounded-full border border-primary/20 bg-primary/[0.08] px-3 py-1 text-[11px] font-medium text-primary dark:border-primary/40 dark:bg-primary/[0.18]">
|
||||
Max {MAX_DOCUMENT_FILES} files per upload
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{isUploading ? (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-medium text-primary">
|
||||
Uploading documents… {companyId ? "" : `${uploadProgress}%`}
|
||||
</p>
|
||||
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-2 dark:bg-dark-3">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary to-purple-500 transition-all duration-300"
|
||||
style={{ width: companyId ? "100%" : `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm font-medium text-red-500">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
{value.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{value.map((fileId) => (
|
||||
<DocumentItem
|
||||
key={fileId}
|
||||
fileId={fileId}
|
||||
onRemove={onRemove}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-dark-5">No documents attached yet.</p>
|
||||
)}
|
||||
|
||||
{failed.length > 0 ? (
|
||||
<div className="rounded-xl border border-error/35 bg-error/5 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-error">
|
||||
Failed uploads
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{failed.map((item, index) => (
|
||||
<li key={`${item.filename}-${index}`} className="text-sm text-error">
|
||||
<span className="font-medium">{item.filename}</span>: {item.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyDocuments;
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
import { formatFileSize } from "@/utils/shared";
|
||||
import useDocumentItemPresenter from "./useDocumentItemPresenter";
|
||||
|
||||
interface IProps {
|
||||
fileId: string;
|
||||
/** When provided, a remove button is shown and invoked with the file id. */
|
||||
onRemove?: (fileId: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DocumentItem = ({ fileId, onRemove, disabled }: IProps) => {
|
||||
const { info, isLoading, isDownloading, download } =
|
||||
useDocumentItemPresenter(fileId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-stroke/70 bg-white/70 p-3 shadow-theme-xs dark:border-dark-3 dark:bg-dark/30">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.12] text-primary">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-dark dark:text-white">
|
||||
{isLoading ? "Loading…" : info?.filename || "Document"}
|
||||
</p>
|
||||
{info?.size ? (
|
||||
<p className="mt-0.5 text-xs text-dark-5">{formatFileSize(info.size)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={download}
|
||||
disabled={isDownloading}
|
||||
className="rounded-lg border border-stroke/80 bg-white px-3 py-1.5 text-xs font-semibold text-dark shadow-theme-xs transition-all hover:-translate-y-0.5 hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-3 dark:bg-dark-2 dark:text-white"
|
||||
>
|
||||
{isDownloading ? "Downloading…" : "Download"}
|
||||
</button>
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(fileId)}
|
||||
disabled={disabled}
|
||||
className="rounded-lg border border-error/35 bg-error/5 px-3 py-1.5 text-xs font-semibold text-error transition-all hover:-translate-y-0.5 hover:bg-error/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentItem;
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
import { useUploadCompanyDocuments } from "@/hooks/queries";
|
||||
import { fileService, FailedFile } from "@/lib/api";
|
||||
import {
|
||||
MAX_DOCUMENT_FILES,
|
||||
validateDocumentFile,
|
||||
} from "@/utils/shared";
|
||||
import { ChangeEvent, useState } from "react";
|
||||
|
||||
interface IProps {
|
||||
/** Present in edit mode (company exists); drives the upload strategy. */
|
||||
companyId?: string;
|
||||
/** Current document file ids held by the form. */
|
||||
value: string[];
|
||||
/** Persists the updated document file id list back to the form. */
|
||||
onChange: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the multi-file document upload lifecycle for the company form.
|
||||
*
|
||||
* - Edit mode (companyId set): uploads via `POST /companies/:id/documents`, which
|
||||
* appends + links files server-side and reports per-file failures.
|
||||
* - Create mode (no company yet): uploads via `POST /files/upload/batch` and
|
||||
* appends the returned ids to the form so they're sent with the create call.
|
||||
*/
|
||||
const useCompanyDocumentsPresenter = ({
|
||||
companyId,
|
||||
value,
|
||||
onChange,
|
||||
}: IProps) => {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [failed, setFailed] = useState<FailedFile[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>();
|
||||
|
||||
const { mutateAsync: uploadToCompany } = useUploadCompanyDocuments(
|
||||
companyId ?? "",
|
||||
);
|
||||
|
||||
const validateSelection = (files: File[]): string | null => {
|
||||
if (value.length + files.length > MAX_DOCUMENT_FILES) {
|
||||
return `You can attach at most ${MAX_DOCUMENT_FILES} documents.`;
|
||||
}
|
||||
for (const file of files) {
|
||||
const error = validateDocumentFile({ file });
|
||||
if (error) return `${file.name}: ${error}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
if (companyId) {
|
||||
const response = await uploadToCompany(files);
|
||||
onChange(response.data.document_file_ids ?? value);
|
||||
return response.data.failed ?? [];
|
||||
}
|
||||
|
||||
const result = await fileService.uploadBatch({
|
||||
files,
|
||||
category: "company_document",
|
||||
onProgress: setUploadProgress,
|
||||
});
|
||||
const newIds = (result.uploaded ?? []).map((item) => item.file_id);
|
||||
onChange([...value, ...newIds]);
|
||||
return result.failed ?? [];
|
||||
};
|
||||
|
||||
const onSelectFiles = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const input = event.target;
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
// Reset so selecting the same file again re-triggers change.
|
||||
input.value = "";
|
||||
if (!files.length) return;
|
||||
|
||||
const validationError = validateSelection(files);
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(undefined);
|
||||
setFailed([]);
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
setFailed(await uploadFiles(files));
|
||||
} catch (error) {
|
||||
console.error("ERROR caught while uploading company documents:", error);
|
||||
setErrorMessage("Failed to upload documents. Please try again.");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async (fileId: string) => {
|
||||
onChange(value.filter((id) => id !== fileId));
|
||||
// In create mode the file is an orphan in storage (not linked to any
|
||||
// company), so clean it up. In edit mode the removal is persisted by the
|
||||
// form's update (PUT replaces the full document list).
|
||||
if (!companyId) {
|
||||
try {
|
||||
await fileService.deleteFile(fileId);
|
||||
} catch (error) {
|
||||
console.error("ERROR caught while deleting orphan document:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
failed,
|
||||
errorMessage,
|
||||
onSelectFiles,
|
||||
onRemove,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCompanyDocumentsPresenter;
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
import { useFileInfo } from "@/hooks/queries";
|
||||
import { fileService } from "@/lib/api";
|
||||
import { saveBlobFromAxiosResponse } from "@/utils/shared";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Loads metadata for a single document file id and exposes a download action.
|
||||
* Keeps the `DocumentItem` view free of data-fetching / side effects.
|
||||
*/
|
||||
const useDocumentItemPresenter = (fileId: string) => {
|
||||
const { data: info, isLoading } = useFileInfo(fileId);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const download = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const response = await fileService.downloadFile(fileId);
|
||||
saveBlobFromAxiosResponse(response, info?.filename ?? fileId);
|
||||
} catch (error) {
|
||||
console.error("ERROR caught while downloading document:", error);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { info, isLoading, isDownloading, download };
|
||||
};
|
||||
|
||||
export default useDocumentItemPresenter;
|
||||
@@ -52,6 +52,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
||||
founded_year: data.founded_year ? +data.founded_year : 0,
|
||||
employee_count: data.employee_count ? +data.employee_count : 0,
|
||||
annual_revenue: data.annual_revenue ? +data.annual_revenue : 0,
|
||||
document_file_ids: data.document_file_ids ?? [],
|
||||
tags: {
|
||||
keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [],
|
||||
categories: data?.tags?.categories?.length
|
||||
|
||||
@@ -8,6 +8,10 @@ interface FileImageUploadCardProps {
|
||||
label: string;
|
||||
inputId: string;
|
||||
accept?: string;
|
||||
title?: string;
|
||||
hint?: string;
|
||||
recommendation?: string;
|
||||
fallbackFileName?: string;
|
||||
previewUrl?: string | null;
|
||||
selectedFileName?: string;
|
||||
selectedFileSize?: number | null;
|
||||
@@ -22,6 +26,10 @@ const FileImageUploadCard = ({
|
||||
label,
|
||||
inputId,
|
||||
accept = "image/png,image/jpeg,image/jpg,image/webp",
|
||||
title = "Click to upload your profile image",
|
||||
hint = "PNG, JPG, WEBP up to 5MB",
|
||||
recommendation = "Recommended size: 400 x 400",
|
||||
fallbackFileName = "Current profile image",
|
||||
previewUrl,
|
||||
selectedFileName,
|
||||
selectedFileSize,
|
||||
@@ -68,11 +76,11 @@ const FileImageUploadCard = ({
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-dark dark:text-white">
|
||||
Click to upload your profile image
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-dark-5">PNG, JPG, WEBP up to 5MB</p>
|
||||
<p className="mt-1 text-xs text-dark-5">{hint}</p>
|
||||
<div className="mt-4 inline-flex items-center rounded-full border border-primary/20 bg-primary/[0.08] px-3 py-1 text-[11px] font-medium text-primary dark:border-primary/40 dark:bg-primary/[0.18]">
|
||||
Recommended size: 400 x 400
|
||||
{recommendation}
|
||||
</div>
|
||||
</label>
|
||||
) : (
|
||||
@@ -97,7 +105,7 @@ const FileImageUploadCard = ({
|
||||
Ready
|
||||
</div>
|
||||
<p className="truncate text-sm font-semibold text-dark dark:text-white">
|
||||
{selectedFileName || "Current profile image"}
|
||||
{selectedFileName || fallbackFileName}
|
||||
</p>
|
||||
{selectedFileSize ? (
|
||||
<p className="mt-0.5 text-xs text-dark-5">
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
|
||||
import { useImageUploadFieldPresenter } from "@/components/ui/useImageUploadFieldPresenter";
|
||||
|
||||
interface IProps {
|
||||
value?: string;
|
||||
onChange: (url: string) => void;
|
||||
label: string;
|
||||
inputId: string;
|
||||
accept?: string;
|
||||
title?: string;
|
||||
hint?: string;
|
||||
recommendation?: string;
|
||||
fallbackFileName?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
allowedTypes?: string[];
|
||||
maxSize?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlled image upload field: renders the shared `FileImageUploadCard` and
|
||||
* owns the upload lifecycle via `useImageUploadFieldPresenter`. Stores the
|
||||
* resulting file URL through `onChange`.
|
||||
*/
|
||||
const ImageUploadField = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
inputId,
|
||||
accept,
|
||||
title,
|
||||
hint,
|
||||
recommendation,
|
||||
fallbackFileName,
|
||||
category,
|
||||
tags,
|
||||
description,
|
||||
allowedTypes,
|
||||
maxSize,
|
||||
errorMessage,
|
||||
}: IProps) => {
|
||||
const {
|
||||
fileRegister,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
selectedFileName,
|
||||
selectedFileSize,
|
||||
previewUrl,
|
||||
errorMessage: uploadError,
|
||||
clear,
|
||||
} = useImageUploadFieldPresenter({
|
||||
value,
|
||||
onChange,
|
||||
category,
|
||||
tags,
|
||||
description,
|
||||
allowedTypes,
|
||||
maxSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<FileImageUploadCard
|
||||
label={label}
|
||||
inputId={inputId}
|
||||
accept={accept}
|
||||
title={title}
|
||||
hint={hint}
|
||||
recommendation={recommendation}
|
||||
fallbackFileName={fallbackFileName}
|
||||
previewUrl={previewUrl}
|
||||
selectedFileName={selectedFileName}
|
||||
selectedFileSize={selectedFileSize}
|
||||
isLoading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
errorMessage={uploadError ?? errorMessage}
|
||||
onClear={clear}
|
||||
fileRegister={fileRegister}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploadField;
|
||||
@@ -22,6 +22,7 @@ interface StepperProps {
|
||||
onStepChange?: (step: number) => void;
|
||||
onSubmit?: () => void;
|
||||
isSubmitting?: boolean;
|
||||
nextDisabled?: boolean;
|
||||
}
|
||||
|
||||
const Stepper: React.FC<StepperProps> = ({
|
||||
@@ -30,6 +31,7 @@ const Stepper: React.FC<StepperProps> = ({
|
||||
onStepChange,
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
nextDisabled = false,
|
||||
}) => {
|
||||
const [internalStep, setInternalStep] = useState(0);
|
||||
const currentStep =
|
||||
@@ -213,6 +215,7 @@ const Stepper: React.FC<StepperProps> = ({
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (nextDisabled) return;
|
||||
if (currentStep === steps.length - 1) {
|
||||
if (onSubmit) onSubmit();
|
||||
} else if (currentStep < steps.length - 1) {
|
||||
@@ -425,20 +428,20 @@ const Stepper: React.FC<StepperProps> = ({
|
||||
onClick={nextStep}
|
||||
onMouseEnter={() => handleButtonHover(nextBtnRef.current, true)}
|
||||
onMouseLeave={() => handleButtonHover(nextBtnRef.current, false)}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || nextDisabled}
|
||||
className={`group relative inline-flex items-center gap-2 overflow-hidden rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all ${
|
||||
isSubmitting
|
||||
isSubmitting || nextDisabled
|
||||
? "cursor-not-allowed bg-gray-4"
|
||||
: "bg-gradient-to-r from-primary via-blue to-primary bg-[length:200%_100%] shadow-lg shadow-primary/30 hover:shadow-primary/50"
|
||||
}`}
|
||||
style={
|
||||
!isSubmitting
|
||||
!isSubmitting && !nextDisabled
|
||||
? { animation: "stepperShimmer 3s linear infinite" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Glow halo for submit */}
|
||||
{isLastStep && !isSubmitting && (
|
||||
{isLastStep && !isSubmitting && !nextDisabled && (
|
||||
<span
|
||||
ref={submitGlowRef}
|
||||
aria-hidden
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { fileService } from "@/lib/api";
|
||||
import { extractFileId } from "@/lib/api/config";
|
||||
import { userService } from "@/lib/api/services/user-services";
|
||||
import {
|
||||
ALLOWED_IMAGE_FILE_TYPES,
|
||||
MAX_IMAGE_FILE_SIZE,
|
||||
validateImageFile,
|
||||
} from "@/utils/shared";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
interface IProps {
|
||||
/** Currently stored URL for this field (empty string when none). */
|
||||
value?: string;
|
||||
/** Persist the uploaded file URL (or empty string when cleared). */
|
||||
onChange: (url: string) => void;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
allowedTypes?: string[];
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
interface IFileFieldValues {
|
||||
file?: FileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable upload lifecycle for a single image field that stores a URL string.
|
||||
* Generalizes the profile-image flow from `useCreateAdminPresenter` so any form
|
||||
* (e.g. marketing card icons) can drop in a `FileImageUploadCard`.
|
||||
*/
|
||||
export const useImageUploadFieldPresenter = ({
|
||||
value,
|
||||
onChange,
|
||||
category = "cms",
|
||||
tags = ["cms", "marketing"],
|
||||
description = "Marketing media",
|
||||
allowedTypes = ALLOWED_IMAGE_FILE_TYPES,
|
||||
maxSize = MAX_IMAGE_FILE_SIZE,
|
||||
}: IProps) => {
|
||||
const [selectedFileName, setSelectedFileName] = useState<string>("");
|
||||
const [selectedFileSize, setSelectedFileSize] = useState<number | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>();
|
||||
|
||||
const { register, setValue } = useForm<IFileFieldValues>({
|
||||
defaultValues: { file: undefined },
|
||||
});
|
||||
|
||||
// Build a previewable source from the stored value. Backend file URLs are
|
||||
// auth-protected, so fetch them through the proxy into a local object URL.
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setPreviewUrl(null);
|
||||
return;
|
||||
}
|
||||
if (!extractFileId(value)) {
|
||||
setPreviewUrl(value);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
userService
|
||||
.fetchFileObjectUrl(value)
|
||||
.then((objectUrl) => {
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
return;
|
||||
}
|
||||
setPreviewUrl(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the preview empty rather than showing a broken image.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
// Revoke local blob previews when they are replaced or unmounted.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const resetSelection = () => {
|
||||
setSelectedFileName("");
|
||||
setSelectedFileSize(null);
|
||||
setUploadProgress(0);
|
||||
};
|
||||
|
||||
const onFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
const input = event.target;
|
||||
|
||||
if (!file) {
|
||||
resetSelection();
|
||||
setErrorMessage(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateImageFile({ file, allowedTypes, maxSize });
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError);
|
||||
input.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(undefined);
|
||||
setSelectedFileName(file.name);
|
||||
setSelectedFileSize(file.size);
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
// Preview the local file directly while the upload runs.
|
||||
setPreviewUrl(URL.createObjectURL(file));
|
||||
|
||||
try {
|
||||
const url = await fileService.uploadFile({
|
||||
file,
|
||||
category,
|
||||
tags,
|
||||
description,
|
||||
onProgress: setUploadProgress,
|
||||
});
|
||||
setUploadProgress(100);
|
||||
onChange(url);
|
||||
} catch {
|
||||
resetSelection();
|
||||
setPreviewUrl(null);
|
||||
setValue("file", undefined);
|
||||
onChange("");
|
||||
setErrorMessage("Failed to upload image");
|
||||
input.value = "";
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setValue("file", undefined);
|
||||
resetSelection();
|
||||
setPreviewUrl(null);
|
||||
setErrorMessage(undefined);
|
||||
onChange("");
|
||||
};
|
||||
|
||||
const fileRegister = register("file", { onChange: onFileChange });
|
||||
|
||||
return {
|
||||
fileRegister,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
selectedFileName,
|
||||
selectedFileSize,
|
||||
previewUrl,
|
||||
errorMessage,
|
||||
clear,
|
||||
};
|
||||
};
|
||||
@@ -70,10 +70,10 @@ export const _TooltipDefaultParams = ({
|
||||
delayHide: 120,
|
||||
variant,
|
||||
className: "fancy-tooltip",
|
||||
border: `1px solid ${palette.border}`,
|
||||
style: {
|
||||
background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
|
||||
boxShadow: palette.shadow,
|
||||
border: `1px solid ${palette.border}`,
|
||||
...styles,
|
||||
} satisfies CSSProperties,
|
||||
};
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from "./useUsersQueries";
|
||||
export * from "./useAdminListQuery";
|
||||
export * from "./useSelectSearchQuery";
|
||||
export * from "./useDashboardQueries";
|
||||
export * from "./useFilesQueries";
|
||||
|
||||
@@ -64,6 +64,24 @@ export const useCompanyDetails = (id: string) => {
|
||||
queryFn: () => companiesService.companyDetails(id),
|
||||
});
|
||||
};
|
||||
export const useUploadCompanyDocuments = (id: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const mutationKey = useMemo(
|
||||
() => [API_ENDPOINTS.COMPANIES.DOCUMENTS(id), "upload-documents"],
|
||||
[id],
|
||||
);
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn: (files: File[]) =>
|
||||
companiesService.uploadDocuments({ id, files }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, id, "details"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteCompanyQuery = (successCallback?: () => void) => {
|
||||
const queryClient = useQueryClient();
|
||||
const mutationKey = useMemo(
|
||||
|
||||
@@ -124,6 +124,9 @@ export const useUpdateCustomer = (id: string) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [API_ENDPOINTS.CUSTOMERS.READ_ALL],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["GET ALL CUSTOMERS", id],
|
||||
});
|
||||
router.push("/customers");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,6 +29,18 @@ export const useDashboardTrendQuery = (params?: {
|
||||
});
|
||||
};
|
||||
|
||||
export const useDashboardStatisticsQuery = (params?: { days?: number }) => {
|
||||
const queryKey = useMemo(
|
||||
() => [API_ENDPOINTS.DASHBOARD.STATISTICS, params],
|
||||
[params],
|
||||
);
|
||||
return useQuery({
|
||||
queryKey,
|
||||
queryFn: () => dashboardService.statistics(params),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDashboardCountriesQuery = (params?: { limit?: number }) => {
|
||||
const queryKey = useMemo(
|
||||
() => [API_ENDPOINTS.DASHBOARD.COUNTRIES, params],
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { API_ENDPOINTS, fileService } from "@/lib/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
/** Fetches (and caches) metadata for a single stored file. */
|
||||
export const useFileInfo = (fileId: string, options?: { enabled?: boolean }) => {
|
||||
const queryKey = useMemo(() => [API_ENDPOINTS.FILES.INFO(fileId)], [fileId]);
|
||||
return useQuery({
|
||||
queryKey,
|
||||
queryFn: () => fileService.getFileInfo(fileId),
|
||||
enabled: (options?.enabled ?? true) && Boolean(fileId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
@@ -28,18 +28,11 @@ export const useGetMyNotifications = (params?: Record<string, any>) => {
|
||||
queryFn: () => notificationService.getMyNotifications(params),
|
||||
});
|
||||
};
|
||||
export const useMarkNotificationAsSeen = (
|
||||
id: string,
|
||||
successCallback?: () => void,
|
||||
) => {
|
||||
export const useMarkNotificationAsSeen = (successCallback?: () => void) => {
|
||||
const queryClient = useQueryClient();
|
||||
const mutationKey = useMemo(
|
||||
() => [API_ENDPOINTS.NOTIFICATIONS.MARK_AS_SEEN(id), id],
|
||||
[id],
|
||||
);
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn: () => notificationService.markNotificationAsSeen(id),
|
||||
mutationKey: ["MARK_NOTIFICATION_AS_SEEN"],
|
||||
mutationFn: (id: string) => notificationService.markNotificationAsSeen(id),
|
||||
onSuccess: () => {
|
||||
if (successCallback) {
|
||||
successCallback();
|
||||
|
||||
+27
-10
@@ -1,21 +1,38 @@
|
||||
import { API_ENDPOINTS } from "./endpoints";
|
||||
|
||||
const TEST_API_BASE_URL = "https://admin.opplenz.com/admin/v1";
|
||||
const PRODUCTION_API_BASE_URL = "https://admin.opplens.com/admin/v1";
|
||||
|
||||
/**
|
||||
* Absolute base URL of the backend admin API.
|
||||
* Absolute base URL of the backend admin API for the current deploy.
|
||||
*
|
||||
* Mirrors the rewrite target in `next.config.mjs` (`/api/proxy/* ->
|
||||
* https://admin.opplenz.com/admin/v1/*`). Axios calls use the relative
|
||||
* `/api/proxy/` base so the browser attaches auth headers same-origin, but
|
||||
* values that are persisted on the backend (e.g. a `profile_image` download
|
||||
* URL) must be absolute so they resolve from anywhere — not the proxy path.
|
||||
* Mirrors the host-based proxy in `next.config.mjs`:
|
||||
* - production (`opplens.com`) -> admin.opplens.com/admin/v1
|
||||
* - test (`opplenz.com`) + dev -> admin.opplenz.com/admin/v1
|
||||
*
|
||||
* Axios calls use the relative `/api/proxy/` base (so the browser attaches auth
|
||||
* headers same-origin and the rewrite picks the right backend), but values that
|
||||
* get persisted on the backend (e.g. a `profile_image` download URL) must be
|
||||
* absolute and domain-correct. Resolved at call time because the host is only
|
||||
* known in the browser.
|
||||
*/
|
||||
export const BACKEND_API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_API_BASE_URL ??
|
||||
"https://admin.opplenz.com/admin/v1";
|
||||
export const getBackendApiBaseUrl = (): string => {
|
||||
const override = process.env.NEXT_PUBLIC_BACKEND_API_BASE_URL;
|
||||
if (override) return override;
|
||||
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
/(^|\.)opplens\.com$/.test(window.location.hostname)
|
||||
) {
|
||||
return PRODUCTION_API_BASE_URL;
|
||||
}
|
||||
|
||||
return TEST_API_BASE_URL;
|
||||
};
|
||||
|
||||
/** Absolute, directly-loadable URL for a stored GridFS file. */
|
||||
export const buildFileDownloadUrl = (fileId: string) =>
|
||||
`${BACKEND_API_BASE_URL}/${API_ENDPOINTS.FILES.DOWNLOAD(fileId)}`;
|
||||
`${getBackendApiBaseUrl()}/${API_ENDPOINTS.FILES.DOWNLOAD(fileId)}`;
|
||||
|
||||
/** Pull the GridFS file id out of a stored download URL, if it is one. */
|
||||
export const extractFileId = (url: string): string | undefined =>
|
||||
|
||||
@@ -17,7 +17,10 @@ export const API_ENDPOINTS = {
|
||||
},
|
||||
FILES: {
|
||||
UPLOAD: "files/upload",
|
||||
UPLOAD_BATCH: "files/upload/batch",
|
||||
DOWNLOAD: (fileId: string) => `files/${fileId}/download`,
|
||||
INFO: (fileId: string) => `files/${fileId}/info`,
|
||||
DELETE: (fileId: string) => `files/${fileId}`,
|
||||
},
|
||||
PROFILE: {
|
||||
READ: "profile",
|
||||
@@ -29,6 +32,7 @@ export const API_ENDPOINTS = {
|
||||
UPDATE: (id: string) => `companies/${id}`,
|
||||
DELETE: (id: string) => `companies/${id}`,
|
||||
DETAILS: (id: string) => `companies/${id}`,
|
||||
DOCUMENTS: (id: string) => `companies/${id}/documents`,
|
||||
CATEGORIES: {
|
||||
READ_ALL: "company-categories",
|
||||
CREATE: "company-categories",
|
||||
@@ -82,6 +86,7 @@ export const API_ENDPOINTS = {
|
||||
DASHBOARD: {
|
||||
SUMMARY: "dashboard/summary",
|
||||
TREND: "dashboard/trend",
|
||||
STATISTICS: "dashboard/statistics",
|
||||
COUNTRIES: "dashboard/countries",
|
||||
NOTICE_TYPES: "dashboard/notice-types",
|
||||
CLOSING_SOON: "dashboard/closing-soon",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TCompaniesResponse,
|
||||
TCompanyCategoryApiResponse,
|
||||
TCreateCompanyCategoryCredentials,
|
||||
UploadDocumentsData,
|
||||
} from "../types";
|
||||
|
||||
import { ICreateCompanyCredentials } from "../types";
|
||||
@@ -51,6 +52,45 @@ export const companiesService = {
|
||||
const response = await api.get(API_ENDPOINTS.COMPANIES.DETAILS(id));
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Uploads documents and links them to a company in one step. New file IDs are
|
||||
* appended to the company's existing `document_file_ids` (server-side), and
|
||||
* any per-file failures are returned in `data.failed`.
|
||||
*/
|
||||
uploadDocuments: async ({
|
||||
id,
|
||||
files,
|
||||
onProgress,
|
||||
}: {
|
||||
id: string;
|
||||
files: File[];
|
||||
onProgress?: (percent: number) => void;
|
||||
}): Promise<ApiResponse<UploadDocumentsData>> => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append("files", file, file.name));
|
||||
|
||||
const response = await api.post(
|
||||
API_ENDPOINTS.COMPANIES.DOCUMENTS(id),
|
||||
formData,
|
||||
{
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (!progressEvent.total) return;
|
||||
onProgress?.(
|
||||
Math.round((progressEvent.loaded * 100) / progressEvent.total),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("ERROR caught in Companies Services Upload Documents:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
deleteCompany: async (id: string) => {
|
||||
try {
|
||||
return (await api.delete(API_ENDPOINTS.COMPANIES.DELETE(id))).data;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
TDashboardClosingSoon,
|
||||
TDashboardCountries,
|
||||
TDashboardNoticeTypes,
|
||||
TDashboardStatistics,
|
||||
TDashboardSummary,
|
||||
TDashboardTrend,
|
||||
} from "../types";
|
||||
@@ -33,6 +34,18 @@ export const dashboardService = {
|
||||
}
|
||||
},
|
||||
|
||||
statistics: async (params?: {
|
||||
days?: number;
|
||||
}): Promise<ApiResponse<TDashboardStatistics>> => {
|
||||
try {
|
||||
return (await api.get(API_ENDPOINTS.DASHBOARD.STATISTICS, { params }))
|
||||
.data;
|
||||
} catch (error) {
|
||||
console.error("ERROR caught in Dashboard Service => statistics:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
countries: async (params?: {
|
||||
limit?: number;
|
||||
}): Promise<ApiResponse<TDashboardCountries>> => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import api from "../axios";
|
||||
import { buildFileDownloadUrl } from "../config";
|
||||
import { API_ENDPOINTS } from "../endpoints";
|
||||
import { BatchUploadResult, FileInfo } from "../types";
|
||||
|
||||
interface IUploadFileParams {
|
||||
file: File;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
||||
interface IUploadBatchParams {
|
||||
files: File[];
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
||||
const trackUploadProgress =
|
||||
(onProgress?: (percent: number) => void) =>
|
||||
(progressEvent: { loaded: number; total?: number }) => {
|
||||
if (!progressEvent.total) return;
|
||||
onProgress?.(
|
||||
Math.round((progressEvent.loaded * 100) / progressEvent.total),
|
||||
);
|
||||
};
|
||||
|
||||
export const fileService = {
|
||||
/**
|
||||
* Uploads a binary file to the shared filestore endpoint and returns the
|
||||
* absolute, directly-loadable download URL for the stored file.
|
||||
*
|
||||
* Mirrors the two-step flow documented for CMS/marketing media: upload the
|
||||
* file, then persist the resulting URL on the entity (e.g. `card.icon`).
|
||||
*/
|
||||
uploadFile: async ({
|
||||
file,
|
||||
category,
|
||||
tags = [],
|
||||
description,
|
||||
onProgress,
|
||||
}: IUploadFileParams): Promise<string> => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (category) formData.append("category", category);
|
||||
tags.forEach((tag) => formData.append("tags", tag));
|
||||
if (description) formData.append("description", description);
|
||||
|
||||
const response = await api.post(API_ENDPOINTS.FILES.UPLOAD, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
onUploadProgress: trackUploadProgress(onProgress),
|
||||
});
|
||||
|
||||
const fileId = response?.data?.file_id as string | undefined;
|
||||
if (!fileId) {
|
||||
throw new Error("No file id returned from upload");
|
||||
}
|
||||
|
||||
return buildFileDownloadUrl(fileId);
|
||||
} catch (error) {
|
||||
console.error("ERROR caught in File Service Upload File", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Uploads multiple files in one request. Returns the batch body directly
|
||||
* (this endpoint is not wrapped in the `success`/`data` envelope).
|
||||
*/
|
||||
uploadBatch: async ({
|
||||
files,
|
||||
category,
|
||||
tags = [],
|
||||
description,
|
||||
onProgress,
|
||||
}: IUploadBatchParams): Promise<BatchUploadResult> => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append("files", file, file.name));
|
||||
if (category) formData.append("category", category);
|
||||
tags.forEach((tag) => formData.append("tags", tag));
|
||||
if (description) formData.append("description", description);
|
||||
|
||||
const response = await api.post(
|
||||
API_ENDPOINTS.FILES.UPLOAD_BATCH,
|
||||
formData,
|
||||
{
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
onUploadProgress: trackUploadProgress(onProgress),
|
||||
},
|
||||
);
|
||||
|
||||
return response.data as BatchUploadResult;
|
||||
} catch (error) {
|
||||
console.error("ERROR caught in File Service Upload Batch", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/** Normalized metadata for a stored file, tolerant of envelope/key variations. */
|
||||
getFileInfo: async (fileId: string): Promise<FileInfo> => {
|
||||
try {
|
||||
const response = await api.get(API_ENDPOINTS.FILES.INFO(fileId));
|
||||
const info = response.data?.data ?? response.data ?? {};
|
||||
|
||||
return {
|
||||
file_id: fileId,
|
||||
filename: info.filename ?? info.name ?? "Document",
|
||||
content_type: info.content_type ?? info.contentType ?? "",
|
||||
size: Number(info.size ?? info.length ?? 0),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("ERROR caught in File Service Get File Info", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/** Downloads a stored file as a blob (caller saves it). */
|
||||
downloadFile: (fileId: string) =>
|
||||
api.get<Blob>(API_ENDPOINTS.FILES.DOWNLOAD(fileId), {
|
||||
responseType: "blob",
|
||||
}),
|
||||
|
||||
/** Permanently removes a file from storage. */
|
||||
deleteFile: async (fileId: string) => {
|
||||
try {
|
||||
return (await api.delete(API_ENDPOINTS.FILES.DELETE(fileId))).data;
|
||||
} catch (error) {
|
||||
console.error("ERROR caught in File Service Delete File", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./companies-service";
|
||||
export * from "./dashboard-service";
|
||||
export * from "./file-service";
|
||||
export * from "./inquiries-service";
|
||||
export * from "./profile-service";
|
||||
export * from "./tenders-service";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import api from "../axios";
|
||||
import { buildFileDownloadUrl, extractFileId } from "../config";
|
||||
import { extractFileId } from "../config";
|
||||
import { API_ENDPOINTS } from "../endpoints";
|
||||
import { fileService } from "./file-service";
|
||||
import {
|
||||
ApiResponse,
|
||||
IAdminListResponse,
|
||||
@@ -128,34 +129,15 @@ export const userService = {
|
||||
onProgress?: (percent: number) => void;
|
||||
}) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("category", "profile_images");
|
||||
formData.append("tags", "user");
|
||||
formData.append("tags", "profile");
|
||||
formData.append("description", "Admin profile image");
|
||||
|
||||
const response = await api.post(API_ENDPOINTS.FILES.UPLOAD, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (!progressEvent.total) return;
|
||||
const percent = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total,
|
||||
);
|
||||
onProgress?.(percent);
|
||||
},
|
||||
});
|
||||
|
||||
const fileId = response?.data?.file_id as string | undefined;
|
||||
if (!fileId) {
|
||||
throw new Error("No file id returned from upload");
|
||||
}
|
||||
|
||||
// Persisted on the backend as the profile image, so it must be an
|
||||
// absolute, directly-loadable URL — not the frontend `/api/proxy` path.
|
||||
return buildFileDownloadUrl(fileId);
|
||||
return fileService.uploadFile({
|
||||
file,
|
||||
category: "profile_images",
|
||||
tags: ["user", "profile"],
|
||||
description: "Admin profile image",
|
||||
onProgress,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("ERROR caught in User Service Upload Profile Image", error);
|
||||
throw error;
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface ICreateCompanyCredentials {
|
||||
address: IAddress;
|
||||
contact_person: IContactPerson;
|
||||
tags: ITags;
|
||||
document_file_ids?: string[];
|
||||
}
|
||||
|
||||
export const companySchema = z.object({
|
||||
@@ -90,6 +91,7 @@ export const companySchema = z.object({
|
||||
certifications: z.array(z.string()).optional(),
|
||||
cpv_codes: z.array(z.string()).optional(),
|
||||
}),
|
||||
document_file_ids: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const companiesResponseSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/** A file that uploaded successfully via a batch / company-documents endpoint. */
|
||||
export interface UploadedFile {
|
||||
file_id: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
size: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** A file that failed to upload, surfaced so the user can retry it. */
|
||||
export interface FailedFile {
|
||||
filename: string;
|
||||
error: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** `data` payload of `POST /companies/:id/documents` (wrapped in ApiResponse). */
|
||||
export interface UploadDocumentsData {
|
||||
document_file_ids: string[];
|
||||
uploaded: UploadedFile[];
|
||||
failed?: FailedFile[];
|
||||
}
|
||||
|
||||
/** Body of `POST /files/upload/batch` — returned directly, not wrapped. */
|
||||
export interface BatchUploadResult {
|
||||
uploaded: UploadedFile[];
|
||||
failed?: FailedFile[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Normalized metadata for a stored file (`GET /files/:id/info`). */
|
||||
export interface FileInfo {
|
||||
file_id: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
size: number;
|
||||
}
|
||||
@@ -21,6 +21,24 @@ export type TDashboardTrend = {
|
||||
series: TDashboardTrendPoint[];
|
||||
};
|
||||
|
||||
export type TDashboardStatisticsDaily = {
|
||||
scraped_ted: TDashboardTrendPoint[];
|
||||
scraped_documents: TDashboardTrendPoint[];
|
||||
translated_notices: TDashboardTrendPoint[];
|
||||
};
|
||||
|
||||
export type TDashboardStatisticsTotals = {
|
||||
scraped_documents: number;
|
||||
translated_tenders: number;
|
||||
};
|
||||
|
||||
export type TDashboardStatistics = {
|
||||
days: number;
|
||||
generated_at: number;
|
||||
daily: TDashboardStatisticsDaily;
|
||||
totals: TDashboardStatisticsTotals;
|
||||
};
|
||||
|
||||
export type TDashboardCountryItem = {
|
||||
country_code: string;
|
||||
count: number;
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface ICreateCompanyCredentials {
|
||||
address: IAddress;
|
||||
contact_person?: IContactPerson;
|
||||
tags: ITags;
|
||||
document_file_ids?: string[];
|
||||
}
|
||||
|
||||
export const companySchema = z.object({
|
||||
@@ -92,6 +93,7 @@ export const companySchema = z.object({
|
||||
certifications: z.array(z.string()).or(z.null()).optional(),
|
||||
cpv_codes: z.array(z.string()).or(z.null()).optional(),
|
||||
}),
|
||||
document_file_ids: z.array(z.string()).or(z.null()).optional(),
|
||||
});
|
||||
|
||||
export const companiesResponseSchema = z.object({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./CompanyCategories";
|
||||
export * from "./CompanyDocuments";
|
||||
export * from "./Customers";
|
||||
export * from "./Factory";
|
||||
export * from "./Posts";
|
||||
|
||||
@@ -1,106 +1,127 @@
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
import {
|
||||
AxiosError,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "react-toastify";
|
||||
import { getLogoutCallback } from "../contexts/User.ctx";
|
||||
import { API_ENDPOINTS, api } from "../lib/api";
|
||||
import { COOKIE_KEYS } from "../lib/shared/cookies";
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
const SIGN_IN_PATH = "/auth/sign-in";
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
type RetriableRequest = InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
interface TokenPair {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
interface ApiErrorData {
|
||||
message?: string;
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface QueuedRequest {
|
||||
resolve: (token: string) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refresh-token queue: requests that arrive mid-refresh wait here and replay
|
||||
// once a fresh access token is available (or fail together if refresh fails).
|
||||
// ---------------------------------------------------------------------------
|
||||
let isRefreshing = false;
|
||||
let failedQueue: QueuedRequest[] = [];
|
||||
|
||||
const processQueue = (error: unknown, token: string | null = null) => {
|
||||
failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
reject(error);
|
||||
} else if (token) {
|
||||
resolve(token);
|
||||
}
|
||||
});
|
||||
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
export const responseInterceptor = (response: AxiosResponse) => {
|
||||
return response;
|
||||
const queueRequest = (
|
||||
originalRequest: RetriableRequest,
|
||||
): Promise<AxiosResponse> =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return api(originalRequest);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session / auth helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const clearLocalSession = () => {
|
||||
Cookies.remove(COOKIE_KEYS.access_token);
|
||||
Cookies.remove(COOKIE_KEYS.refresh_token);
|
||||
Cookies.remove(COOKIE_KEYS.user);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("auth-token");
|
||||
localStorage.removeItem("user-data");
|
||||
}
|
||||
};
|
||||
|
||||
export const responseErrorHandler = async (error: AxiosError) => {
|
||||
const originalRequest = error.config as any;
|
||||
const redirectToSignIn = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = SIGN_IN_PATH;
|
||||
}
|
||||
};
|
||||
|
||||
// Skip token refresh for login endpoint - let login errors pass through
|
||||
const isLoginRequest = originalRequest?.url?.includes(
|
||||
API_ENDPOINTS.USER.LOGIN,
|
||||
);
|
||||
|
||||
const isRefreshTokenRequest = originalRequest?.url?.includes(
|
||||
API_ENDPOINTS.USER.REFRESH_TOKEN,
|
||||
);
|
||||
|
||||
// If we get a 401 on refresh token endpoint, logout immediately (token refresh failed)
|
||||
if (error.response?.status === 401 && isRefreshTokenRequest) {
|
||||
/** Logs the user out via the registered callback, falling back to a manual reset. */
|
||||
const forceLogout = () => {
|
||||
const logout = getLogoutCallback();
|
||||
if (logout) {
|
||||
logout();
|
||||
} else {
|
||||
// Fallback if logout callback is not registered
|
||||
Cookies.remove(COOKIE_KEYS.access_token);
|
||||
Cookies.remove(COOKIE_KEYS.refresh_token);
|
||||
Cookies.remove(COOKIE_KEYS.user);
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/auth/sign-in";
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!isLoginRequest
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token: string) => {
|
||||
originalRequest.headers["Authorization"] = "Bearer " + token;
|
||||
return api(originalRequest);
|
||||
})
|
||||
.catch((err) => {
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}
|
||||
clearLocalSession();
|
||||
redirectToSignIn();
|
||||
};
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error reporting
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Builds the message shown in the error toast, appending the API-provided
|
||||
* `details` (e.g. field-level validation hints) to the main error message
|
||||
* when available.
|
||||
*/
|
||||
const buildErrorMessage = (data: unknown, fallback: string): string => {
|
||||
const payload = data as ApiErrorData | undefined;
|
||||
const message = payload?.error?.message || payload?.message || fallback;
|
||||
const details = payload?.error?.details;
|
||||
|
||||
try {
|
||||
const refreshToken = Cookies.get(COOKIE_KEYS.refresh_token);
|
||||
if (!refreshToken) {
|
||||
// No refresh token available, logout immediately
|
||||
const logout = getLogoutCallback();
|
||||
if (logout) {
|
||||
logout();
|
||||
} else {
|
||||
// Fallback if logout callback is not registered
|
||||
Cookies.remove(COOKIE_KEYS.access_token);
|
||||
Cookies.remove(COOKIE_KEYS.refresh_token);
|
||||
Cookies.remove(COOKIE_KEYS.user);
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/auth/sign-in";
|
||||
}
|
||||
}
|
||||
return Promise.reject(new Error("Refresh token not found."));
|
||||
}
|
||||
return details ? `${message}: ${details}` : message;
|
||||
};
|
||||
|
||||
const notifyError = (error: unknown) => {
|
||||
const data = (error as AxiosError).response?.data;
|
||||
toast.error(buildErrorMessage(data, (error as Error).message));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
const requestNewAccessToken = async (refreshToken: string): Promise<string> => {
|
||||
const response = await api.post(API_ENDPOINTS.USER.REFRESH_TOKEN, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token } = response.data.data as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
const { access_token, refresh_token } = response.data.data as TokenPair;
|
||||
|
||||
if (!access_token || !refresh_token) {
|
||||
throw new Error("Invalid token received from refresh endpoint.");
|
||||
@@ -109,52 +130,79 @@ export const responseErrorHandler = async (error: AxiosError) => {
|
||||
Cookies.set(COOKIE_KEYS.access_token, access_token);
|
||||
Cookies.set(COOKIE_KEYS.refresh_token, refresh_token);
|
||||
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return access_token;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a 401 by refreshing the access token and replaying the request.
|
||||
* Concurrent requests are queued until the in-flight refresh settles.
|
||||
*/
|
||||
const handleUnauthorized = async (originalRequest: RetriableRequest) => {
|
||||
if (isRefreshing) {
|
||||
return queueRequest(originalRequest);
|
||||
}
|
||||
processQueue(null, access_token);
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = Cookies.get(COOKIE_KEYS.refresh_token);
|
||||
if (!refreshToken) {
|
||||
forceLogout();
|
||||
return Promise.reject(new Error("Refresh token not found."));
|
||||
}
|
||||
|
||||
const accessToken = await requestNewAccessToken(refreshToken);
|
||||
processQueue(null, accessToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
|
||||
// Use logout callback from User context
|
||||
const logout = getLogoutCallback();
|
||||
if (logout) {
|
||||
logout();
|
||||
} else {
|
||||
// Fallback if logout callback is not registered
|
||||
Cookies.remove(COOKIE_KEYS.access_token);
|
||||
Cookies.remove(COOKIE_KEYS.refresh_token);
|
||||
Cookies.remove(COOKIE_KEYS.user);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("auth-token");
|
||||
localStorage.removeItem("user-data");
|
||||
window.location.href = "/auth/sign-in";
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
(
|
||||
(refreshError as AxiosError).response?.data as {
|
||||
message: string;
|
||||
error: { code: string; message: string };
|
||||
}
|
||||
)?.error?.message || (refreshError as Error).message;
|
||||
toast.error(errorMessage);
|
||||
|
||||
forceLogout();
|
||||
notifyError(refreshError);
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request predicates
|
||||
// ---------------------------------------------------------------------------
|
||||
const matchesEndpoint = (request: RetriableRequest | undefined, endpoint: string) =>
|
||||
request?.url?.includes(endpoint) ?? false;
|
||||
|
||||
// Login errors must surface to the caller instead of triggering a refresh.
|
||||
const isLoginRequest = (request?: RetriableRequest) =>
|
||||
matchesEndpoint(request, API_ENDPOINTS.USER.LOGIN);
|
||||
|
||||
const isRefreshTokenRequest = (request?: RetriableRequest) =>
|
||||
matchesEndpoint(request, API_ENDPOINTS.USER.REFRESH_TOKEN);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interceptors
|
||||
// ---------------------------------------------------------------------------
|
||||
export const responseInterceptor = (response: AxiosResponse) => response;
|
||||
|
||||
export const responseErrorHandler = (error: AxiosError) => {
|
||||
const originalRequest = error.config as RetriableRequest | undefined;
|
||||
const isUnauthorized = error.response?.status === 401;
|
||||
|
||||
// A 401 from the refresh endpoint itself means the session is unrecoverable.
|
||||
if (isUnauthorized && isRefreshTokenRequest(originalRequest)) {
|
||||
forceLogout();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
(
|
||||
error.response?.data as {
|
||||
message: string;
|
||||
error: { code: string; message: string };
|
||||
if (
|
||||
isUnauthorized &&
|
||||
originalRequest &&
|
||||
!originalRequest._retry &&
|
||||
!isLoginRequest(originalRequest)
|
||||
) {
|
||||
return handleUnauthorized(originalRequest);
|
||||
}
|
||||
)?.error?.message || error.message;
|
||||
toast.error(errorMessage);
|
||||
|
||||
notifyError(error);
|
||||
return Promise.reject(error);
|
||||
};
|
||||
|
||||
@@ -139,6 +139,16 @@ export const buildQueryString = (params: Record<string, any>): string => {
|
||||
export const serializeAxiosParams = buildQueryString;
|
||||
export const isValidAlpha2CountryCode = (code: string) => /^[A-Za-z]{2}$/.test(code);
|
||||
|
||||
/** True when a date-range filter value holds at least one usable (positive, finite) unix bound. */
|
||||
export const isDateRangeActive = (value: unknown): boolean => {
|
||||
if (!Array.isArray(value) || value.length === 0) return false;
|
||||
return value.some((bound) => {
|
||||
if (bound === undefined || bound === null || bound === "") return false;
|
||||
const n = typeof bound === "number" ? bound : Number(bound);
|
||||
return Number.isFinite(n) && n > 0;
|
||||
});
|
||||
};
|
||||
|
||||
export const getPaginatedRowNumber = ({
|
||||
index,
|
||||
page = 1,
|
||||
@@ -183,6 +193,59 @@ export const validateImageFile = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Company document upload limits (see company-documents API guide). */
|
||||
export const ALLOWED_DOCUMENT_FILE_TYPES = [
|
||||
// Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
// Documents
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
// Text / data
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
"application/json",
|
||||
// Archives
|
||||
"application/zip",
|
||||
"application/x-rar-compressed",
|
||||
"application/x-7z-compressed",
|
||||
];
|
||||
|
||||
export const MAX_DOCUMENT_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
export const MAX_DOCUMENT_FILES = 20;
|
||||
|
||||
/** `accept` attribute mirroring the allowed document MIME types. */
|
||||
export const DOCUMENT_FILE_ACCEPT =
|
||||
".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.webp,.svg,.txt,.csv,.json,.zip,.rar,.7z";
|
||||
|
||||
export const validateDocumentFile = ({
|
||||
file,
|
||||
allowedTypes = ALLOWED_DOCUMENT_FILE_TYPES,
|
||||
maxSize = MAX_DOCUMENT_FILE_SIZE,
|
||||
}: {
|
||||
file: File;
|
||||
allowedTypes?: string[];
|
||||
maxSize?: number;
|
||||
}): string | null => {
|
||||
// Browsers occasionally send an empty type; the server then infers it from
|
||||
// the file extension, so only reject explicitly disallowed types.
|
||||
if (file.type && !allowedTypes.includes(file.type)) {
|
||||
return `Unsupported file type: ${file.type}`;
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
return "File size must be 100MB or less";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
|
||||
Reference in New Issue
Block a user