Compare commits

..

10 Commits

Author SHA1 Message Date
AmirReza Jamali a62963a93d chore(env): update application version to 2.3.1 in production environment
feat(customers): enhance customer list filters with lazy-loaded company options

- Added InputGroup components for searching by full name and email in CustomerListFilters.
- Integrated Select component for company selection with lazy-loading capabilities.
- Implemented company search and load more functionality using useCompaniesInfiniteQuery.
- Updated useCustomerListPresenter to manage company search state and loading indicators.
- Enhanced customer list filtering experience by allowing dynamic company selection.
2026-06-08 12:48:04 +03:30
AmirReza Jamali 72948a9b55 feat(dashboard): add daily trend chart and statistics section to dashboard
- Introduced DailyTrendChart component for visualizing daily scraped TED notices, documents, and translated notices.
- Implemented StatisticsSection to display key metrics with animated counters and a days selector for data range.
- Created utility function to convert API data into labeled points for chart rendering.
- Updated dashboard index to include the new StatisticsSection, enhancing overall dashboard functionality and user experience.
- Refactored existing trends chart to utilize the new DailyPoint type for consistency in data handling.
2026-06-07 12:39:15 +03:30
AmirReza Jamali da1e2f89e7 feat(documents): implement document upload functionality for companies
- Added DocumentItem component to display uploaded documents in CompanyDetailsPage.
- Introduced useUploadCompanyDocuments hook for handling document uploads.
- Updated CreateCompany form to include document upload capabilities and validation for file types and sizes.
- Enhanced API services to support batch document uploads and retrieval of document information.
- Improved response handling in the file service for better error management and user feedback.
2026-06-06 17:18:02 +03:30
AmirReza Jamali 88f223bf08 chore(env): update application version to 2.2.8 in production environment
feat(marketing): enhance step components with validity reporting

- Added onValidityChange prop to multiple step components (BenefitsStep, ChallengesStep, ChartStep, ContactsStep, FeaturesStep, FooterStep, HeroStep) to handle form validity.
- Updated respective presenter hooks to utilize the new onValidityChange functionality for real-time validity reporting.
- Enhanced MarketingForm to manage step validity and disable the next button based on current step validity.
2026-06-06 15:39:03 +03:30
AmirReza Jamali 5d01d66ea3 chore(env): update application version to 2.2.7 in production environment
feat(image-upload): introduce ImageUploadField component for enhanced image uploads

- Added ImageUploadField component to streamline image uploads with improved UI and functionality.
- Integrated file upload handling with a new file service for better management of image uploads.
- Updated SectionStep component to utilize ImageUploadField for icon uploads, enhancing user experience.
- Refactored TenderListFilters to use isDateRangeActive utility for improved date range filtering logic.
2026-06-06 09:51:32 +03:30
AmirReza Jamali a99f86b2f9 feat(notification): enhance NotificationDetailsPage and Notification component with improved UI and functionality
- Refactored NotificationDetailsPage to utilize a presenter hook for better state management and loading handling.
- Improved UI elements in NotificationDetailsPage, including a hero header and detail grid for displaying notification information.
- Updated Notification component to streamline the marking of notifications as seen and enhance the display of notification details.
- Added gradient backgrounds and improved tooltip functionality for better visual feedback and user experience.
2026-06-03 16:34:43 +03:30
AmirReza Jamali bb3695ee74 chore(env): update application version to 2.2.6 in production environment
feat(tenders): enhance TendersTable with improved title truncation and tooltip support

- Updated TendersTable to include a tooltip for tender titles exceeding 40 characters, improving user experience by providing additional context.
- Refactored imports in TendersTable for better organization and clarity.
2026-06-01 19:07:47 +03:30
AmirReza Jamali 9424758af5 fix(next.config): correct comment formatting for BACKEND_API_BASE_URL warning
- Updated comment in next.config.mjs to improve clarity regarding the implications of setting BACKEND_API_BASE_URL in the environment file.
- Added "use client" directive in tableSortContext.ts to indicate client-side rendering for the context component.
2026-06-01 13:14:25 +03:30
AmirReza Jamali de0e31bac5 feat(docker): add BACKEND_API_BASE_URL support for build-time routing
- Introduced ARG and ENV for BACKEND_API_BASE_URL in Dockerfile to ensure the correct API base URL is available during the build process.
- Updated next.config.mjs to include a warning about the implications of setting BACKEND_API_BASE_URL in the environment file, emphasizing its impact on multi-domain routing.
2026-06-01 12:55:52 +03:30
AmirReza Jamali f0902d7536 feat(api): enhance backend API configuration and routing for multi-domain support
- Updated `next.config.mjs` to implement host-based routing for two domains, allowing seamless API proxying to the appropriate backend based on the host.
- Refactored backend API base URL logic in `config.ts` to dynamically determine the correct URL based on the deployment environment, supporting both production and test setups.
- Modified `CreateCompany` form to validate website input with a regex pattern and changed input type to text for better user experience.
2026-06-01 11:34:42 +03:30
79 changed files with 3335 additions and 1122 deletions
+1 -1
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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: `${override}/:path*`,
},
];
}
return [
// Production: any opplens.com host (apex or subdomain like www.).
{
source: "/api/proxy/:path*",
destination: `https://admin.opplenz.com/admin/v1/: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"
/>
))}
{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>
<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…"
/>
);
}
@@ -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 (
<>
<Breadcrumb items={breadcrumbItems} />
{data?.data && <NotificationDetailsCard data={data.data} />}
</>
<div ref={rootRef} className="flex flex-col gap-6">
<div data-anim="breadcrumb">
<Breadcrumb items={breadcrumbItems} />
</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>
+3 -2
View File
@@ -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>
+3 -1
View File
@@ -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 (
+3 -1
View File
@@ -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>
</>
+37 -13
View File
@@ -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,
},
})}
label="Icon"
name={`cards.${index}.icon`}
required
type="text"
placeholder="Enter Icon URL"
}}
render={({ field, fieldState }) => (
<ImageUploadField
value={(field.value as string) ?? ""}
onChange={field.onChange}
label="Icon"
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;
+94 -41
View File
@@ -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,51 +414,62 @@ 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) => {
const v = String(item.value);
const isActive = v === String(value);
return (
<li key={v}>
<button
type="button"
role="option"
aria-selected={isActive}
data-cy={dataCy ? `${dataCy}-option` : undefined}
data-value={v}
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",
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 ? (
<span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6">
{v}
</span>
) : null}
</button>
<>
{dropdownItems.map((item) => {
const v = String(item.value);
const isActive = v === String(value);
return (
<li key={v}>
<button
type="button"
role="option"
aria-selected={isActive}
data-cy={dataCy ? `${dataCy}-option` : undefined}
data-value={v}
onClick={() => pickItem(v)}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-start text-sm transition",
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>
{showItemValue ? (
<span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6">
{v}
</span>
) : null}
</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)}
+17 -11
View File
@@ -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;
+125 -97
View File
@@ -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";
@@ -78,102 +79,129 @@ const TendersTable = () => {
sortOrder={sortOrder}
onSortChange={handleSortChange}
>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column) => {
const sortKey = columnSortKeys[column];
return (
<TableHead
key={column}
colSpan={100}
sortable={Boolean(sortKey)}
sortKey={sortKey}
defaultSortOrder={columnDefaultOrder[column]}
>
{column}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column) => {
const sortKey = columnSortKeys[column];
return (
<TableHead
key={column}
colSpan={100}
sortable={Boolean(sortKey)}
sortKey={sortKey}
defaultSortOrder={columnDefaultOrder[column]}
>
{column}
</TableHead>
);
})}
</TableRow>
</TableHeader>
{isPending ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={tendersList}
columns={columns}
emptyMessage="No tenders were found"
isLoading={false}
>
{tendersList.map((item, index) => (
<TableRow
key={item.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getRowNumber(index)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.title}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.country_code}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{formatDateTimeCell(item.publication_date)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{formatDateTimeCell(item.submission_deadline)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{formatDateTimeCell(item.tender_deadline)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{formatDateTimeCell(item?.created_at ?? 0)}
</TableCell>
{isPending ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={tendersList}
columns={columns}
emptyMessage="No tenders were found"
isLoading={false}
>
{tendersList.map((item, index) => (
<TableRow
key={item.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getRowNumber(index)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{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}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{formatDateTimeCell(item.publication_date)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{formatDateTimeCell(item.submission_deadline)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{formatDateTimeCell(item.tender_deadline)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{formatDateTimeCell(item?.created_at ?? 0)}
</TableCell>
<TableCell className={cn(`text capitalize`)} colSpan={100}>
<Status status={item.status}>{item.status}</Status>
</TableCell>
<TableCell className="capitalize" colSpan={100}>
<div className="flex items-center gap-2">
<button
type="button"
data-tooltip-id="details"
data-tooltip-content="Details"
data-tooltip-place="top"
onClick={() => navigateToTenderDetails(item.id)}
>
<Tooltip
{..._TooltipDefaultParams({
id: "details",
})}
/>
<EyeIcon />
</button>
<button
type="button"
data-tooltip-id="feedback"
data-tooltip-content="Feedback"
data-tooltip-place="top"
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" })} />
<ExclamationIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
<TableCell
className={cn(`text capitalize`)}
colSpan={100}
>
<Status status={item.status}>{item.status}</Status>
</TableCell>
<TableCell className="capitalize" colSpan={100}>
<div className="flex items-center gap-2">
<button
type="button"
data-tooltip-id="details"
data-tooltip-content="Details"
data-tooltip-place="top"
onClick={() => navigateToTenderDetails(item.id)}
>
<Tooltip
{..._TooltipDefaultParams({
id: "details",
})}
/>
<EyeIcon />
</button>
<button
type="button"
data-tooltip-id="feedback"
data-tooltip-content="Feedback"
data-tooltip-place="top"
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" })}
/>
<ExclamationIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</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}`);
+266 -296
View File
@@ -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,189 +307,129 @@ 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>
)}
<div className="sm:col-span-2">
<InputGroup
{...register("address.street", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 5,
message: FormErrorMessages.minLength(5),
},
maxLength: {
value: 200,
message: FormErrorMessages.maxLength(200),
},
})}
label="Street"
name="address.street"
required
type="text"
placeholder="Building, street, suite or unit"
/>
<FieldError message={errors.address?.street?.message} />
</div>
<div>
<InputGroup
{...register("address.city", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="City"
required
name="address.city"
type="text"
placeholder="City or town"
/>
<FieldError message={errors.address?.city?.message} />
</div>
<div>
<InputGroup
{...register("address.state", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="State"
name="address.state"
type="text"
required
placeholder="State, province, or region"
/>
<FieldError message={errors.address?.state?.message} />
</div>
<div>
<InputGroup
{...register("address.postal_code", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 3,
message: FormErrorMessages.minLength(3),
},
maxLength: {
value: 20,
message: FormErrorMessages.maxLength(20),
},
})}
label="Postal code"
name="address.postal_code"
type="text"
required
placeholder="Postal or ZIP code"
/>
<FieldError message={errors.address?.postal_code?.message} />
</div>
<div>
<InputGroup
{...register("address.country", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="Country"
name="address.country"
type="text"
required
placeholder="Full country name"
/>
<FieldError message={errors.address?.country?.message} />
</div>
</ShowcaseSection>
<ShowcaseSection title="Address" className="grid grid-cols-1 gap-5">
<InputGroup
{...register("address.street", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 5,
message: FormErrorMessages.minLength(5),
},
maxLength: {
value: 200,
message: FormErrorMessages.maxLength(200),
},
})}
label="Street"
name="address.street"
required
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>
)}
<InputGroup
{...register("address.city", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="City"
required
name="address.city"
type="text"
placeholder="City or town"
/>
{errors.address?.city && (
<p className="mt-1 text-sm text-red-500">
{errors.address.city.message}
</p>
)}
<InputGroup
{...register("address.state", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="State"
name="address.state"
type="text"
required
placeholder="State, province, or region"
/>
{errors.address?.state && (
<p className="mt-1 text-sm text-red-500">
{errors.address.state.message}
</p>
)}
<InputGroup
{...register("address.postal_code", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 3,
message: FormErrorMessages.minLength(3),
},
maxLength: {
value: 20,
message: FormErrorMessages.maxLength(20),
},
})}
label="Postal code"
name="address.postal_code"
type="text"
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>
)}
<InputGroup
{...register("address.country", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="Country"
name="address.country"
type="text"
required
placeholder="Full country name"
/>
{errors.address?.country && (
<p className="mt-1 text-sm text-red-500">
{errors.address.country.message}
</p>
)}
</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 {
label: c.name,
value: c.id,
};
})
? 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,20 +471,25 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
setValue={setValue}
placeholder=""
/>
<div className="sm:col-span-2">
<TagInput
{...register("tags.specializations")}
label="Specializations"
name="tags.specializations"
watch={watch}
setValue={setValue}
placeholder=""
/>
<FieldError message={errors.tags?.specializations?.message} />
</div>
</ShowcaseSection>
<TagInput
{...register("tags.specializations")}
label="Specializations"
name="tags.specializations"
watch={watch}
setValue={setValue}
placeholder=""
<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)}
/>
{errors.tags?.specializations && (
<p className="mt-1 text-sm text-red-500">
{errors.tags.specializations.message}
</p>
)}
</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
+12 -4
View File
@@ -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">
+86
View File
@@ -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;
+7 -4
View File
@@ -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
+2
View File
@@ -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,
};
};
+1 -1
View File
@@ -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,
};
+1
View File
@@ -16,3 +16,4 @@ export * from "./useUsersQueries";
export * from "./useAdminListQuery";
export * from "./useSelectSearchQuery";
export * from "./useDashboardQueries";
export * from "./useFilesQueries";
+18
View File
@@ -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(
+3
View File
@@ -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");
},
});
+12
View File
@@ -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],
+14
View File
@@ -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,
});
};
+3 -10
View File
@@ -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
View File
@@ -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 =>
+5
View File
@@ -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",
+40
View File
@@ -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;
+13
View File
@@ -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>> => {
+139
View File
@@ -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
View File
@@ -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";
+9 -27
View File
@@ -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;
+2
View File
@@ -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({
+37
View File
@@ -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;
}
+18
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -1,4 +1,5 @@
export * from "./CompanyCategories";
export * from "./CompanyDocuments";
export * from "./Customers";
export * from "./Factory";
export * from "./Posts";
+179 -131
View File
@@ -1,160 +1,208 @@
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,
);
/** Logs the user out via the registered callback, falling back to a manual reset. */
const forceLogout = () => {
const logout = getLogoutCallback();
if (logout) {
logout();
return;
}
const isRefreshTokenRequest = originalRequest?.url?.includes(
API_ENDPOINTS.USER.REFRESH_TOKEN,
);
clearLocalSession();
redirectToSignIn();
};
// If we get a 401 on refresh token endpoint, logout immediately (token refresh failed)
if (error.response?.status === 401 && isRefreshTokenRequest) {
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";
}
// ---------------------------------------------------------------------------
// 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;
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 TokenPair;
if (!access_token || !refresh_token) {
throw new Error("Invalid token received from refresh endpoint.");
}
Cookies.set(COOKIE_KEYS.access_token, access_token);
Cookies.set(COOKIE_KEYS.refresh_token, refresh_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);
}
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);
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);
}
if (
error.response?.status === 401 &&
isUnauthorized &&
originalRequest &&
!originalRequest._retry &&
!isLoginRequest
!isLoginRequest(originalRequest)
) {
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);
});
}
originalRequest._retry = true;
isRefreshing = true;
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."));
}
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;
};
if (!access_token || !refresh_token) {
throw new Error("Invalid token received from refresh endpoint.");
}
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}`;
}
processQueue(null, access_token);
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);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
return handleUnauthorized(originalRequest);
}
const errorMessage =
(
error.response?.data as {
message: string;
error: { code: string; message: string };
}
)?.error?.message || error.message;
toast.error(errorMessage);
notifyError(error);
return Promise.reject(error);
};
+63
View File
@@ -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`;