This commit is contained in:
natib21
2026-07-10 11:25:59 +00:00
parent 2696ca7498
commit e6e44e773b
1146 changed files with 200266 additions and 90527 deletions

View File

@@ -0,0 +1,250 @@
import NotificationList from "@/record-management/components/NotificationList";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDown, User, Key, LogOut } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { useTranslation } from "react-i18next";
import { FiBell, FiChevronDown } from "react-icons/fi";
import { useNavigate } from "react-router-dom";
import { useNotifications } from "@/shared/hooks/useNotification";
import { cn } from "@/shared/common/ui/fileUploader/utils";
import { useUser } from "@/shared/context/UserContext";
import {
UI_LANGUAGE_OPTIONS,
getUiLanguageLabel,
getUiLanguageShortLabel,
resolveUiLanguage,
} from "@/shared/i18n/uiLanguages";
export const ExternalPortal = ({
mobileView = false,
onItemClick,
}: {
mobileView?: boolean;
onItemClick?: () => void;
}) => {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const userDetails = useUser();
const { logout } = useAuthUser();
const fullName = userDetails?.name?.en || t("header.user");
const splittedName = fullName.trim().split(" ");
const initials =
splittedName.length === 1
? splittedName[0][0]
: `${splittedName[0][0]}${splittedName[1][0]}`;
const currentLanguage = resolveUiLanguage(i18n.language);
const changeLanguage = (lng: string) => i18n.changeLanguage(lng);
const handleLogout = () => {
logout("/external-portal/signin");
};
const { unseenCount } = useNotifications({
take: 10,
skip: 0,
orderBy: "updatedAt:DESC",
});
const [openNotifications, setOpenNotifications] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setOpenNotifications(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Handle navigation with optional callback for mobile
const handleNavigation = (path: string) => {
navigate(path);
if (onItemClick) onItemClick();
};
return (
<>
<div
className={`flex items-center ${
mobileView
? "flex-col space-y-4 w-full"
: "space-x-1 md:space-x-3 ml-auto"
}`}>
{/* Notifications */}
<div
className={`relative ${mobileView ? "w-full" : ""}`}
ref={dropdownRef}>
<Button
variant="ghost"
size={mobileView ? "default" : "icon"}
className={cn(
"relative text-gray-500 hover:bg-primary-50 hover:text-primary-600",
mobileView
? "w-full justify-start px-4 py-3 text-base"
: "h-8 w-8 rounded-full md:h-9 md:w-9",
openNotifications &&
"bg-primary-50 text-primary-700 ring-1 ring-inset ring-primary-300 dark:bg-primary-900/30 dark:text-primary-300 dark:ring-primary-700/60",
)}
aria-label={t("header.notifications")}
onClick={() => setOpenNotifications((prev) => !prev)}>
<FiBell
className={cn(
"h-4 w-4 transition-colors md:h-5 md:w-5",
mobileView && "mr-3",
openNotifications &&
"fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300",
)}
/>
{mobileView && <span>{t("header.notifications")}</span>}
{unseenCount > 0 && (
<span
className={`absolute ${
mobileView ? "top-3 right-4" : "-top-1 -right-1"
} bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full`}>
{unseenCount}
</span>
)}
</Button>
{openNotifications && (
<div
className={
mobileView
? "fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-hidden rounded-2xl border bg-white shadow-2xl"
: "absolute right-0 z-50 mt-2 w-[24rem] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border bg-white shadow-2xl"
}>
<NotificationList />
</div>
)}
</div>
{/* Language Switcher */}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button
variant="ghost"
size={mobileView ? "default" : "sm"}
className={`${
mobileView
? "w-full justify-start px-4 py-3 text-base"
: "gap-1 md:gap-1.5 text-xs md:text-sm h-8 px-2 md:px-3"
} font-medium text-gray-700 hover:bg-primary-50 hover:text-primary-600`}
onClick={(e) => e.preventDefault()}>
<FiChevronDown
className={`${
mobileView ? "mr-3" : ""
} h-3 w-3 md:h-4 md:w-4 opacity-50`}
/>
<span>{getUiLanguageLabel(currentLanguage, t)}</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={`w-40 bg-white shadow-xl rounded-lg p-1 z-50 ${
mobileView ? "ml-4" : ""
}`}
align={mobileView ? "start" : "end"}
sideOffset={5}>
{UI_LANGUAGE_OPTIONS.map((lang) => (
<DropdownMenu.Item
key={lang.value}
onSelect={() => {
changeLanguage(lang.value);
if (onItemClick) onItemClick();
}}
className={cn(
"flex items-center justify-between text-sm text-gray-700 hover:bg-primary-100 rounded-md px-3 py-2 cursor-pointer",
currentLanguage === lang.value && "bg-primary-50",
)}
>
<span>{getUiLanguageLabel(lang.value, t)}</span>
{currentLanguage === lang.value && (
<span className="text-xs">
{getUiLanguageShortLabel(lang.value, t)}
</span>
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/* User Menu */}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button
variant="ghost"
className={`flex items-center ${
mobileView
? "w-full justify-start px-4 py-3 text-base"
: "h-8 md:h-10 px-1 md:px-2"
} space-x-1 md:space-x-2 text-gray-700 hover:bg-gray-100 hover:text-gray-900`}
aria-label={t("header.userMenu")}>
<div
className={`flex items-center justify-center ${
mobileView ? "w-10 h-10" : "w-7 h-7 md:w-8 md:h-8"
} rounded-full bg-gray-200`}>
<span
className={`${
mobileView ? "text-base" : "text-xs md:text-sm"
} font-medium`}>
{initials.toUpperCase()}
</span>
</div>
{!mobileView && (
<div className="hidden sm:flex flex-col items-start">
<span className="text-xs font-medium leading-none">
{userDetails?.name?.en}
</span>
<span className="text-[10px] text-gray-500 leading-none mt-1">
External Organization
</span>
</div>
)}
{mobileView && (
<div className="flex flex-col items-start ml-3">
<span className="text-sm font-medium leading-none">
{userDetails?.name?.en}
</span>
<span className="text-xs text-gray-500 leading-none mt-1">
External Organization
</span>
</div>
)}
<ChevronDown className="h-3 w-3 md:h-4 md:w-4 text-gray-500" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={`w-48 p-1 bg-white shadow-xl rounded-md ${
mobileView ? "ml-4" : ""
}`}
align={mobileView ? "start" : "end"}>
<DropdownMenu.Item
className="flex items-center px-3 py-2.5 text-sm text-gray-700 hover:bg-primary-100 rounded-md cursor-pointer"
onClick={() => handleNavigation("/profile")}>
<User className="mr-2.5 h-4 w-4 text-gray-500" />
<span>{t("header.viewProfile")}</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex items-center px-3 py-2.5 text-sm text-gray-700 hover:bg-primary-100 rounded-md cursor-pointer"
onClick={() =>
handleNavigation("/record-management/change-password")
}>
<Key className="mr-2.5 h-4 w-4 text-gray-500" />
<span>{t("header.changePassword")}</span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-gray-200" />
<DropdownMenu.Item
className="flex items-center px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 rounded-md cursor-pointer"
onClick={handleLogout}>
<LogOut className="mr-2.5 h-4 w-4" />
<span>{t("header.signOut")}</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</>
);
};

View File

@@ -0,0 +1,49 @@
"use client";
import Header from "../../../layout/components/Header";
import React from "react";
import { AlertCircle, Mail, PhoneCall } from "lucide-react";
import { useTranslation } from "react-i18next";
interface VerificationPendingProps {
hasCompletedRegistration: boolean;
contactNumber?: string;
email?:string;
}
const VerificationPending: React.FC<VerificationPendingProps> = ({
hasCompletedRegistration,
contactNumber,
email,
}) => {
const { t } = useTranslation();
if (hasCompletedRegistration) {
return null; // ✅ Nothing to show if user has finished registration
}
return (
<div className="min-h-screen flex items-center justify-center bg-primary-50 px-6">
<Header />
<div className="max-w-lg w-full bg-white rounded-2xl shadow-lg p-8 border border-primary-200">
<div className="flex items-center gap-3 mb-4">
<AlertCircle className="text-primary-600 w-6 h-6" />
<h1 className="text-xl font-semibold text-primary-700">
{t("verification.pending")}
</h1>
</div>
<p className="text-gray-700 mb-4">
{t("verification.message")}{" "}
<span className="font-medium text-primary-700">
<Mail/>{email} or <PhoneCall/>{contactNumber} </span>.
</p>
</div>
</div>
);
};
export default VerificationPending;

View File

@@ -0,0 +1,5 @@
import UserTypeSelection from "./UserTypeSelection";
export default function ExternalAuthPage() {
return <UserTypeSelection />;
}

View File

@@ -0,0 +1,111 @@
"use client";
import { useRegisterExternalPortalUser } from "@/external-portal/hooks/useRegisterExternalPortalUser";
import { Button } from "@/shared/common/ui/button";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { getDefaultFaydaRedirectUri } from "@/shared/utils/faydaOidc";
import { persistFaydaRegistrationAuth } from "@/shared/utils/faydaAuthSession";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
export default function ExternalPortalCallback() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { handleError } = useErrorHandler(t);
const { isFayidaRegistering, registerExternalFayidaUser } =
useRegisterExternalPortalUser();
const hasRun = useRef(false);
useEffect(() => {
if (hasRun.current) return;
hasRun.current = true;
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code) {
toast.error("Missing authorization code");
setError("Missing authorization code");
setLoading(false);
navigate("/external-portal/signin", { replace: true });
return;
}
const loginWithFayda = async () => {
try {
const res = await registerExternalFayidaUser({
code,
redirectUri: getDefaultFaydaRedirectUri(),
});
if (!res || !res.response?.data) {
throw new Error("Invalid response from Fayda login");
}
const data = res.response.data;
const userId = data?.userId ?? data?.user?.id ?? data?.user?.userId;
const token = data?.token;
const refreshToken = data?.refreshToken;
if (!userId) {
throw new Error("Failed to get user ID from server.");
}
await persistFaydaRegistrationAuth({
...data,
token,
refreshToken,
});
navigate(`/verify-otp?userId=${userId}&isExternalOrg=false`, {
replace: true,
});
} catch (err: unknown) {
console.error("LoginWithFayda error:", err);
handleError(err);
setError(
err instanceof Error
? err.message
: t("registration.auth.faydaSigninFailed"),
);
} finally {
setLoading(false);
}
};
loginWithFayda();
}, [searchParams, navigate, handleError, registerExternalFayidaUser, t]);
if (loading || isFayidaRegistering) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-white">
<Loader2 className="h-10 w-10 text-primary-600 animate-spin mb-4" />
<p className="text-primary-700 text-lg font-medium">
{t("registration.auth.faydaProcessing")}
</p>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-red-50">
<p className="text-red-600 mb-4">{error}</p>
<Button
onClick={() =>
navigate("/external-portal/signin", { replace: true })
}>
{t("registration.auth.backToAuth")}
</Button>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,249 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import {
FileText,
ImageIcon,
FileIcon,
VideoIcon,
DownloadIcon,
XIcon,
Loader2,
} from "lucide-react";
import { cn } from "@/shared/lib/utils";
import { useToast } from "@/shared/common/ui/use-toast";
type FileType = "image" | "pdf" | "video" | "other";
interface FilePreviewProps {
file: File | string; // Can accept File object or URL string
type?: FileType; // Optional type hint
className?: string;
onRemove?: () => void;
showDownload?: boolean;
}
export const FilePreview = ({
file,
type,
className,
onRemove,
showDownload = true,
}: FilePreviewProps) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [detectedType, setDetectedType] = useState<FileType>("other");
const { toast } = useToast();
useEffect(() => {
const determineFileType = (): FileType => {
if (type) return type;
if (typeof file === "string") {
const extension = file.split(".").pop()?.toLowerCase();
if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension || "")) {
return "image";
}
if (extension === "pdf") return "pdf";
if (["mp4", "webm", "ogg"].includes(extension || "")) return "video";
return "other";
}
if (file.type.startsWith("image/")) return "image";
if (file.type === "application/pdf") return "pdf";
if (file.type.startsWith("video/")) return "video";
return "other";
};
const generatePreview = async () => {
setIsLoading(true);
setDetectedType(determineFileType());
try {
if (typeof file === "string") {
setPreviewUrl(file);
} else {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
}
} catch (error) {
console.error("Error generating preview:", error);
toast({
title: "Error",
description: "Could not generate file preview",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
generatePreview();
return () => {
if (previewUrl && typeof file !== "string") {
URL.revokeObjectURL(previewUrl);
}
};
}, [file, type]);
const handleDownload = () => {
if (!previewUrl) return;
const link = document.createElement("a");
link.href = previewUrl;
link.download = typeof file === "string" ? "download" : file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const renderPreview = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
switch (detectedType) {
case "image":
return (
<img
src={previewUrl || ""}
alt="Preview"
className="object-contain w-full h-full"
onLoad={() => setIsLoading(false)}
/>
);
case "pdf":
return (
<div className="flex flex-col items-center justify-center h-full p-4">
<FileText className="h-16 w-16 text-red-500 dark:text-red-400" />
<span className="mt-2 text-sm font-medium truncate text-foreground">
{typeof file === "string" ? "PDF Document" : file.name}
</span>
</div>
);
case "video":
return (
<video
controls
className="w-full h-full"
onLoadedData={() => setIsLoading(false)}>
<source src={previewUrl || ""} type="video/mp4" />
Your browser does not support the video tag.
</video>
);
default:
return (
<div className="flex flex-col items-center justify-center h-full p-4">
<FileIcon className="h-16 w-16 text-gray-400 dark:text-gray-500" />
<span className="mt-2 text-sm font-medium truncate text-foreground">
{typeof file === "string" ? "File" : file.name}
</span>
</div>
);
}
};
return (
<>
<div
className={cn(
"relative border rounded-md overflow-hidden bg-gray-50 dark:bg-gray-800 dark:border-gray-700 w-full h-40",
className
)}>
{renderPreview()}
<div className="absolute top-2 right-2 flex gap-2">
{onRemove && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-background/80 hover:bg-background"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}>
<XIcon className="h-4 w-4" />
</Button>
)}
{showDownload && previewUrl && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-background/80 hover:bg-background"
onClick={(e) => {
e.stopPropagation();
handleDownload();
}}>
<DownloadIcon className="h-4 w-4" />
</Button>
)}
</div>
<Button
variant="ghost"
className="absolute inset-0 w-full h-full opacity-0 hover:opacity-100 hover:bg-background/20 dark:hover:bg-background/40"
onClick={() => setIsDialogOpen(true)}>
<span className="sr-only">View fullscreen</span>
</Button>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-[90vw] max-h-[90vh]">
<DialogHeader>
<DialogTitle>
{typeof file === "string" ? "File Preview" : file.name}
</DialogTitle>
</DialogHeader>
<div className="relative h-[70vh]">
{detectedType === "image" && (
<img
src={previewUrl || ""}
alt="Fullscreen preview"
className="object-contain w-full h-full"
/>
)}
{detectedType === "pdf" && (
<iframe
src={previewUrl || ""}
className="w-full h-full"
title="PDF Preview"
/>
)}
{detectedType === "video" && (
<video controls autoPlay className="w-full h-full">
<source src={previewUrl || ""} type="video/mp4" />
</video>
)}
{detectedType === "other" && (
<div className="flex items-center justify-center h-full">
<FileIcon className="h-16 w-16 text-gray-400 dark:text-gray-500" />
</div>
)}
</div>
<div className="flex justify-end gap-2">
{showDownload && (
<Button onClick={handleDownload} variant="outline">
<DownloadIcon className="mr-2 h-4 w-4" />
Download
</Button>
)}
<Button onClick={() => setIsDialogOpen(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,227 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import ReactCrop, {
centerCrop,
makeAspectCrop,
Crop,
PixelCrop,
convertToPixelCrop,
} from "react-image-crop";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { RotateCw, RotateCcw, Rotate3D } from "lucide-react";
import { canvasPreview } from "./canvasPreview";
import { useDebounceEffect } from "./useDebounceEffect";
import "react-image-crop/dist/ReactCrop.css";
interface ImageCropperProps {
file: File;
onCropComplete: (croppedFile: File) => void;
aspectRatio?: number;
}
export function ImageCropper({
file,
onCropComplete,
aspectRatio = 3 / 4,
}: ImageCropperProps) {
const [imgSrc, setImgSrc] = useState("");
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const [scale, setScale] = useState(1);
const [rotate, setRotate] = useState(0);
const rotateClockwise = useCallback(() => {
setRotate((prev) => (prev + 90) % 360);
}, []);
const rotateCounterClockwise = useCallback(() => {
setRotate((prev) => (prev - 90) % 360);
}, []);
const rotateSmall = useCallback((degrees: number) => {
setRotate((prev) => (prev + degrees) % 360);
}, []);
React.useEffect(() => {
const reader = new FileReader();
reader.addEventListener("load", () => {
setImgSrc(reader.result?.toString() || "");
});
reader.readAsDataURL(file);
}, [file]);
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
const { width, height } = e.currentTarget;
setCrop(centerAspectCrop(width, height, aspectRatio));
}
function centerAspectCrop(
mediaWidth: number,
mediaHeight: number,
aspect: number
) {
return centerCrop(
makeAspectCrop(
{
unit: "%",
width: 90,
},
aspect,
mediaWidth,
mediaHeight
),
mediaWidth,
mediaHeight
);
}
async function handleCropComplete() {
const image = imgRef.current;
const previewCanvas = previewCanvasRef.current;
if (!image || !previewCanvas || !completedCrop) {
throw new Error("Crop canvas does not exist");
}
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
const offscreen = new OffscreenCanvas(
completedCrop.width * scaleX,
completedCrop.height * scaleY
);
const ctx = offscreen.getContext("2d");
if (!ctx) {
throw new Error("No 2d context");
}
ctx.drawImage(
previewCanvas,
0,
0,
previewCanvas.width,
previewCanvas.height,
0,
0,
offscreen.width,
offscreen.height
);
const blob = await offscreen.convertToBlob({
type: file.type || "image/png",
});
const croppedFile = new File([blob], file.name, {
type: blob.type,
lastModified: Date.now(),
});
onCropComplete(croppedFile);
}
useDebounceEffect(
async () => {
if (
completedCrop?.width &&
completedCrop?.height &&
imgRef.current &&
previewCanvasRef.current
) {
canvasPreview(
imgRef.current,
previewCanvasRef.current,
completedCrop,
scale,
rotate
);
}
},
100,
[completedCrop, scale, rotate]
);
return (
<div className="flex flex-col gap-4">
{!!imgSrc && (
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(c) => setCompletedCrop(c)}
aspect={aspectRatio}
minHeight={100}>
<img
ref={imgRef}
alt="Crop me"
src={imgSrc}
style={{ transform: `rotate(${rotate}deg)` }}
onLoad={onImageLoad}
className="max-h-[400px] object-contain"
/>
</ReactCrop>
)}
<div className="flex items-center gap-2">
<Label htmlFor="rotate-input">Rotate:</Label>
<Button
variant="outline"
size="sm"
onClick={rotateCounterClockwise}
title="Rotate counter-clockwise">
<RotateCcw className="h-4 w-4" />
</Button>
<Input
id="rotate-input"
type="number"
value={rotate}
min="-180"
max="180"
onChange={(e) => setRotate(Number(e.target.value))}
className="w-16"
/>
<Button
variant="outline"
size="sm"
onClick={rotateClockwise}
title="Rotate clockwise">
<RotateCw className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => rotateSmall(15)}
title="Rotate 15° clockwise">
<Rotate3D className="h-4 w-4 mr-1" /> 15°
</Button>
<Button
variant="outline"
size="sm"
onClick={() => rotateSmall(-15)}
title="Rotate 15° counter-clockwise">
<Rotate3D className="h-4 w-4 mr-1 transform scale-x-[-1]" /> 15°
</Button>
</div>
<Button
onClick={handleCropComplete}
disabled={!completedCrop}
className="self-start">
Apply Crop
</Button>
<canvas
ref={previewCanvasRef}
style={{
display: "none",
border: "1px solid black",
objectFit: "contain",
width: completedCrop?.width,
height: completedCrop?.height,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,544 @@
// RegistrationStepper.tsx
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/shared/common/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/common/ui/form";
import { Input } from "@/shared/common/ui/input";
import { Mail, User, Phone, Text, Building } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Stepper } from "./Stepper";
import VerifyOtp from "../verifyOTP";
import UploadDocumentsStep from "./UploadDocumentsStep";
import { useRegisterExternalPortalUser } from "@/external-portal/hooks/useRegisterExternalPortalUser";
import { submitApplication } from "@/external-portal/services/portalOutgoingService";
import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
import { useDocumentRequirement } from "@/shared/hooks/useOrganizationReport";
import { FilterEnum } from "@/shared/services/organizationsService";
import { DocumentReq } from "./UploadDocumentsStep";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useTranslation } from "react-i18next";
import { useDocumentUploads } from "@/external-portal/hooks/useDocumentUploads";
import { DocumentPayloadDto } from "@/shared/dto/External-Portal/External-PortalDto";
import { useDeleteExternalUser } from "@/super-admin/hooks/useExternalUsers";
import Cookies from "js-cookie";
type FilesRecord = Record<string, File | string | null>;
// registration schema
const registrationSchema = z.object({
email: z.string().email("Invalid email address"),
username: z.string().min(3, "Username must be at least 3 characters"),
phoneNumber: z.string().min(10, "Phone number must be at least 10 digits"),
name: z.object({
en: z.string().min(2, "English name must be at least 2 characters"),
am: z.string().min(2, "Amharic name must be at least 2 characters"),
}),
userType: z.enum(["individual", "external_organization"]),
});
type RegistrationFormValues = z.infer<typeof registrationSchema>;
const RegistrationStepper: React.FC = () => {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<RegistrationFormValues | null>(null);
const [uploadedFiles, setUploadedFiles] = useState<FilesRecord>({});
const [submitting, setSubmitting] = useState(false);
const [step1Completed, setStep1Completed] = useState(false);
const { t } = useTranslation();
const { registerExternalPortalUser, isRegistering } =
useRegisterExternalPortalUser();
const { handleError } = useErrorHandler(t);
const { uploadDocuments } = useDocumentUploads();
const form = useForm<RegistrationFormValues>({
resolver: zodResolver(registrationSchema),
defaultValues: {
email: "",
username: "",
phoneNumber: "",
userType: "external_organization",
name: { en: "", am: "" },
},
});
const userType = form.watch("userType");
const mappedType =
userType === "external_organization" ? "organization" : userType;
const filterData: FilterEnum | undefined =
mappedType && Object.values(FilterEnum).includes(mappedType as FilterEnum)
? (mappedType as FilterEnum)
: undefined;
// fetch requirement docs to know which are required
const { requirementDoc } = useDocumentRequirement(filterData);
const requiredDocs = useMemo(
() =>
requirementDoc?.items?.filter((d: DocumentReq) => !d.isOptional) ?? [],
[requirementDoc]
);
const attachedCount = useMemo(
() => Object.values(uploadedFiles).filter((f) => f !== null).length,
[uploadedFiles]
);
//validation
const isStep1Complete = useMemo(() => {
if (!form.formState.isValid) return false;
// Check if all required documents are uploaded
const hasAllRequiredDocs = requiredDocs.every(
(doc) => uploadedFiles[doc.id] instanceof File
);
return hasAllRequiredDocs && form.formState.isValid;
}, [form.formState.isValid, requiredDocs, uploadedFiles]);
useEffect(() => {
setStep1Completed(isStep1Complete);
}, [isStep1Complete]);
useEffect(() => {
setStep1Completed(isStep1Complete);
}, [isStep1Complete]);
const resetRegistrationFlow = () => {
setCurrentStep(0);
setFormData(null);
setUploadedFiles({});
setSubmitting(false);
setStep1Completed(false);
form.reset({
email: "",
username: "",
phoneNumber: "",
userType: "external_organization",
name: { en: "", am: "" },
});
};
useEffect(() => {
resetRegistrationFlow();
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
resetRegistrationFlow();
}
};
window.addEventListener("pageshow", handlePageShow);
return () => window.removeEventListener("pageshow", handlePageShow);
}, []);
const onRegistrationSubmit = async (data: RegistrationFormValues) => {
// 1⃣ Validate documents
const missingDocs: string[] = requiredDocs
.filter((doc) => !uploadedFiles[doc.id])
.map((doc) => `${doc.title?.en || "Document"} is required.`);
// 2⃣ Collect form errors from react-hook-form
const formErrorMessages = Object.values(form.formState.errors)
.map((err: any) => err?.message)
.filter(Boolean);
// 3⃣ Combine all errors
const allErrors = [...formErrorMessages, ...missingDocs];
// 4⃣ Stop submission and show toast if errors exist
if (allErrors.length > 0) {
allErrors.forEach((msg) => toast.error(msg));
return;
}
setSubmitting(true);
try {
// 5⃣ Create external portal user
const response = await registerExternalPortalUser(data);
if (!response?.success) {
form.setError("root", {
type: "manual",
message: response?.response?.data?.message,
});
return;
}
// 6⃣ Save tokens
const token = response.response?.data?.token;
const refreshToken = response.response?.data?.refreshToken;
Cookies.set("auth-token", token || "");
Cookies.set("refresh-token", refreshToken || "");
// 7⃣ Upload documents
await uploadAllFiles();
await submitApplication();
toast.success(t("registration.verifyOtp.messages.registrationComplete"));
setFormData(data);
setStep1Completed(true);
setCurrentStep(1); // move to OTP verification
} catch (err) {
if (err instanceof Error && err.message.includes("Duplicate entry")) {
form.setError("root", {
type: "manual",
message: t("registration.verifyOtp.errors.alreadyRegistered"),
});
} else {
handleError(err);
}
} finally {
setSubmitting(false);
}
};
const uploadAllFiles = async (): Promise<void> => {
const filesToUpload = Object.entries(uploadedFiles).filter(
([, file]) => file instanceof File
);
if (filesToUpload.length === 0) {
throw new Error(
"No documents uploaded. Registration requires documents."
);
}
const uploadPromises = filesToUpload.map(async ([docId, file]) => {
if (file instanceof File) {
const payload: DocumentPayloadDto = {
documentId: docId,
type: filterData || FilterEnum.EXTERNAL,
fileInfo: {
size: file.size,
fileName: file.name,
contentType: file.type,
originalname: file.name,
},
};
return uploadDocuments(payload, file).catch((err) => {
throw new Error(
`Failed to upload ${file.name} for document ${docId}: ${err}`
);
});
}
});
await Promise.all(uploadPromises);
};
const onVerifyComplete = async () => {
setSubmitting(true);
try {
toast.success("Application submitted successfully.");
const refreshToken = Cookies.get("refresh-token");
if (!refreshToken) {
throw new Error("No refresh token found");
}
} catch (err) {
console.error("submit error", err);
toast.error("Failed to submit application.");
} finally {
setSubmitting(false);
}
};
const handleStepChange = (step: number) => {
if (step === 1 && !step1Completed) {
toast.error(
"Please complete the registration form and upload all required documents first"
);
return;
}
setCurrentStep(step);
};
const steps = [
{
title: t("registration.steps.registerUpload.title"),
description: t("registration.steps.registerUpload.description"),
content: (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onRegistrationSubmit)}
className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left: form */}
<div className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{userType === "external_organization"
? t("registration.form.organizationEmail")
: t("registration.form.individualEmail")}
<span className="text-red-500" aria-hidden="true">
*
</span>
</FormLabel>
<FormControl>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...field}
className="pl-10"
placeholder={
userType === "external_organization"
? t(
"registration.form.organizationEmailPlaceholder"
)
: t(
"registration.form.individualEmailPlaceholder"
)
}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{userType === "external_organization"
? t("registration.form.organizationUsername")
: t("registration.form.individualUsername")}
<span className="text-red-500" aria-hidden="true">
*
</span>
</FormLabel>
<FormControl>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...field}
className="pl-10"
placeholder={
userType === "external_organization"
? t(
"registration.form.organizationUsernamePlaceholder"
)
: t(
"registration.form.individualUsernamePlaceholder"
)
}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phoneNumber"
render={({ field }) => (
<FormItem>
<FormLabel>
{userType === "external_organization"
? t("registration.form.organizationPhone")
: t("registration.form.individualPhone")}
<span className="text-red-500" aria-hidden="true">
*
</span>
</FormLabel>
<FormControl>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...field}
className="pl-10"
placeholder={
userType === "external_organization"
? t(
"registration.form.organizationPhonePlaceholder"
)
: t(
"registration.form.individualPhonePlaceholder"
)
}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name.en"
render={({ field }) => (
<FormItem>
<FormLabel>
{userType === "external_organization"
? t("registration.form.organizationNameEn")
: t("registration.form.individualNameEn")}
<span className="text-red-500" aria-hidden="true">
*
</span>
</FormLabel>
<FormControl>
<div className="relative">
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...field}
className="pl-10"
placeholder={
userType === "external_organization"
? t(
"registration.form.organizationNameEnPlaceholder"
)
: t(
"registration.form.individualNameEnPlaceholder"
)
}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name.am"
render={({ field }) => (
<FormItem>
<FormLabel>
{userType === "external_organization"
? t("registration.form.organizationNameAm")
: t("registration.form.individualNameAm")}
<span className="text-red-500" aria-hidden="true">
*
</span>
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={
userType === "external_organization"
? t(
"registration.form.organizationNameAmPlaceholder"
)
: t(
"registration.form.individualNameAmPlaceholder"
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root?.message && (
<div className="text-sm text-destructive mt-2">
{String(form.formState.errors.root.message)}
</div>
)}
</div>
{/* Right: Uploads */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">
{t("registration.documents.requiredDocuments")}
</h3>
<p className="text-sm text-gray-500">
{t("registration.documents.requirementsUpdate")}
</p>
</div>
<div className="text-sm text-gray-600">
{attachedCount} {t("registration.documents.attached")}
</div>
</div>
<div className="p-2 border rounded-md max-h-[60vh] overflow-auto">
<UploadDocumentsStep
registrationType={userType}
uploadedFiles={uploadedFiles}
setUploadedFiles={setUploadedFiles}
/>
</div>
<p className="text-sm text-gray-500">
{t("registration.documents.tip")}
</p>
</div>
{/* Submit Button - Span full width */}
<div className="md:col-span-2">
<Button
type="submit"
disabled={isRegistering || submitting}
className="w-full bg-primary-600">
{isRegistering || submitting
? t("registration.form.processing")
: userType === "external_organization"
? t("registration.form.organizationSubmitButton")
: t("registration.form.individualSubmitButton")}
</Button>
</div>
</div>
</form>
</Form>
),
},
{
title: t("registration.steps.verifyOtp.title"),
description: t("registration.steps.verifyOtp.description"),
content: (
<VerifyOtp
email={formData?.email}
phone={formData?.phoneNumber}
onComplete={onVerifyComplete}
isExternalOrg={true}
/>
),
},
];
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center p-4">
<Card className="w-full max-w-5xl">
<CardHeader>
<div className="flex items-center justify-center mb-4">
<Building className="h-8 w-8 mr-2" />
<CardTitle className="text-2xl font-bold">Registration</CardTitle>
</div>
<Stepper
steps={steps}
currentStep={currentStep}
setCurrentStep={handleStepChange}
disabled={submitting}
/>
</CardHeader>
<CardContent>{steps[currentStep].content}</CardContent>
</Card>
</div>
);
};
export default RegistrationStepper;

View File

@@ -0,0 +1,79 @@
import React from "react";
import { cn } from "@/shared/lib/utils";
interface StepperProps {
steps: {
title: string;
description: string;
}[];
currentStep: number;
setCurrentStep: (step: number) => void;
disabled?:boolean;
}
export const Stepper = ({
steps,
currentStep,
setCurrentStep,
disabled = false
}: StepperProps) => {
return (
<div className="w-full">
<div className="flex justify-between">
{steps.map((step, index) => (
<div
key={index}
className={cn(
"flex flex-col items-center flex-1",
index < steps.length - 1 && "relative"
)}>
<button
type="button"
onClick={() => {
if (
!disabled &&
(index < currentStep || index === currentStep)
) {
setCurrentStep(index);
}}}
disabled={disabled || index > currentStep}
className={cn(
"flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors duration-300",
currentStep >= index
? "bg-primary-500 border-primary-600 text-white" // Active step (green)
: "bg-white border-gray-300 text-gray-500" // Inactive step
)}>
{index + 1}
</button>
<div className="mt-2 text-center">
<p
className={cn(
"text-sm font-medium",
currentStep >= index
? "text-primary-600" // Active title (darker green)
: "text-gray-500" // Inactive title
)}>
{step.title}
</p>
<p
className={cn(
"text-xs",
currentStep >= index ? "text-primary-500" : "text-gray-400"
)}>
{step.description}
</p>
</div>
{index < steps.length - 1 && (
<div
className={cn(
"absolute top-5 left-1/2 w-full h-0.5 -z-10",
currentStep > index ? "bg-primary-400" : "bg-gray-200"
)}
/>
)}
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,280 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { useToast } from "@/shared/common/ui/use-toast";
import { useDocumentRequirement } from "@/shared/hooks/useOrganizationReport";
import { useMyUploads } from "@/external-portal/hooks/useMyUpload";
import { useQueries } from "@tanstack/react-query";
import { getMyUploadById } from "@/external-portal/services/portalOutgoingService";
import { useDocumentUploads } from "@/external-portal/hooks/useDocumentUploads";
import { FilterEnum } from "@/shared/services/organizationsService";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import { Check, Trash } from "lucide-react";
import { UploadFileModal } from "./UploadFileModal";
import { useTranslation } from "react-i18next";
import { useUser } from "@/shared/context/UserContext";
export interface LocalizedText {
am?: string;
en?: string;
}
export interface DocumentReq {
id: string;
title: LocalizedText;
description?: LocalizedText;
key?: string;
type?: string;
order?: number;
isActive?: boolean;
isOptional?: boolean;
}
type FilesRecord = Record<string, File | string | null>;
interface UploadDocumentsStepProps {
registrationType?: "individual" | "external_organization" | string;
uploadedFiles: FilesRecord;
setUploadedFiles: React.Dispatch<React.SetStateAction<FilesRecord>>;
}
export const UploadDocumentsStep: React.FC<UploadDocumentsStepProps> = ({
registrationType,
uploadedFiles,
setUploadedFiles,
}) => {
const userDetails = useUser();
const {t} = useTranslation()
// For other registration types, map to FilterEnum
const mappedType =
registrationType === "external_organization"
? "organization"
: (registrationType as string) || userDetails?.userType;
const filterData: FilterEnum | undefined =
mappedType && Object.values(FilterEnum).includes(mappedType as FilterEnum)
? (mappedType as FilterEnum)
: undefined;
const { requirementDoc } = useDocumentRequirement(filterData);
const { data: myUploads } = useMyUploads();
const latestUploadIds = useMemo(() => {
if (!requirementDoc?.items?.length) return [];
return requirementDoc.items.map((doc: DocumentReq) => {
const uploadsForDoc = myUploads?.filter(
(u: any) => u.documentId === doc.id
);
if (uploadsForDoc?.length) {
const latest = uploadsForDoc.reduce((prev: any, cur: any) =>
new Date(prev.createdAt) > new Date(cur.createdAt) ? prev : cur
);
return {
docId: doc.id,
uploadId: latest.id,
fileInfo: latest.fileInfo,
};
}
return { docId: doc.id, uploadId: null, fileInfo: null };
});
}, [requirementDoc?.items, myUploads]);
const presignedQueries = useQueries({
queries:
latestUploadIds?.map(({ uploadId }: any) => ({
queryKey: ["myUpload", uploadId],
queryFn: () =>
uploadId ? getMyUploadById(uploadId) : Promise.resolve(null),
enabled: !!uploadId,
})) || [],
});
const prefills = useMemo(() => {
const map: FilesRecord = {};
if (!latestUploadIds?.length) return map;
latestUploadIds.forEach(({ docId, fileInfo }, idx) => {
const q = presignedQueries[idx];
const presigned = q?.data?.presigned;
map[docId] = presigned || fileInfo?.fileName || null;
});
return map;
}, [latestUploadIds, presignedQueries]);
useEffect(() => {
if (
Object.keys(uploadedFiles).length === 0 &&
Object.keys(prefills).length > 0
) {
setUploadedFiles((prev) => ({ ...prefills, ...prev }));
}
}, [prefills, uploadedFiles, setUploadedFiles]);
const [modalOpen, setModalOpen] = React.useState(false);
const [selectedDoc, setSelectedDoc] = React.useState<DocumentReq | null>(null);
// When user clicks "Upload" button
const handleOpenModal = (doc: DocumentReq) => {
setSelectedDoc(doc);
setModalOpen(true);
};
// When file is selected in modal
const handleFileSelect = (file: File | null) => {
if (selectedDoc) {
setUploadedFiles((prev) => ({
...prev,
[selectedDoc.id]: file,
}));
}
setModalOpen(false);
setSelectedDoc(null);
};
const handleRemove = (docId: string) => {
setUploadedFiles((prev: FilesRecord) => ({
...prev,
[docId]: null,
}));
};
const [faydaValue, setFaydaValue] = useState<string>("");
// If registration type is individual, show Fayda input
if (registrationType === "individual") {
return (
<div className="space-y-3">
<label className="block">
<span className="font-medium">
{t("registration.documents.fayda")}
</span>
<Input
type="text"
value={faydaValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setFaydaValue(e.target.value)
}
placeholder={t("registration.documents.faydaMessage")}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</label>
</div>
);
}
return (
<div className="space-y-3">
{requirementDoc?.items?.length === 0 ? (
<div className="text-sm text-gray-500">
{t("registration.documents.noRequirements")}
</div>
) : (
requirementDoc.items.map((doc) => {
const current = uploadedFiles[doc.id];
const isNewFile = current instanceof File;
return (
<div
key={doc.id}
className="flex items-center gap-2 p-3 border rounded-lg">
<div className="flex-1">
<div className="font-medium">{doc.title?.en || doc.id}</div>
{doc.description?.en && (
<div className="text-sm text-muted-foreground">
{doc.description.en}
</div>
)}
{current && (
<div className="text-xs mt-1 flex items-center gap-1">
<Check className="h-3 w-3" />
<span
className={
isNewFile ? "text-blue-600" : "text-primary-600"
}>
{isNewFile
? `Ready to upload: ${current.name}`
: `Previously uploaded: ${current}`}
</span>
{isNewFile && (
<span className="text-orange-500 ml-2">
({t("registration.documents.willUploadOnSubmit")})
</span>
)}
</div>
)}
{!doc.isOptional && !current && (
<div className="text-xs text-red-600 mt-1">
{t("registration.documents.required")}
</div>
)}
</div>
<div className="flex items-center gap-2">
{current && (
<Button
variant="ghost"
size="icon"
onClick={() => handleRemove(doc.id)}
className="h-8 w-8 text-destructive">
<Trash className="h-4 w-4" />
</Button>
)}
<Button
size="sm"
variant={current ? "outline" : "default"}
className={current ? "" : "bg-primary-600"}
onClick={() => handleOpenModal(doc)}>
{current ? "Change" : "Upload"}
</Button>
</div>
</div>
);
})
)}
{modalOpen && selectedDoc && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<UploadFileModal
value={uploadedFiles[selectedDoc.id] || null}
multiple={false}
accept={[
".png",
".jpg",
".jpeg",
".pdf",
".dwg",
".dxf",
".dwt",
".bak",
".sv$",
".dws",
".mxd",
".aprx",
".rar",
".zip",
]}
onChange={(file) => {
handleFileSelect(
file instanceof File
? file
: Array.isArray(file)
? file[0]
: null
);
}}
onClose={() => {
setModalOpen(false);
setSelectedDoc(null);
}}
fileUploadFields={{
requiredDocumentId: selectedDoc.id,
}}
/>
</div>
)}
</div>
);
};
export default UploadDocumentsStep;

View File

@@ -0,0 +1,514 @@
"use client";
import React, { memo, useCallback, useMemo, useRef, useState } from "react";
import { Button } from "@/shared/common/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/common/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "@/shared/common/ui/alert";
import {
CloudUpload,
Trash,
FileType,
X,
Info,
FileUp,
Plus,
Check,
} from "lucide-react";
import { ImageCropper } from "./ImageCropper";
import { FilePreview } from "./FilePreview";
import { useToast } from "@/shared/common/ui/use-toast";
import { MAX_FILE_SIZE_MB } from "@/shared/utils/max-file-size";
import {
getAllowedExtensionsFromAccept,
getAllowedMimeTypesFromAccept,
getFinalAllowedFileTypes,
} from "@/shared/utils/get-final-allowed-file-types";
import { ClientSideValidator } from "@/shared/services/validation/ClientSideValidator";
import { FileUploadValidator } from "@/shared/services/validation/FileUploadValidator";
interface UploadFileModalProps {
onChange: (
file: File | File[] | null,
status?: string,
requiredDocumentId?: string
) => void;
value: File | File[] | null | string;
fileUploadFields?: any;
accept?: string[] | null;
multiple?: boolean;
onClose?: () => void;
}
export const UploadFileModal = memo(
({
onChange,
value,
fileUploadFields,
accept,
multiple,
onClose,
}: UploadFileModalProps) => {
const [file, setFile] = useState<File | File[] | null>(null);
const [croppingFile, setCroppingFile] = useState<File | null>(null);
const [croppedFile, setCroppedFile] = useState<File | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [fileErrorMessages, setFileErrorMessages] = useState<any[]>([]);
const openRef = useRef<HTMLInputElement>(null);
const validatorRef = useRef(new ClientSideValidator());
const { toast } = useToast();
const needsCropping = (file: File) => {
const photoConfig = fileUploadFields?.photoConfiguration;
const isPhotoConfigEnabled =
photoConfig === true || photoConfig === "true";
return (
isPhotoConfigEnabled &&
file.type.startsWith("image/") &&
!["image/gif", "image/svg+xml"].includes(file.type)
);
};
const handleChange = useCallback(
async (newFile: File | File[] | null) => {
setErrorMessage(null);
setFileErrorMessages([]);
if (newFile) {
const allowedMimeTypes = accept
? getAllowedMimeTypesFromAccept(accept)
: undefined;
const allowedExtensions = accept
? getAllowedExtensionsFromAccept(accept)
: undefined;
if (Array.isArray(newFile)) {
const validationErrors: any[] = [];
for (let index = 0; index < newFile.length; index++) {
const item = newFile[index];
// Comprehensive file validation
const basicValidation = FileUploadValidator.validateFile(item, {
maxSizeMB: MAX_FILE_SIZE_MB,
allowedMimeTypes:
item.type && allowedMimeTypes?.length
? allowedMimeTypes
: undefined,
allowedExtensions:
allowedExtensions?.length ? allowedExtensions : undefined,
});
if (!basicValidation.isValid) {
validationErrors.push({
fileIndex: index,
error: basicValidation.error || "File validation failed",
});
continue;
}
// Client-side magic byte validation
if (accept) {
const clientValidation = await validatorRef.current.validateUpload(
item,
item.type,
allowedMimeTypes || []
);
if (!clientValidation.isValid) {
validationErrors.push({
fileIndex: index,
error: clientValidation.error || "File validation failed",
});
continue;
}
}
}
if (validationErrors.length > 0) {
setFileErrorMessages(validationErrors);
setErrorMessage(`File validation error`);
return;
}
} else {
// Comprehensive file validation
const basicValidation = FileUploadValidator.validateFile(newFile, {
maxSizeMB: MAX_FILE_SIZE_MB,
allowedMimeTypes:
newFile.type && allowedMimeTypes?.length
? allowedMimeTypes
: undefined,
allowedExtensions:
allowedExtensions?.length ? allowedExtensions : undefined,
});
if (!basicValidation.isValid) {
setErrorMessage(basicValidation.error || "File validation failed");
return;
}
if (accept) {
// Client-side magic byte validation
const clientValidation = await validatorRef.current.validateUpload(
newFile,
newFile.type,
allowedMimeTypes || []
);
if (!clientValidation.isValid) {
setErrorMessage(clientValidation.error || "File validation failed");
return;
}
}
if (needsCropping(newFile)) {
setCroppingFile(newFile);
return;
}
}
}
// Set the file and immediately call onChange to update parent
const finalFile = multiple
? [
...(Array.isArray(file) ? file : []),
...(Array.isArray(newFile) ? newFile : [newFile]).filter(Boolean),
]
: newFile;
const cleanedFile = Array.isArray(finalFile)
? finalFile.filter((f): f is File => f !== null) // removes nulls
: finalFile;
setFile(cleanedFile);
// Immediately notify parent about the file change
if (cleanedFile) {
onChange(
cleanedFile,
"selected",
fileUploadFields?.requiredDocumentId
);
} else {
onChange(null, "removed", fileUploadFields?.requiredDocumentId);
}
},
[fileUploadFields?.photoConfiguration, accept, multiple, file, onChange]
);
const handleClose = useCallback(() => {
setCroppingFile(null);
setFile(null);
setCroppedFile(null);
setErrorMessage(null);
onClose?.();
}, [onClose]);
const handleCropComplete = useCallback(
(croppedFile: File) => {
setCroppingFile(null);
setCroppedFile(croppedFile);
setFile(croppedFile);
onChange(croppedFile, "selected", fileUploadFields?.requiredDocumentId);
},
[onChange, fileUploadFields?.requiredDocumentId]
);
const handleCancelCrop = useCallback(() => {
setCroppingFile(null);
setFile(null);
setCroppedFile(null);
onChange(null, "removed", fileUploadFields?.requiredDocumentId);
}, [onChange, fileUploadFields?.requiredDocumentId]);
const handleRemoveFile = useCallback(
(index?: number) => {
if (multiple && index !== undefined) {
const updatedFiles = Array.isArray(file)
? file.filter((_, i) => i !== index)
: null;
setFile(updatedFiles);
setFileErrorMessages(
fileErrorMessages.filter((x) => x?.fileIndex !== index)
);
if (updatedFiles && updatedFiles.length > 0) {
onChange(
updatedFiles,
"selected",
fileUploadFields?.requiredDocumentId
);
} else {
onChange(null, "removed", fileUploadFields?.requiredDocumentId);
}
} else {
setFile(null);
setCroppedFile(null);
setErrorMessage(null);
onChange(null, "removed", fileUploadFields?.requiredDocumentId);
}
},
[
file,
multiple,
fileErrorMessages,
onChange,
fileUploadFields?.requiredDocumentId,
]
);
const isMultipleFileAttached = useMemo(
() => file && Array.isArray(file),
[file]
);
const isSomeFileAttached = useMemo(() => {
if (isMultipleFileAttached) {
return Array.isArray(file) ? file.length > 0 : false;
}
return !!file;
}, [file, isMultipleFileAttached]);
return (
<Card className="w-full max-w-md relative">
<CardHeader>
<CardTitle>Upload File{multiple && "s"}</CardTitle>
<Button
variant="ghost"
size="icon"
className="absolute right-4 top-4 h-6 w-6"
onClick={handleClose}>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center gap-4">
{croppingFile ? (
<div className="w-full space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold">Crop Image</h3>
<Button
variant="ghost"
size="icon"
onClick={handleCancelCrop}>
<X className="h-5 w-5" />
</Button>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>Note</AlertTitle>
<AlertDescription>
This image will be cropped to a 3:4 aspect ratio. Please
adjust the crop area accordingly.
</AlertDescription>
</Alert>
<div className="p-4 border rounded-lg bg-muted">
<ImageCropper
file={croppingFile}
onCropComplete={handleCropComplete}
aspectRatio={3 / 4}
/>
</div>
</div>
) : (
<>
<div className={isSomeFileAttached ? "hidden" : "block"}>
<div className="flex justify-center">
<CloudUpload className="h-12 w-12" />
</div>
<h2 className="text-xl font-bold text-center mt-4">
Upload File{multiple && "s"}
</h2>
</div>
{isSomeFileAttached && (
<div className="relative flex flex-col items-center gap-4 p-4 border rounded-md">
<div
className={
isMultipleFileAttached
? "max-w-[30rem] max-h-[20rem] overflow-auto"
: "h-40 w-40"
}>
{file instanceof File &&
file?.type === "application/pdf" ? (
<div className="flex flex-col items-center justify-center pt-16">
<FileType size={60} className="h-16 w-16" />
</div>
) : isMultipleFileAttached ? (
<div className="grid grid-cols-2 gap-4">
{Array.isArray(file) &&
Array.from(file)?.map((f, i) => (
<div key={i} className="space-y-2">
{f?.type === "application/pdf" ? (
<div className="flex flex-col items-center justify-center pt-16">
<FileType size={60} className="h-16 w-16" />
</div>
) : (
<div
className={`border rounded-md ${
Array.from(file)?.length == 1
? "h-60"
: "h-48"
} overflow-auto`}>
<FilePreview file={f} type="image" />
</div>
)}
<p className="w-36 truncate">{f?.name}</p>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemoveFile(i)}>
<Trash className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
{fileErrorMessages?.find(
(item) => item.fileIndex === i
)?.error && (
<Alert variant="destructive">
<X className="h-4 w-4" />
<AlertDescription>
{
fileErrorMessages?.find(
(item) => item.fileIndex === i
)?.error
}
</AlertDescription>
</Alert>
)}
</div>
))}
</div>
) : (
<div className="w-full h-full overflow-auto">
<FilePreview
file={croppedFile || (file as File)}
type="image"
/>
</div>
)}
</div>
{!isMultipleFileAttached &&
(croppedFile || file) instanceof File && (
<p className="w-36 truncate">
{croppedFile
? croppedFile.name
: file instanceof File
? file.name
: ""}
</p>
)}
{!isMultipleFileAttached && (
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8"
onClick={() => handleRemoveFile()}>
<Trash className="h-4 w-4 text-destructive" />
</Button>
)}
{/* File Selected Indicator */}
<div className="flex items-center gap-2 text-primary-600">
<Check className="h-4 w-4" />
<span className="text-sm font-medium">File selected</span>
</div>
</div>
)}
<p className="text-sm text-muted-foreground text-center">
You can attach{" "}
{accept
?.map((item) => `${item} files (max ${MAX_FILE_SIZE_MB}MB)`)
?.join(", ")}
</p>
{multiple && (
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-primary" />
<p className="text-sm text-primary">
You can attach multiple files
</p>
</div>
)}
<div className="flex flex-col gap-4 w-full">
<Input
type="file"
accept={
accept
? getFinalAllowedFileTypes(accept)
: "image/png,image/jpeg,application/pdf"
}
onChange={(e) => {
const files = e.target.files;
if (!files) return;
if (multiple) {
handleChange(Array.from(files));
} else {
handleChange(files[0]);
}
}}
multiple={multiple}
className="hidden"
ref={openRef}
/>
<Button
variant="outline"
onClick={() => openRef.current?.click()}
className="w-full">
{isSomeFileAttached ? (
multiple ? (
<>
<Plus className="mr-2 h-4 w-4" />
Add more files
</>
) : (
<>
<FileUp className="mr-2 h-4 w-4" />
Change file
</>
)
) : (
<>
<FileUp className="mr-2 h-4 w-4" />
Select file{multiple ? "s" : ""}
</>
)}
</Button>
{/* REMOVED: Continue button - file selection is now immediate */}
</div>
{errorMessage && (
<Alert variant="destructive">
<X className="h-4 w-4" />
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
</>
)}
</div>
</CardContent>
</Card>
);
}
);
UploadFileModal.displayName = "UploadFileModal";

View File

@@ -0,0 +1,450 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import {
Building2,
Loader2,
AlertCircle,
UserRound,
} from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Card } from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Link } from "react-router-dom";
import VerifyOtp from "../verifyOTP";
import { useTradeLicenseVerification } from "@/external-portal/hooks/useTradeLicenseVerification";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import {
startExternalPortalFaydaAuth,
} from "@/shared/utils/faydaOidc";
import {
EtradeBusinessLicenseOption,
fetchRegistrationByTin,
mapEtradeBusinessLicenses,
resolveEtradeLanguage,
TinRegistrationNotFoundError,
} from "@/complaints/services/etradeTinService";
import {
isValidLicenseNo,
normalizeLicenseNo,
normalizeTin,
} from "@/external-portal/utils/etradeValidation";
import {
clearEtradeLicenseNo,
storeEtradeLicenseNo,
} from "@/external-portal/utils/etradeAuthStorage";
type OrgStep = "trade_license" | null;
type TradeLicenseStep = "tin" | "select_license";
export default function UserTypeSelection() {
const [orgStep, setOrgStep] = useState<OrgStep>(null);
const [tradeLicenseStep, setTradeLicenseStep] =
useState<TradeLicenseStep>("tin");
const [tin, setTin] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [licenseOptions, setLicenseOptions] = useState<
EtradeBusinessLicenseOption[]
>([]);
const [selectedLicenseNumber, setSelectedLicenseNumber] = useState("");
const [tradeLicenseNumber, setTradeLicenseNumber] = useState("");
const [tinError, setTinError] = useState<string | null>(null);
const [isVerifyingTin, setIsVerifyingTin] = useState(false);
const { t, i18n } = useTranslation();
const [showVerifyOtp, setShowVerifyOtp] = useState(false);
const { handleError } = useErrorHandler(t);
const { registerTradeLicense, isRegistering } = useTradeLicenseVerification();
const resetTradeLicenseFlow = () => {
setTradeLicenseStep("tin");
setTin("");
setOrganizationName("");
setLicenseOptions([]);
setSelectedLicenseNumber("");
setTradeLicenseNumber("");
setTinError(null);
setShowVerifyOtp(false);
clearEtradeLicenseNo();
};
useEffect(() => {
setOrgStep(null);
resetTradeLicenseFlow();
}, []);
const onVerifyComplete = () => {
toast.success(t("registration.etrade.registrationSuccess"));
};
const registerWithLicense = async (licenseNumber: string) => {
const normalizedLicense = normalizeLicenseNo(licenseNumber);
if (!isValidLicenseNo(normalizedLicense)) return;
setTradeLicenseNumber(normalizedLicense);
storeEtradeLicenseNo(normalizedLicense);
await registerTradeLicense({
tin: normalizeTin(tin),
licenseNo: normalizedLicense,
});
setShowVerifyOtp(true);
};
const startETradeAuth = () => {
resetTradeLicenseFlow();
setOrgStep("trade_license");
};
const handleTinVerify = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setTinError(null);
const normalizedTin = tin.trim();
if (!normalizedTin) {
setTinError(t("complaint.tin.tinRequired"));
return;
}
if (!/^\d{10}$/.test(normalizedTin)) {
setTinError(t("complaint.tin.tinInvalid"));
return;
}
try {
setIsVerifyingTin(true);
const registration = await fetchRegistrationByTin(
normalizedTin,
resolveEtradeLanguage(i18n.language),
);
const businesses = mapEtradeBusinessLicenses(
registration,
resolveEtradeLanguage(i18n.language),
);
if (businesses.length === 0) {
setTinError(t("registration.etrade.noLicensesFound"));
return;
}
const companyName = String(
registration.BusinessName ??
registration.businessName ??
registration.BusinessNameAmh ??
registration.businessNameAmh ??
"",
).trim();
setOrganizationName(companyName);
setLicenseOptions(businesses);
setSelectedLicenseNumber(businesses[0].licenseNumber);
setTradeLicenseStep("select_license");
} catch (error) {
if (error instanceof TinRegistrationNotFoundError) {
setTinError(t("complaint.tin.notFound"));
return;
}
if (
error instanceof Error &&
error.message === "ETRADE_REFERER_REJECTED"
) {
setTinError(t("complaint.tin.proxyError"));
return;
}
handleError(error);
} finally {
setIsVerifyingTin(false);
}
};
const handleLicenseSelectionSubmit = async () => {
if (!selectedLicenseNumber) return;
try {
await registerWithLicense(selectedLicenseNumber);
} catch (error) {
handleError(error);
}
};
const authOptions = [
{
provider: "fayda" as const,
title: t("registration.auth.continueWithFayda"),
description: t("registration.auth.faydaDescription"),
icon: UserRound,
onClick: () => startExternalPortalFaydaAuth(),
},
{
provider: "etrade" as const,
title: t("registration.auth.continueWithEtrade"),
description: t("registration.auth.etradeDescription"),
icon: Building2,
onClick: () => startETradeAuth(),
},
];
return (
<div className="min-h-screen flex items-center justify-center bg-primary-50 p-4">
<div className="w-full max-w-3xl">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center gap-2 mb-4">
<Building2 className="size-8 text-primary-600" />
<h1 className="text-3xl font-bold text-primary-900">
{t("registration.auth.portalTitle")}
</h1>
</div>
<p className="text-primary-700 text-lg">
{t("registration.auth.selectionDescription")}
</p>
</div>
{!orgStep && (
<>
<div className="grid gap-6 md:grid-cols-2 mb-8">
{authOptions.map((option) => {
const Icon = option.icon;
return (
<Card
key={option.provider}
className="p-6 cursor-pointer border-2 border-primary-200 bg-white transition-all hover:border-primary-400"
onClick={option.onClick}>
<div className="flex flex-col items-center text-center gap-4">
<div className="p-4 rounded-full bg-primary-100 text-primary-600">
<Icon className="size-8" />
</div>
<div>
<h2 className="text-xl font-semibold mb-2 text-primary-900">
{option.title}
</h2>
<p className="text-primary-700 text-sm">
{option.description}
</p>
</div>
</div>
</Card>
);
})}
</div>
<div className="text-center">
<Link
to="/login"
className="text-sm font-medium text-primary-700 hover:text-primary-900 hover:underline">
{t("registration.auth.employeeLogin")}
</Link>
</div>
<div className="mt-4 text-center">
<Link
to="/external-portal/signup/manual"
className="text-sm font-medium text-primary-700 hover:text-primary-900 hover:underline">
{t("registration.auth.manualOrgSignup")}
</Link>
</div>
</>
)}
{orgStep === "trade_license" && (
<div className="mb-6 flex items-center justify-center gap-2 text-sm">
<span
className={`rounded-full px-3 py-1 ${
tradeLicenseStep === "tin" && !showVerifyOtp
? "bg-primary-600 text-white"
: "bg-primary-100 text-primary-700"
}`}>
1. {t("registration.etrade.stepTin")}
</span>
<span className="text-primary-400"></span>
<span
className={`rounded-full px-3 py-1 ${
tradeLicenseStep === "select_license" && !showVerifyOtp
? "bg-primary-600 text-white"
: "bg-primary-100 text-primary-700"
}`}>
2. {t("registration.etrade.stepLicense")}
</span>
<span className="text-primary-400"></span>
<span
className={`rounded-full px-3 py-1 ${
showVerifyOtp
? "bg-primary-600 text-white"
: "bg-primary-100 text-primary-700"
}`}>
3. {t("registration.etrade.stepOtp")}
</span>
</div>
)}
{orgStep === "trade_license" &&
(!showVerifyOtp ? (
tradeLicenseStep === "tin" ? (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-2xl font-semibold text-primary-900">
{t("registration.auth.continueWithEtrade")}
</h2>
<p className="mt-2 text-sm text-primary-700">
{t("registration.etrade.enterTinDescription")}
</p>
</div>
{tinError && (
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
<p className="text-sm text-red-700">{tinError}</p>
</div>
)}
<form onSubmit={handleTinVerify} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="etrade-tin">
{t("complaint.tin.tinLabel")}
</Label>
<Input
id="etrade-tin"
inputMode="numeric"
pattern="\d*"
maxLength={10}
value={tin}
onChange={(event) =>
setTin(
event.target.value.replace(/\D/g, "").slice(0, 10),
)
}
placeholder={t("complaint.tin.tinPlaceholder")}
className="border-primary-300 focus:ring-primary-500 font-mono text-lg tracking-wide"
autoComplete="off"
disabled={isVerifyingTin}
required
/>
<p className="text-xs text-primary-600">
{t("complaint.tin.tinHint")}
</p>
</div>
<div className="flex justify-center gap-4 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setOrgStep(null)}
className="border-primary-400 text-primary-700 hover:bg-primary-100"
disabled={isVerifyingTin}>
{t("registration.etrade.back")}
</Button>
<Button
type="submit"
disabled={isVerifyingTin || tin.trim().length !== 10}
className="bg-primary-600 hover:bg-primary-700 text-white">
{isVerifyingTin ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("complaint.tin.verifying")}
</span>
) : (
t("complaint.tin.verify")
)}
</Button>
</div>
</form>
</div>
) : (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-2xl font-semibold text-primary-900">
{t("registration.etrade.selectLicenseTitle")}
</h2>
<p className="mt-2 text-sm text-primary-700">
{t("registration.etrade.selectLicenseDescription")}
</p>
{organizationName && (
<p className="mt-2 text-sm font-medium text-primary-900">
{organizationName}
</p>
)}
</div>
<div className="max-h-[24rem] space-y-3 overflow-y-auto pr-1">
{licenseOptions.map((option) => {
const isSelected =
selectedLicenseNumber === option.licenseNumber;
return (
<Card
key={option.mainGuid}
className={`cursor-pointer border-2 p-4 transition-all ${
isSelected
? "border-primary-500 bg-primary-50 ring-2 ring-primary-200"
: "border-primary-200 hover:border-primary-300 bg-white"
}`}
onClick={() =>
setSelectedLicenseNumber(option.licenseNumber)
}>
<div className="space-y-2 text-left">
<p className="font-semibold text-primary-900">
{option.tradeName}
</p>
<p className="text-sm text-primary-700">
<span className="font-medium">
{t("registration.etrade.licenseNumber")}:
</span>{" "}
<span className="font-mono">
{option.licenseNumber}
</span>
</p>
{option.activities.length > 0 && (
<p className="text-sm text-primary-600">
<span className="font-medium">
{t("registration.etrade.activity")}:
</span>{" "}
{option.activities.join("; ")}
</p>
)}
</div>
</Card>
);
})}
</div>
<div className="flex justify-center gap-4">
<Button
variant="outline"
onClick={() => {
setTradeLicenseStep("tin");
setSelectedLicenseNumber("");
setLicenseOptions([]);
}}
className="border-primary-400 text-primary-700 hover:bg-primary-100"
disabled={isRegistering}>
{t("registration.etrade.back")}
</Button>
<Button
onClick={handleLicenseSelectionSubmit}
disabled={!selectedLicenseNumber || isRegistering}
className="bg-primary-600 hover:bg-primary-700 text-white">
{isRegistering ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("registration.etrade.loading")}
</span>
) : (
t("registration.etrade.continue")
)}
</Button>
</div>
</div>
)
) : (
<VerifyOtp
licenseNo={tradeLicenseNumber}
onComplete={onVerifyComplete}
isEtradeVerification={true}
isExternalOrg={true}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { PixelCrop } from "react-image-crop";
export async function canvasPreview(
image: HTMLImageElement,
canvas: HTMLCanvasElement,
crop: PixelCrop,
scale = 1,
rotate = 0
) {
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("No 2d context");
}
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
const pixelRatio = window.devicePixelRatio;
canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
ctx.scale(pixelRatio, pixelRatio);
ctx.imageSmoothingQuality = "high";
const cropX = crop.x * scaleX;
const cropY = crop.y * scaleY;
const cropWidth = crop.width * scaleX;
const cropHeight = crop.height * scaleY;
const rotateRads = rotate * (Math.PI / 180);
const centerX = image.naturalWidth / 2;
const centerY = image.naturalHeight / 2;
ctx.save();
ctx.translate(-cropX, -cropY);
ctx.translate(centerX, centerY);
ctx.rotate(rotateRads);
ctx.scale(scale, scale);
ctx.translate(-centerX, -centerY);
ctx.drawImage(
image,
0,
0,
image.naturalWidth,
image.naturalHeight,
0,
0,
image.naturalWidth,
image.naturalHeight
);
ctx.restore();
}

View File

@@ -0,0 +1,270 @@
import { useState } from "react";
import Header from "@/layout/components/Header";
import { Button } from "@/shared/common/ui/button";
import { useToast } from "@/shared/common/ui/use-toast";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useDocumentRequirement } from "@/shared/hooks/useOrganizationReport";
import { useMyUploads } from "@/external-portal/hooks/useMyUpload";
import { useQueries } from "@tanstack/react-query";
import {
getMyUploadById,
LatestUploadInfo,
submitApplication,
} from "@/external-portal/services/portalOutgoingService";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import { FilterEnum } from "@/shared/services/organizationsService";
import { UploadFileModal } from "./UploadFileModal";
import { useUser } from "@/shared/context/UserContext";
interface LocalizedText {
am: string;
en: string;
}
type Upload = {
uploadId: string;
// add other fields if needed
};
interface Document {
id: string;
title: LocalizedText;
description: LocalizedText;
key: string;
type: string;
order: number;
isActive: boolean;
isOptional: boolean;
}
const UploadSteps = () => {
const { toast } = useToast();
const localized = useLocalizedName();
const userDetails = useUser();
const filterDataStr = userDetails?.userType;
const mappedType =
filterDataStr === "external_organization" ? "organization" : filterDataStr;
const filterData: FilterEnum | undefined =
mappedType && Object.values(FilterEnum).includes(mappedType as FilterEnum)
? (mappedType as FilterEnum)
: undefined;
// Fetch required docs
const { requirementDoc } = useDocumentRequirement(filterData);
const { data: myUploads } = useMyUploads();
// Step 1: Collect latest upload IDs for each document
const latestUploadIds = (requirementDoc.items ?? []).map((doc) => {
const uploadsForDoc = (myUploads ?? []).filter(
(u) => u.documentId === doc.id
);
if (uploadsForDoc.length) {
const latestUpload = uploadsForDoc.reduce((prev, current) =>
new Date(prev.createdAt) > new Date(current.createdAt) ? prev : current
);
return {
docId: doc.id,
uploadId: latestUpload.id,
fileInfo: latestUpload.fileInfo,
};
}
return { docId: doc.id, uploadId: null, fileInfo: null };
});
// Step 2: Query presigned URLs for latest uploads
const queriesToRun = (latestUploadIds as LatestUploadInfo[]).filter(
({ uploadId }) => !!uploadId
);
const uploadQueries = useQueries({
queries: queriesToRun.map(({ uploadId }) => ({
queryKey: ["myUpload", uploadId],
queryFn: () => getMyUploadById(uploadId!),
})),
});
// Step 3: Build files record
const filesRecord: Record<string, File | string | null> =
latestUploadIds.reduce((acc, { docId, fileInfo }, idx) => {
const queryIndex = queriesToRun.findIndex(
(q) => q.uploadId === latestUploadIds[idx].uploadId
);
const uploadData =
queryIndex >= 0 ? uploadQueries[queryIndex]?.data : undefined;
acc[docId] = uploadData?.presigned || fileInfo?.fileName || null;
return acc;
}, {} as Record<string, File | string | null>);
// State for modal and files
const [uploadedFiles, setUploadedFiles] = useState(filesRecord);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const handleOpenModal = (doc: Document) => {
setSelectedDoc(doc);
setModalOpen(true);
};
const handleUploadComplete = (file: File | string | null) => {
if (selectedDoc) {
const newFiles = {
...uploadedFiles,
[selectedDoc.id]: file,
};
setUploadedFiles(newFiles);
setSelectedDoc(null);
setModalOpen(false);
if (file) {
toast({
title: "Success",
description:
"File uploaded successfully. You will be notified of your status via SMS shortly.",
variant: "default",
});
}
}
};
const handleSubmit = async () => {
try {
const response = await submitApplication();
if (!response) {
toast({
title: "Error",
description: "User ID is missing. Cannot submit application.",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description:
"You have submitted your application and will be notified of the status. Thank you!",
variant: "default",
});
} catch (error) {
console.error(error);
toast({
title: "Error",
description: "Failed to submit application.",
variant: "destructive",
});
}
};
return (
<div className="flex flex-col gap-4 w-full">
<Header />
<div className="p-4 bg-white shadow rounded-lg w-full">
<h2 className="text-lg font-semibold text-center">Upload Documents</h2>
<p className="text-sm text-gray-600 text-center">
Please upload the required documents for verification.
</p>
<div className="w-full max-w-5xl mx-auto mt-4 space-y-4">
{requirementDoc?.items.length === 0 && (
<div>No Document Requirements available.</div>
)}
{requirementDoc?.items.map((doc: Document) => (
<div
key={doc.id}
className="flex items-center justify-between p-4 border rounded-md">
<div className="flex flex-col">
<span className="font-medium">{localized(doc.title)}</span>
{doc.description && (
<span className="text-sm text-muted-foreground">
{localized(doc.description)}
</span>
)}
</div>
<div className="flex gap-2">
{uploadedFiles[doc.id] && (
<Button
variant="outline"
size="sm"
onClick={() => {
const file = uploadedFiles[doc.id];
if (!file) return;
if (typeof file === "string") {
window.open(file, "_blank");
} else if (file instanceof File) {
const url = URL.createObjectURL(file);
window.open(url, "_blank");
}
}}>
View
</Button>
)}
<Button
size="sm"
className="bg-primary-600"
onClick={() => handleOpenModal(doc)}>
{uploadedFiles[doc.id] ? "Replace" : "Upload"}
</Button>
</div>
</div>
))}
<div className="pt-4">
<Button
size="sm"
className="bg-primary-600 w-full"
disabled={
Object.values(uploadedFiles).filter((file) => file !== null)
.length === 0
}
onClick={handleSubmit}>
Submit
</Button>
</div>
</div>
</div>
{/* Modal Overlay */}
{modalOpen && selectedDoc && (
<UploadFileModal
value={uploadedFiles[selectedDoc.id] || null}
multiple={false}
accept={[
".png",
".jpg",
".jpeg",
".pdf",
".dwg",
".dxf",
".dwt",
".bak",
".sv$",
".dws",
".mxd",
".aprx",
".rar",
".zip",
]}
onChange={(file) => {
if (file === null) handleUploadComplete(null);
else if (file instanceof File) handleUploadComplete(file);
else if (Array.isArray(file) && file[0])
handleUploadComplete(file[0]);
}}
fileUploadFields={{
requiredDocumentId: selectedDoc.id,
}}
/>
)}
</div>
);
};
export default UploadSteps;

View File

@@ -0,0 +1,26 @@
// components/useDebounceEffect.ts
import { useEffect, useRef } from "react";
export function useDebounceEffect(
fn: () => void,
waitTime: number,
deps?: any[]
) {
const timeoutRef = useRef<number>(0);
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
fn();
}, waitTime);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, deps);
}

View File

@@ -0,0 +1,447 @@
"use client";
import React, { useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "react-router-dom";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Mail, User, Phone, Text, Building } from "lucide-react";
import { useToast } from "@/shared/common/ui/use-toast";
import { userSchema, UserFormValues } from "./outgoing/formSchema";
import { useRegisterExternalPortalUser } from "../hooks/useRegisterExternalPortalUser";
import { useTenantConfig } from "@/layout/components/TenantConfig";
import { useDocumentRequirement } from "@/shared/hooks/useOrganizationReport";
import { useMyUploads } from "@/external-portal/hooks/useMyUpload";
import { useQueries } from "@tanstack/react-query";
import {
getMyUploadById,
submitApplication,
} from "@/external-portal/services/portalOutgoingService";
import { FilterEnum } from "@/shared/services/organizationsService";
import { UploadFileModal } from "./Registration/UploadFileModal";
type LocalizedText = { am: string; en: string };
interface Document {
id: string;
title: LocalizedText;
description: LocalizedText;
key: string;
type: string;
order: number;
isActive: boolean;
isOptional: boolean;
}
export default function SignupWithUploads() {
const { toast } = useToast();
const navigate = useNavigate();
const { config: tenantConfig } = useTenantConfig();
const { registerExternalPortalUser, isRegistering } =
useRegisterExternalPortalUser();
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
defaultValues: {
email: "",
username: "",
phoneNumber: "",
userType: "external_organization",
name: { am: "", en: "" },
},
mode: "onChange",
});
// watch userType so we can load document requirements for that type
const watchedUserType = form.watch("userType") || "external_organization";
// Map userType to FilterEnum values (same logic you used elsewhere)
const mappedType =
watchedUserType === "external_organization"
? "organization"
: watchedUserType;
const filterData: FilterEnum | undefined =
mappedType && Object.values(FilterEnum).includes(mappedType as FilterEnum)
? (mappedType as FilterEnum)
: undefined;
// fetch requirement doc for the watched user type
const { requirementDoc } = useDocumentRequirement(filterData);
// fetch user's previous uploads (if any) and prefill the upload list
const { data: myUploads } = useMyUploads();
// build latest upload ids per requirement doc (like your UploadSteps logic)
const latestUploadIds = (requirementDoc.items ?? []).map((doc) => {
const uploadsForDoc = (myUploads ?? []).filter(
(u) => u.documentId === doc.id
);
if (uploadsForDoc.length) {
const latestUpload = uploadsForDoc.reduce((prev, current) =>
new Date(prev.createdAt) > new Date(current.createdAt) ? prev : current
);
return {
docId: doc.id,
uploadId: latestUpload.id,
fileInfo: latestUpload.fileInfo,
};
}
return { docId: doc.id, uploadId: null, fileInfo: null };
});
// query presigned urls for each latest upload
const uploadQueries = useQueries({
queries:
latestUploadIds.map(({ uploadId }:any) => ({
queryKey: ["myUpload", uploadId],
queryFn: () => getMyUploadById(uploadId as string),
enabled: !!uploadId,
})) || [],
});
// build filesRecord map: docId -> presignedURL | fileName | null
const filesRecord = latestUploadIds.reduce(
(acc: Record<string, File | string | null>, { docId, fileInfo }: any, idx:any) => {
const uploadData = uploadQueries[idx]?.data as
| { data?: { presigned?: string } }
| undefined;
const presigned = uploadData?.data?.presigned;
acc[docId] = presigned || fileInfo?.fileName || null;
return acc;
},
{} as Record<string, File | string | null>
);
// local state to track uploaded files while user interacts on this page
const [uploadedFiles, setUploadedFiles] =
useState<Record<string, File | string | null>>(filesRecord);
// modal state for UploadFileModal
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const openUploadModal = (doc: Document) => {
setSelectedDoc(doc);
setModalOpen(true);
};
const handleUploadComplete = (file: File | null | string) => {
if (!selectedDoc) return;
const newFiles = { ...uploadedFiles, [selectedDoc.id]: file };
setUploadedFiles(newFiles);
setSelectedDoc(null);
setModalOpen(false);
if (file) {
toast({
title: "Success",
description:
"File uploaded successfully. You will be notified of your status via SMS shortly.",
variant: "default",
});
}
};
// Signup submit: registers the user then triggers submitApplication (if desired)
const onSubmit = async (values: UserFormValues) => {
try {
// 1) Register user (existing hook)
await registerExternalPortalUser(values);
// 2) After registration, optionally submit an application.
// Your uploadDocuments hook (used in UploadFileModal) already uploads
// files during the modal upload action. submitApplication() finalizes/sends the application.
try {
await submitApplication();
} catch (err) {
// non-blocking: show toast but continue to navigate to verify OTP
console.error("submitApplication error", err);
toast({
title: "Warning",
description:
"Account created but application submission failed. You can retry later.",
variant: "destructive",
});
}
// 3) Navigate to verify-otp with query params (same behavior you had)
const params = new URLSearchParams();
if (values.email) params.set("email", values.email.trim());
if (values.phoneNumber) {
const normalizedPhone = values.phoneNumber.trim().replace(/^0/, "+251");
params.set("phone", normalizedPhone);
}
navigate(`/external-portal/verify-otp?${params.toString()}`);
} catch (error) {
console.error("Signup error:", error);
toast({
title: "Error",
description: "Failed to create account. Please check your details.",
variant: "destructive",
});
}
};
// helper localizer — re-use your localized name hook if needed (not provided here).
const localized = (t: LocalizedText) => t?.en || t?.am || "";
// count uploaded files
const attachedCount = useMemo(
() => Object.values(uploadedFiles).filter((f) => f !== null).length,
[uploadedFiles]
);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center p-4">
<div className="w-full max-w-6xl bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col md:flex-row h-full max-h-[900px]">
{/* Left: Signup form */}
<div className="md:w-1/2 w-full p-8 md:p-12 flex flex-col gap-4">
<div className="flex items-center mb-4">
<img
src={tenantConfig.logo || "/assets/smart-office-logo.svg"}
alt={tenantConfig.appName}
className="h-10 object-contain"
/>
<span className="ml-2 text-xl font-semibold text-gray-800">
{tenantConfig.appName}
</span>
</div>
<div>
<h1 className="text-2xl font-bold mb-1">Create Account</h1>
<p className="text-sm text-gray-500">
Create your account and upload required documents on the right.
</p>
</div>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mt-4 flex-1 flex flex-col">
<div className="space-y-4">
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...form.register("email")}
type="email"
placeholder="Email"
className="pl-10 h-12"
/>
</div>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...form.register("username")}
type="text"
placeholder="Username"
className="pl-10 h-12"
/>
</div>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...form.register("phoneNumber")}
type="tel"
placeholder="Phone Number"
className="pl-10 h-12"
/>
</div>
<div className="relative">
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...form.register("name.en")}
type="text"
placeholder="Name (English)"
className="pl-10 h-12"
/>
</div>
<div className="relative">
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...form.register("name.am")}
type="text"
placeholder="Name (Amharic)"
className="pl-10 h-12"
/>
</div>
{/* userType control — you may already have this as a select / radio; using plain input for brevity */}
<div>
<label className="text-sm text-gray-600">Account type</label>
<div className="mt-2 flex gap-2">
<label
className={`px-3 py-2 rounded-md border ${
form.getValues("userType") === "external_organization"
? "bg-primary-50 border-primary"
: ""
}`}>
<input
type="radio"
{...form.register("userType")}
value="external_organization"
className="mr-2"
/>
Organization
</label>
<label
className={`px-3 py-2 rounded-md border ${
form.getValues("userType") === "individual"
? "bg-primary-50 border-primary"
: ""
}`}>
<input
type="radio"
{...form.register("userType")}
value="individual"
className="mr-2"
/>
Individual
</label>
</div>
</div>
</div>
<div className="mt-auto pt-4">
<Button
type="submit"
className="w-full h-12 bg-primary hover:bg-primary-700 text-white"
disabled={isRegistering}>
{isRegistering
? "Creating Account..."
: "Create account & submit"}
</Button>
<p className="text-center text-sm text-gray-500 mt-3">
Already have an account?{" "}
<a href="/login" className="text-primary">
Sign in
</a>
</p>
</div>
</form>
</div>
{/* Vertical divider */}
<div className="hidden md:block w-px bg-gray-200" />
{/* Right: Upload document area (changes by userType) */}
<div className="md:w-1/2 w-full p-6 md:p-10">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">
Upload Required Documents
</h3>
<p className="text-sm text-gray-500">
Required documents for:{" "}
<span className="font-medium">{mappedType}</span>
</p>
</div>
<div className="text-sm text-gray-600">
{attachedCount} attached
</div>
</div>
<div className="space-y-3 max-h-[62vh] overflow-auto">
{requirementDoc?.items?.length === 0 && (
<div className="text-sm text-gray-500">
No document requirements for this account type.
</div>
)}
{requirementDoc?.items?.map((doc: Document) => (
<div
key={doc.id}
className="flex items-center justify-between p-3 border rounded-md">
<div className="flex flex-col">
<span className="font-medium">{localized(doc.title)}</span>
{doc.description && (
<span className="text-sm text-muted-foreground">
{localized(doc.description)}
</span>
)}
</div>
<div className="flex gap-2 items-center">
{uploadedFiles[doc.id] && (
<Button
variant="outline"
size="sm"
onClick={() => {
const file = uploadedFiles[doc.id];
if (!file) return;
if (typeof file === "string") {
window.open(file, "_blank");
} else if (file instanceof File) {
const url = URL.createObjectURL(file);
window.open(url, "_blank");
}
}}>
View
</Button>
)}
<Button
size="sm"
className="bg-primary-600"
onClick={() => openUploadModal(doc)}>
{uploadedFiles[doc.id] ? "Replace" : "Upload"}
</Button>
</div>
</div>
))}
</div>
<div className="mt-4 text-sm text-gray-500">
Tip: You can upload files now or complete your account and upload
later.
</div>
</div>
</div>
{/* Upload modal */}
{modalOpen && selectedDoc && (
<UploadFileModal
value={uploadedFiles[selectedDoc.id] || null}
multiple={false}
accept={[
".png",
".jpg",
".jpeg",
".pdf",
".dwg",
".dxf",
".dwt",
".bak",
".sv$",
".dws",
".mxd",
".aprx",
".rar",
".zip",
]}
onChange={(file) => {
if (file === null) handleUploadComplete(null);
else if (file instanceof File) handleUploadComplete(file);
else if (Array.isArray(file) && file[0])
handleUploadComplete(file[0]);
}}
fileUploadFields={{
requiredDocumentId: selectedDoc.id,
// optionally: photoConfiguration etc.
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,339 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchRecordById,
regeneratePdf,
} from "@/record-management/services/api/userRecordService";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import FileList from "@/record-management/common/fileListComponent";
import {
ChevronLeft,
RefreshCw,
Maximize,
Minimize,
Download,
X,
MoveLeft,
} from "lucide-react";
import { attachmentResponse } from "../hooks/useCreateExternalLetterRecord";
import { Button } from "@/shared/common/ui/button";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
interface ViewExternalRecordProps {
itemId: string;
onBack: () => void;
}
const Box = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 hover:border-primary-200 dark:hover:border-primary-600 transition-colors">
<label className="block text-base font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
{title}
</label>
{children}
</div>
);
const ViewExternalRecord: React.FC<ViewExternalRecordProps> = ({
itemId,
onBack,
}) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const [isFullScreen, setIsFullScreen] = useState(false);
const [isLoadingPdf, setIsLoadingPdf] = useState(true);
const { handleError } = useErrorHandler(t);
const { data: attachment, isLoading, error } = attachmentResponse(itemId);
const { data: record, error: recordFetchError } = useQuery({
queryKey: ["record", itemId],
queryFn: () => fetchRecordById(itemId!),
enabled: !!itemId,
});
// Surface main-record fetch failures via the centralized error handler.
// attachment failure stays silent — the existing inline UI already covers it.
const prevRecordErrorRef = useRef<unknown>(null);
useEffect(() => {
if (recordFetchError && recordFetchError !== prevRecordErrorRef.current) {
prevRecordErrorRef.current = recordFetchError;
handleError(recordFetchError);
}
}, [recordFetchError, handleError]);
const attachmentUrl = useMemo(() => {
if (!attachment) return null;
// Handle ArrayBuffer case
if (attachment instanceof ArrayBuffer) {
const blob = new Blob([attachment], { type: "application/pdf" });
return URL.createObjectURL(blob);
}
// Handle presigned URL case
if (typeof attachment === "object" && "presigned" in attachment) {
return attachment.presigned;
}
// Handle direct URL case
if (typeof attachment === "string") {
return attachment.startsWith("http") ? attachment : null;
}
return null;
}, [attachment]);
useEffect(() => {
return () => {
// Clean up blob URLs
if (attachmentUrl && attachmentUrl.startsWith("blob:")) {
URL.revokeObjectURL(attachmentUrl);
}
};
}, [attachmentUrl]);
const { mutate: regeneratePdfMutation, isPending: isRegenerating } =
useMutation({
mutationFn: async () => {
if (!itemId) throw new Error("Record ID is missing");
await regeneratePdf(itemId);
},
onSuccess: () => {
toast.success(t("msg.pdfRegenerated"));
queryClient.invalidateQueries({
queryKey: ["attachmentResponse", itemId],
});
},
onError: (error) => {
handleError(error);
},
});
const handleDownload = () => {
if (!attachmentUrl) return;
const link = document.createElement("a");
link.href = attachmentUrl;
link.download = `document-${itemId}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const openFullScreen = () => {
setIsFullScreen(true);
};
const closeFullScreen = () => {
setIsFullScreen(false);
};
const handleIframeLoad = () => {
setIsLoadingPdf(false);
};
// Handle escape key to exit fullscreen
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape" && isFullScreen) {
closeFullScreen();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isFullScreen]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">{t("loading")}...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="bg-red-100 rounded-full p-3 w-12 h-12 flex items-center justify-center mx-auto mb-4">
<X className="h-6 w-6 text-red-600" />
</div>
<h3 className="text-lg font-medium text-red-800 mb-2">
{t("error.failedToLoadPdf")}
</h3>
<p className="text-red-600 mb-4">{error.message}</p>
<div className="flex">
<Button
onClick={onBack}
size="sm" // or use className for custom sizing
className="bg-gray-600 text-sm px-1 py-1 w-[100px] hover:bg-gray-700"
>
<MoveLeft className="w-4 h-4" />
{t("common.back")}
</Button>
</div>
</div>
</div>
);
}
return (
<>
{/* Normal View */}
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center mb-6">
<div className="flex">
<Button
onClick={onBack}
size="sm" // or use className for custom sizing
className="bg-gray-600 text-sm px-1 py-1 w-[100px] hover:bg-gray-700"
>
<MoveLeft className="w-4 h-4" />
{t("common.back")}
</Button>
</div>
<div className="flex gap-3">
<button
onClick={() => regeneratePdfMutation()}
disabled={isRegenerating || !attachmentUrl}
className="flex items-center px-4 py-2 bg-white border border-gray-300 rounded-lg shadow-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<RefreshCw
className={`h-4 w-4 mr-2 ${
isRegenerating ? "animate-spin" : ""
}`}
/>
{t("viewDetail.regeneratePdf")}
</button>
<button
onClick={handleDownload}
disabled={!attachmentUrl}
className="flex items-center px-4 py-2 bg-white border border-gray-300 rounded-lg shadow-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<Download className="h-4 w-4 mr-2" />
{t("userRecord.Download")}
</button>
<button
onClick={openFullScreen}
disabled={!attachmentUrl}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg shadow-sm hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<Maximize className="h-4 w-4 mr-2" />
{t("viewDetail.fullScreen")}
</button>
</div>
</div>
<div className="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden">
{attachmentUrl ? (
<div className="relative">
{isLoadingPdf && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-gray-600 dark:text-gray-400 flex items-center gap-2">
<span className="animate-spin rounded-full h-4 w-4 border-t-2 border-primary-600 dark:border-primary-400"></span>
{t("viewDetail.loading")}
</p>
</div>
</div>
)}
<iframe
src={attachmentUrl}
title="PDF Preview"
width="100%"
height="800px"
style={{ border: "none" }}
onLoad={handleIframeLoad}
className={
isLoadingPdf
? "opacity-0"
: "opacity-100 transition-opacity duration-300"
}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-96 bg-gray-50">
<div className="bg-gray-100 rounded-full p-4 w-16 h-16 flex items-center justify-center mb-4">
<X className="h-8 w-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{t("noPdfAvailable")}
</h3>
<p className="text-gray-500 text-center max-w-md">
{t("noPdfAvailableDescription") ||
"The PDF document is not available. You can try regenerating it using the button above."}
</p>
</div>
)}
</div>
</div>
</div>
{/* Full Screen Overlay */}
{isFullScreen && attachmentUrl && (
<div className="fixed inset-0 bg-black z-50 flex flex-col">
{/* Header */}
<div className="bg-gray-900 text-white px-6 py-4 flex justify-between items-center">
<div className="flex items-center">
<span className="font-medium">Document Preview</span>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleDownload}
className="flex items-center px-3 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors duration-200"
title="Download"
>
<Download className="h-4 w-4" />
</button>
<button
onClick={closeFullScreen}
className="flex items-center px-3 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors duration-200"
title="Exit Fullscreen"
>
<Minimize className="h-4 w-4" />
</button>
</div>
</div>
{/* PDF Container */}
<div className="flex-1 bg-gray-800">
<iframe
src={attachmentUrl}
title="PDF Preview - Full Screen"
width="100%"
height="100%"
style={{ border: "none" }}
/>
</div>
{/* Footer */}
<div className="bg-gray-900 text-white px-6 py-3 text-sm text-center">
<p>Press ESC to exit fullscreen mode</p>
</div>
</div>
)}
<div className="mt-3">
<Box title={t("viewDetail.attachments")}>
<FileList
attachments={record?.content[0]?.recordAttachments ?? []}
showDelete={false}
/>
</Box>
</div>
</>
);
};
export default ViewExternalRecord;

View File

@@ -0,0 +1,148 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Button } from "@/shared/common/ui/button";
import { Card, CardHeader, CardContent } from "@/shared/common/ui/card";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import VerifyOtp from "./verifyOTP";
import { Clock } from "lucide-react";
import { useUser } from "@/shared/context/UserContext";
export const LandingPageLayout = () => {
const userDetails = useUser();
const doesUserHaveSetPassword = userDetails?.hasFinishedRegistration;
const shouldBlock = doesUserHaveSetPassword === false;
const shellClasses =
"min-h-screen flex flex-col w-full " +
(shouldBlock ? "blur-sm pointer-events-none select-none" : "");
return (
<>
{/* Main Shell (blurred & non-interactive when blocked) */}
<div className={shellClasses} aria-hidden={shouldBlock}>
{/* Header Section */}
<header className="text-center py-10 border-b border-gray-200 bg-primary-500 ">
<div className="container">
<h1 className="text-4xl font-bold text-primary-foreground mb-4">
Welcome to SmartOffice
</h1>
<p className="text-xl text-primary-foreground/90 max-w-2xl mx-auto mb-8">
Submit, track, and manage letters to organizations seamlessly. Our
system ensures transparency and traceability at every step.
</p>
{!userDetails && (
<div className="flex justify-center gap-4">
<Button asChild variant="secondary">
<Link to="/external-portal/signin">Sign In</Link>
</Button>
<Button asChild>
<Link to="/external-portal/signup">Sign Up</Link>
</Button>
</div>
)}
</div>
</header>
{/* Main Content */}
<main className="flex-grow py-12 px-4">
<section className="container max-w-6xl mx-auto">
<h2 className="text-3xl font-bold text-center text-foreground mb-4">
How It Works
</h2>
<p className="text-lg text-muted-foreground text-center max-w-2xl mx-auto mb-12">
From registration to letter submission and tracking, we've made
the process simple.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="hover:shadow-md transition-shadow">
<CardHeader>
<h3 className="text-xl font-semibold text-primary">
1. Sign Up
</h3>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Register your organization or as an individual. Get a
confirmation via text message.
</p>
</CardContent>
</Card>
<Card className="hover:shadow-md transition-shadow">
<CardHeader>
<h3 className="text-xl font-semibold text-primary">
2. Log In
</h3>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Access your dashboard and start interacting with the system
securely.
</p>
</CardContent>
</Card>
<Card className="hover:shadow-md transition-shadow hover:cursor-pointer">
<Link to="/external-portal/portal-outgoing/submit-letter">
<CardHeader>
<h3 className="text-xl font-semibold text-primary">
3. Submit Letters
</h3>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Select your target organization and submit your letter
directly through the portal.
</p>
</CardContent>
</Link>
</Card>
<Card className="hover:shadow-md transition-shadow hover:cursor-pointer">
<Link to="/external-portal/portal-outgoing">
<CardHeader>
<h3 className="text-xl font-semibold text-primary">
4. Track Activities
</h3>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
See your submitted letters and view the activity logs
associated with each.
</p>
</CardContent>
</Link>
</Card>
</div>
</section>
</main>
{/* Footer */}
<footer className="py-6 border-t border-gray-200 text-center text-muted-foreground">
<div className="container">
<p>© 2025 SmartOffice. All rights reserved.</p>
</div>
</footer>
</div>
{shouldBlock && (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center text-center p-6 pointer-events-auto">
<div className="bg-white bg-opacity-90 backdrop-blur-md rounded-xl shadow-xl p-8 max-w-md w-full border border-gray-200">
<Clock className="w-12 h-12 text-yellow-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-red-600 mb-4">
Access Restricted
</h2>
<p className="text-gray-800 text-lg">
Your registration is pending. Please wait for approval from the
SmartOffice administrator.
</p>
</div>
</div>
)}
</>
);
};
export default LandingPageLayout;

View File

@@ -0,0 +1,93 @@
import { z } from "zod";
export const createLetterSchema = z.object({
fileInfo: z.object({
fileName: z.string(),
contentType: z.string(),
size: z.number(),
originalname: z.string(),
}),
subject: z.string().min(1, "Subject is required"),
letterNumber: z.string().min(1, "Letter number is required"),
preferredLanguage: z.enum(["am", "en"], {
required_error: "Preferred language is required",
}),
});
// 👇 If you want TypeScript types derived from the Zod schema:
export type CreateLetterFormValues = z.infer<typeof createLetterSchema>;
export const createComplaintLetterSchema = z.object({
fileInfo: z.object({
fileName: z.string(),
contentType: z.string(),
size: z.number(),
originalname: z.string(),
}),
subject: z.string().min(1, "Subject is required"),
description: z.string().min(1, "Description is required"),
recipient: z.string().optional(),
letterNumber: z.string().optional(),
preferredLanguage: z.enum(["am", "en"], {
required_error: "Preferred language is required",
}),
});
export type CreateComplaintLetterFormValues = z.infer<
typeof createComplaintLetterSchema
>;
export const userSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
username: z
.string()
.min(3, { message: "Username must be at least 3 characters" }),
phoneNumber: z
.string()
.min(10, { message: "Phone number must be at least 10 digits" }),
userType: z.string().min(1, { message: "User type is required" }),
name: z.object({
am: z.string().min(1, { message: "Amharic name is required" }),
en: z.string().min(1, { message: "English name is required" }),
}),
});
export type UserFormValues = z.infer<typeof userSchema>;
export const defaultCreateLetterValues: CreateLetterFormValues = {
fileInfo: {
fileName: "",
contentType: "",
size: 0,
originalname: "",
},
subject: "",
letterNumber: "",
preferredLanguage: "en",
};
export const defaultCreateComplaintLetterValues: CreateComplaintLetterFormValues =
{
fileInfo: {
fileName: "",
contentType: "",
size: 0,
originalname: "",
},
subject: "",
description: "",
recipient: "",
letterNumber: "",
preferredLanguage: "en",
};
export const defaultUserValues: UserFormValues = {
email: "",
username: "",
phoneNumber: "",
userType: "",
name: {
am: "",
en: "",
},
};

View File

@@ -0,0 +1,207 @@
import { useState, useMemo, useEffect } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { DataTable } from "@/record-management/common/DataTable";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { FilePlus2, Search } from "lucide-react";
import { t } from "i18next";
import { portalOutgoingColumn } from "./portalOutgoingColumn";
import { useExternalPortal } from "@/external-portal/hooks/useCreateExternalLetterRecord";
import Loader from "@/record-management/common/loader";
import { useUser } from "@/shared/context/UserContext";
import { useLocalizedName } from "@/shared/common/localizedName";
import { hasComplaintVerification } from "@/complaints/utils/complaintVerificationStorage";
import { COMPLAINT_SUBMIT_PATH } from "@/complaints/utils/complaintRoutes";
type FilterType = "Reference" | "From" | "Subject" | "letterNumber";
const PortalOutgoing = () => {
const [statusFilter, setStatusFilter] = useState<string>("");
const [searchQuery, setSearchQuery] = useState("");
const [showRegistrationAlert, setShowRegistrationAlert] = useState(false);
const userDetails = useUser();
const localizedName = useLocalizedName();
const hasCompletedRegistration = userDetails?.hasFinishedRegistration;
const navigate = useNavigate();
const location = useLocation();
const complaintSessionActive = hasComplaintVerification();
const showComplaintPrompt =
complaintSessionActive ||
Boolean(
(location.state as { fromComplaintVerification?: boolean } | null)
?.fromComplaintVerification,
);
useEffect(() => {
if (!hasCompletedRegistration && !complaintSessionActive) {
setShowRegistrationAlert(true);
} else {
setShowRegistrationAlert(false);
}
}, [complaintSessionActive, hasCompletedRegistration]);
const [filterType, setFilterType] = useState<FilterType>("Reference");
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const params: Record<string, any> = {
skip: pagination.pageIndex * pagination.pageSize,
take: pagination.pageSize,
orderBy: "record.createdAt:DESC",
};
const {
ExternalLetters,
isFetchingLetters,
ExternalCount,
refetchExternalLetters,
} = useExternalPortal(params);
const statusOptions = [{ value: "", label: t("statusBar.All") }];
const filteredRecords = useMemo(() => {
let filtered = ExternalLetters;
if (statusFilter) {
filtered = filtered.filter(
(record: any) => record.status === statusFilter,
);
}
if (!searchQuery) return filtered;
const query = searchQuery.toLowerCase();
return filtered.filter((record: any) => {
switch (filterType) {
case "Reference":
return record.letterNumber?.toLowerCase().includes(query);
case "Subject":
return record.content[0]?.subject.toLowerCase().includes(query);
default:
return true;
}
});
}, [ExternalLetters, statusFilter, searchQuery, filterType]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
};
const handleFilterChange = (value: FilterType) => {
setFilterType(value);
};
return (
<div className="min-h-screen w-full bg-slate-50 py-6 md:py-8">
<div className="mx-auto max-w-6xl px-4 md:px-6">
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">
{t("nav.myRecord")}
</h1>
<p className="mt-1 text-sm text-slate-600">
{t("complaint.fayda.formSubtitle")}
</p>
</div>
<Link to={COMPLAINT_SUBMIT_PATH}>
<Button className="h-11 rounded-lg px-5 shadow-sm bg-primary text-primary-foreground hover:bg-primary/90">
<FilePlus2 className="mr-2 h-4 w-4" />
{complaintSessionActive
? t("complaint.submit")
: t("userRecord.Add Record")}
</Button>
</Link>
</div>
{showComplaintPrompt ? (
<div className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
{t("complaint.choice.prompt")}{" "}
<Link
to={COMPLAINT_SUBMIT_PATH}
className="font-semibold underline underline-offset-2">
{t("complaint.submit")}
</Link>
</div>
) : null}
{showRegistrationAlert ? (
<div className="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{t("registration.registrationRequired")}{" "}
<button
type="button"
className="font-semibold underline underline-offset-2"
onClick={() => navigate("/external-portal/upload-documents")}>
{t("registration.uploadDocuments")}
</button>
</div>
) : null}
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm md:p-5">
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-2">
{statusOptions.map((option) => (
<Button
key={option.value}
variant={statusFilter === option.value ? "linkActive" : "link"}
onClick={() => setStatusFilter(option.value)}
className="rounded-md">
{option.label}
</Button>
))}
</div>
<div className="flex w-full flex-col gap-3 sm:flex-row lg:w-auto">
<Select value={filterType} onValueChange={handleFilterChange}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder={t("userRecord.Filter by")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="Subject">
{t("userRecord.Subject")}
</SelectItem>
<SelectItem value="Reference">
{t("userRecord.Letter Number")}
</SelectItem>
</SelectContent>
</Select>
<div className="relative w-full sm:min-w-[240px]">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="pl-9"
type="text"
placeholder={`${t("userRecord.Search")} ${filterType.toLowerCase()}`}
value={searchQuery}
onChange={handleSearchChange}
/>
</div>
</div>
</div>
{isFetchingLetters ? <Loader /> : null}
<DataTable
tableName="External Records"
columns={portalOutgoingColumn(
localizedName as (name?: { am?: string; en?: string }) => string,
)}
data={filteredRecords}
toolBarPosition="right"
totalCount={ExternalCount}
pagination={pagination}
setPagination={setPagination}
refresh={refetchExternalLetters}
/>
</div>
</div>
</div>
);
};
export default PortalOutgoing;

View File

@@ -0,0 +1,111 @@
import { ColumnDef } from "@tanstack/react-table";
import { IncomingRecordDto } from "@/record-management/dto/userRecords/userRecordsDto";
import { Badge } from "@/shared/common/ui/badge";
import { Button } from "@/shared/common/ui/button";
import { Download, Eye, MoreHorizontal, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { LetterRecordDto } from "@/shared/dto/External-Portal/External-PortalDto";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { renderStatus } from "@/record-management/utils/renderDetails";
export const portalOutgoingColumn = (
localizedName: (name?: { am?: string; en?: string }) => string
): ColumnDef<LetterRecordDto>[] => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { t, i18n } = useTranslation();
// eslint-disable-next-line react-hooks/rules-of-hooks
const navigate = useNavigate();
const handleViewRecord = (recordId: string) => {
navigate(`/external-portal/view/${recordId}`);
};
return [
{
accessorKey: "referenceNumber",
header: t("userRecord.Letter Number"),
cell: ({ row }) => (
<div className="font-medium">{row?.original?.letterNumber}</div>
),
},
{
accessorKey: "subject",
header: t("userRecord.Subject"),
cell: ({ row }) => {
const subject = row.original.content[0]?.subject || "-";
return <div className="text-sm text-gray-700">{subject}</div>;
},
},
{
accessorKey: "Receiver",
header: t("userRecord.ReceivingUnit"),
cell: ({ row }) => {
const receivingUnit =
localizedName(row.original.receivingUnits?.[0]?.unit?.name) || "-";
return <div className="text-sm text-gray-700">{receivingUnit}</div>;
},
},
{
accessorKey: "status",
header: t("userRecord.Status"),
cell: ({ row }) => {
const status = row?.original?.statusKey;
return renderStatus(status, status);
},
},
{
accessorKey: "Date",
header: t("userRecord.Date Received"),
cell: ({ row }) => {
const lang = i18n.language;
const dispatchedDate = row.original.createdAt;
const amharicDate = row.original.amharicCreatedAt;
if (lang.startsWith("am")) {
return amharicDate ?? "-";
} else {
const date = new Date(dispatchedDate).toLocaleDateString(
i18n.language,
{
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
}
);
return date ?? "-";
}
},
},
{
id: "actions",
header: t("userIncoming.Actions"),
cell: ({ row }) => {
const record = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewRecord(record.id)}>
<Eye className="h-4 w-4 mr-2" />
{t("userRecord.View")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
};

View File

@@ -0,0 +1,601 @@
import { useEffect, useState } from "react";
import Cookies from "js-cookie";
import { useForm, type Resolver } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@/shared/common/ui/form";
import { ReusableFileUploader } from "@/shared/common/ui/fileUploader/reusableFileUploader";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
createLetterSchema,
createComplaintLetterSchema,
defaultCreateLetterValues,
defaultCreateComplaintLetterValues,
CreateLetterFormValues,
} from "./formSchema";
import { useExternalPortal } from "@/external-portal/hooks/useCreateExternalLetterRecord";
import { useNavigate } from "react-router-dom";
import { recordFileService } from "@/record-management/services/api/recordFileService";
import { RadioGroup, RadioGroupItem } from "@/shared/common/ui/radio-group";
import { Label } from "@/shared/common/ui/label";
import { motion } from "framer-motion";
import { useUser } from "@/shared/context/UserContext";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { Textarea } from "@/shared/common/ui/textarea";
import { ComplaintVerifiedInfoPanel } from "@/complaints/components/ComplaintVerifiedInfoPanel";
import {
clearComplaintVerification,
getComplaintVerification,
} from "@/complaints/utils/complaintVerificationStorage";
import { COMPLAINT_RECORDS_PATH } from "@/complaints/utils/complaintRoutes";
import {
unitConfigurationService,
type CanReceiveComplaintResponse,
} from "@/shared/services/unitConfigurationService";
import {
getExternalPortalReceivingUnitName,
resolveExternalPortalReceivingUnitIds,
} from "@/external-portal/config/receivingOrganization";
import {
PortalFieldLabel,
PortalFormHeader,
PortalFormSection,
PortalReadOnlyField,
} from "../shared/PortalFormPrimitives";
import { ArrowLeft, Building2, Send } from "lucide-react";
interface AddIncomingRecordPortalFormProps {
onSuccess?: () => void;
onCancel?: () => void;
}
const SubmitExternalRecordForm = ({
onSuccess,
onCancel,
}: AddIncomingRecordPortalFormProps) => {
const { t, i18n } = useTranslation();
const complaintSession = getComplaintVerification();
const isComplaintMode = complaintSession !== null;
const hasAuthToken = Boolean(Cookies.get("auth-token"));
const [canReceiveComplaintConfig, setCanReceiveComplaintConfig] =
useState<CanReceiveComplaintResponse | null>(null);
const [isLoadingCanReceiveComplaintConfig, setIsLoadingCanReceiveComplaintConfig] =
useState(false);
type FormValues = CreateLetterFormValues & {
description?: string;
recipient?: string;
};
const form = useForm<FormValues>({
resolver: zodResolver(
(isComplaintMode
? createComplaintLetterSchema
: createLetterSchema) as typeof createLetterSchema,
) as Resolver<FormValues>,
defaultValues: (isComplaintMode
? defaultCreateComplaintLetterValues
: defaultCreateLetterValues) as FormValues,
mode: "onChange",
});
const [showRegistrationAlert, setShowRegistrationAlert] = useState(false);
const userDetails = useUser();
const { handleError } = useErrorHandler(t);
const hasCompletedRegistration = userDetails?.hasFinishedRegistration;
const { createExternalPortalLetter, isSending } = useExternalPortal();
const [files, setFiles] = useState<File[]>([]);
const [mainFileIndex, setMainFileIndex] = useState<number | null>(null);
const navigate = useNavigate();
const handleCancel = onCancel ?? (() => navigate(COMPLAINT_RECORDS_PATH));
const complaintReceivingUnitId = resolveExternalPortalReceivingUnitIds()[0];
const receivingOrganizationName = getExternalPortalReceivingUnitName(
i18n.language,
);
useEffect(() => {
if (!hasCompletedRegistration && !isComplaintMode) {
setShowRegistrationAlert(true);
} else {
setShowRegistrationAlert(false);
}
}, [hasCompletedRegistration, isComplaintMode]);
// Complaint receiving unit is the configured target unit (not the submitter's unit).
useEffect(() => {
if (!isComplaintMode) return;
if (!hasAuthToken) return;
if (!complaintReceivingUnitId) return;
let isCancelled = false;
const load = async () => {
try {
setIsLoadingCanReceiveComplaintConfig(true);
const res = await unitConfigurationService.getCanReceiveComplaint(
complaintReceivingUnitId,
);
if (isCancelled) return;
setCanReceiveComplaintConfig(res.data);
} catch (err) {
if (isCancelled) return;
setCanReceiveComplaintConfig(null);
toast.error(
t(
"complaint.receiveCheckFailed",
"Could not verify complaint receiving for this unit. Please try again.",
),
);
} finally {
if (!isCancelled) {
setIsLoadingCanReceiveComplaintConfig(false);
}
}
};
load();
return () => {
isCancelled = true;
};
}, [complaintReceivingUnitId, hasAuthToken, isComplaintMode, t]);
useEffect(() => {
if (!isComplaintMode) return;
if (!hasAuthToken) return;
if (!canReceiveComplaintConfig) return;
if (canReceiveComplaintConfig.canReceiveComplaint === false) {
toast.error(
t(
"complaint.receiveDisabled",
"Complaint receiving is disabled for this unit.",
),
);
navigate(COMPLAINT_RECORDS_PATH);
}
}, [
canReceiveComplaintConfig,
hasAuthToken,
isComplaintMode,
navigate,
t,
]);
useEffect(() => {
if (files.length === 1) {
setMainFileIndex(0);
}
}, [files]);
const buildComplaintSubject = (
subject: string,
recipient?: string,
description?: string,
) => {
let composed = subject.trim();
if (recipient?.trim()) {
composed = `[To: ${recipient.trim()}] ${composed}`;
}
if (description?.trim()) {
composed = `${composed}\n\n${description.trim()}`;
}
return composed;
};
const isAllowedMainFile = (file: File) => {
if (!isComplaintMode) {
return file.type === "application/pdf";
}
return (
file.type === "application/pdf" ||
file.type === "image/jpeg" ||
file.type === "image/png"
);
};
const onSubmit = async (values: FormValues) => {
if (files.length === 0) {
toast.error(t("userRecord.Please select file"));
return;
}
if (mainFileIndex === null) {
toast.error(t("Please select the main file for the record"));
return;
}
const mainFile = files[mainFileIndex];
const fileInfo = {
fileName: mainFile.name,
contentType: mainFile.type,
size: mainFile.size,
originalname: mainFile.name,
};
const unitIds = isComplaintMode
? [complaintReceivingUnitId]
: resolveExternalPortalReceivingUnitIds();
if (isComplaintMode) {
if (isLoadingCanReceiveComplaintConfig) {
toast.error(
t(
"complaint.receiveCheckInProgress",
"Still verifying complaint receiving for this unit. Please wait.",
),
);
return;
}
if (!canReceiveComplaintConfig) {
toast.error(
t(
"complaint.receiveCheckFailed",
"Could not verify complaint receiving for this unit. Please try again.",
),
);
return;
}
if (canReceiveComplaintConfig.canReceiveComplaint === false) {
toast.error(
t(
"complaint.receiveDisabled",
"Complaint receiving is disabled for this unit.",
),
);
return;
}
}
if (!complaintReceivingUnitId && isComplaintMode) {
toast.error(
t(
"complaint.receiveCheckFailed",
"Could not verify complaint receiving for this unit. Please try again.",
),
);
return;
}
if (!isAllowedMainFile(mainFile)) {
toast.error(
isComplaintMode
? t("complaint.fayda.uploadHint")
: t("userRecord.The main file must be a PDF document"),
);
return;
}
const letterNumber =
values.letterNumber?.trim() ||
(isComplaintMode ? `CMP-${Date.now()}` : "");
if (!letterNumber) {
toast.error(t("userRecord.Letter Number"));
return;
}
const subject = isComplaintMode
? buildComplaintSubject(
values.subject,
values.recipient,
values.description,
)
: values.subject;
try {
const recordResult = await createExternalPortalLetter(
{
...values,
subject,
fileInfo,
unitIds,
letterNumber,
},
mainFile,
);
const contentId = recordResult.contentId;
if (!contentId) {
throw new Error("Missing contentId from created record");
}
form.reset();
setFiles([]);
setMainFileIndex(null);
const attachments = files.filter((_, idx) => idx !== mainFileIndex);
if (attachments.length > 0) {
try {
await Promise.all(
attachments.map(async (attachment) => {
const uploadMetaResult = await recordFileService.uploadMeta(
attachment,
attachment.name,
contentId,
);
await recordFileService.uploadFile(
attachment,
uploadMetaResult.presigned,
);
await recordFileService.updateStatus(
uploadMetaResult.id,
contentId,
true,
);
}),
);
} catch (error: unknown) {
handleError(error);
}
}
toast.success(
isComplaintMode
? t("complaint.success")
: t("externalPortal.letterSentSuccess"),
);
if (isComplaintMode) {
clearComplaintVerification();
}
onSuccess?.();
navigate(COMPLAINT_RECORDS_PATH);
} catch (error) {
await handleError(error);
}
};
const handleGoToDocuments = () => {
navigate("/external-portal/upload-documents");
setShowRegistrationAlert(false);
};
return (
<>
{showRegistrationAlert && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div className="flex items-center justify-between max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-yellow-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
You haven't completed the verification process. Please upload
required documents to continue.
</p>
</div>
</div>
<div className="ml-4 flex-shrink-0">
<button
onClick={handleGoToDocuments}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500">
Upload Documents
</button>
</div>
</div>
</motion.div>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
aria-labelledby="form-title">
{isComplaintMode ? (
<button
type="button"
onClick={() => navigate(COMPLAINT_RECORDS_PATH)}
className="inline-flex items-center gap-2 text-sm font-medium text-slate-600 transition-colors hover:text-primary"
>
<ArrowLeft className="h-4 w-4" />
{t("complaint.fayda.backToMyRecords")}
</button>
) : null}
<PortalFormHeader
title={
isComplaintMode
? t("complaint.formTitle")
: t("userIncoming.uploadIncomingRecord")
}
subtitle={
isComplaintMode ? t("complaint.fayda.formSubtitle") : undefined
}
/>
{complaintSession ? (
<ComplaintVerifiedInfoPanel session={complaintSession} />
) : null}
<PortalFormSection className="w-full min-w-0 max-w-full space-y-5 overflow-visible">
{!isComplaintMode ? (
<PortalReadOnlyField
label={t("userIncoming.Receiving Organization")}
value={receivingOrganizationName}
icon={Building2}
/>
) : null}
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel asChild>
<PortalFieldLabel required>
{t("userIncoming.Subject")}
</PortalFieldLabel>
</FormLabel>
<FormControl>
<Input
placeholder={t("userIncoming.Subject")}
className="h-11 rounded-lg border-slate-200 focus-visible:ring-primary/20"
{...field}
disabled={isSending}
aria-required="true"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isComplaintMode ? (
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel asChild>
<PortalFieldLabel required>
{t("complaint.fayda.description")}
</PortalFieldLabel>
</FormLabel>
<FormControl>
<Textarea
placeholder={t(
"complaint.fayda.descriptionPlaceholder",
)}
className="min-h-[160px] resize-none rounded-lg border-slate-200 focus-visible:ring-primary/20"
{...field}
disabled={isSending}
aria-required="true"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : null}
{!isComplaintMode ? (
<FormField
control={form.control}
name="letterNumber"
render={({ field }) => (
<FormItem>
<FormLabel asChild>
<PortalFieldLabel required>
{t("userRecord.Letter Number")}
</PortalFieldLabel>
</FormLabel>
<FormControl>
<Input
placeholder={t("userRecord.Letter Number")}
className="h-11 rounded-lg border-slate-200 focus-visible:ring-primary/20"
{...field}
disabled={isSending}
aria-required="true"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : null}
<div>
<PortalFieldLabel required>
{isComplaintMode
? t("complaint.fayda.attachment")
: t("userIncoming.Attachments")}
</PortalFieldLabel>
<div className="mt-2 rounded-xl border-2 border-dashed border-slate-200 bg-slate-50/50 p-2 transition-colors hover:border-primary/40">
<ReusableFileUploader
files={files}
setFiles={setFiles}
isLoading={isSending}
/>
</div>
</div>
{files.length > 0 && (
<div>
<PortalFieldLabel required>
{t("Select the main file for the record")}
</PortalFieldLabel>
<RadioGroup
className="mt-2"
value={mainFileIndex?.toString() || ""}
onValueChange={(value) => setMainFileIndex(Number(value))}
aria-required="true">
{files.map((file, index) => (
<div key={file.name} className="flex items-center space-x-2">
<RadioGroupItem
value={index.toString()}
id={`main-file-${index}`}
/>
<Label htmlFor={`main-file-${index}`}>{file.name}</Label>
</div>
))}
</RadioGroup>
</div>
)}
</PortalFormSection>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-slate-500">{t("common.requiredFields")}</p>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSending}
className="h-11 min-w-[8rem] rounded-lg">
{t("common.Cancel")}
</Button>
<Button
type="submit"
disabled={
!form.formState.isValid ||
isSending ||
files.length === 0 ||
(isComplaintMode &&
(isLoadingCanReceiveComplaintConfig ||
canReceiveComplaintConfig?.canReceiveComplaint !== true))
}
className="h-11 min-w-[10rem] rounded-lg bg-primary hover:bg-primary/90">
{isSending ? (
t("userIncoming.Processing...")
) : (
<span className="inline-flex items-center gap-2">
{isComplaintMode ? t("complaint.submit") : t("common.Submit")}
{isComplaintMode ? <Send className="h-4 w-4" /> : null}
</span>
)}
</Button>
</div>
</div>
</form>
</Form>
</>
);
};
export default SubmitExternalRecordForm;

View File

@@ -0,0 +1,104 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/shared/common/ui/fileUploader/utils";
export function PortalFormPage({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("min-h-screen bg-slate-50 py-8 md:py-10", className)}>
<div className="mx-auto w-full max-w-3xl px-4 md:px-6">{children}</div>
</div>
);
}
export function PortalFormHeader({
title,
subtitle,
}: {
title: string;
subtitle?: string;
}) {
return (
<div className="mb-8 text-center">
<h1
id="form-title"
className="text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">
{title}
</h1>
{subtitle ? (
<p className="mt-2 text-sm text-slate-600 md:text-base">{subtitle}</p>
) : null}
</div>
);
}
export function PortalFormSection({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<section
className={cn(
"rounded-xl border border-slate-200 bg-white p-5 shadow-sm md:p-6",
className,
)}>
{children}
</section>
);
}
export function PortalFieldLabel({
children,
required,
}: {
children: ReactNode;
required?: boolean;
}) {
return (
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
{children}
{required ? <span className="ml-0.5 text-red-600">*</span> : null}
</span>
);
}
export function PortalReadOnlyField({
label,
value,
icon: Icon,
mono = false,
}: {
label: string;
value?: string;
icon?: LucideIcon;
mono?: boolean;
}) {
if (!value) return null;
return (
<div className="space-y-1.5">
<PortalFieldLabel>{label}</PortalFieldLabel>
<div className="relative">
{Icon ? (
<Icon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
) : null}
<div
className={cn(
"rounded-lg border border-slate-200 bg-white py-2.5 text-sm text-slate-800",
Icon ? "pl-10 pr-3" : "px-3",
mono && "font-mono",
)}>
{value}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Lock, User, Mail, Phone, Globe, Building, Text } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { userSchema, UserFormValues } from "./outgoing/formSchema";
import { useRegisterExternalPortalUser } from "../hooks/useRegisterExternalPortalUser";
import { useTenantConfig } from "@/layout/components/TenantConfig";
const Signup = () => {
const navigate = useNavigate();
const { config: tenantConfig } = useTenantConfig();
const { registerExternalPortalUser, isRegistering } =
useRegisterExternalPortalUser();
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
defaultValues: {
email: "",
username: "",
phoneNumber: "",
userType: "external_organization",
name: {
am: "",
en: "",
},
},
mode: "onChange",
});
const onSubmit = async (values: UserFormValues) => {
try {
await registerExternalPortalUser(values);
const params = new URLSearchParams();
if (values.email) params.set("email", values.email.trim());
if (values.phoneNumber) {
const normalizedPhone = values.phoneNumber.trim().replace(/^0/, "+251");
params.set("phone", normalizedPhone);
}
navigate(`/external-portal/verify-otp?${params.toString()}`);
} catch (error) {
console.error("Signup error:", error);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center p-4">
<div className="w-full max-w-6xl bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col md:flex-row h-full max-h-[800px]">
{/* Left Panel */}
<div className="md:w-1/2 w-full p-8 md:p-12 flex flex-col">
<div className="flex items-center mb-8">
<img
src={tenantConfig.logo || "/assets/smart-office-logo.svg"}
alt={tenantConfig.appName}
className="h-10 object-contain"
/>
<span className="ml-2 text-xl font-semibold text-gray-800">
{tenantConfig.appName}
</span>
</div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Create Organization Account
</h1>
<p className="text-gray-500">
Streamline your workflow with our paperless solution
</p>
</div>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 flex-1 flex flex-col"
>
<div className="space-y-4">
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
type="email"
placeholder="Organization Email"
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
{...form.register("email")}
/>
</div>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
type="text"
placeholder="Organization Username"
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
{...form.register("username")}
/>
</div>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
type="tel"
placeholder="Organization Phone Number"
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
{...form.register("phoneNumber")}
/>
</div>
<div className="relative">
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
type="text"
placeholder="Organization Name (English)"
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
{...form.register("name.en")}
/>
</div>
<div className="relative">
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
type="text"
placeholder="Organization Name (Amharic)"
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
{...form.register("name.am")}
/>
</div>
</div>
<div className="mt-auto pt-4">
<Button
type="submit"
className="w-full h-12 bg-primary hover:bg-primary-700 text-white text-base font-medium rounded-lg transition-all duration-300 shadow-md hover:shadow-lg"
disabled={isRegistering}
>
{isRegistering ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Creating Account...
</span>
) : (
"Sign Up"
)}
</Button>
<p className="text-center text-sm text-gray-500 mt-4">
Already have an account?{" "}
<a
href="/login"
className="text-primary hover:underline font-medium"
>
Sign in
</a>
</p>
</div>
</form>
</div>
{/* Right Panel */}
<div className="md:w-1/2 bg-gradient-to-br from-primary to-primary-300 text-white p-10 hidden md:flex flex-col">
<div className="mb-8">
<h2 className="text-3xl font-bold mb-4">
Transform Your Office Experience
</h2>
<p className="text-lg opacity-90">
Join thousands of organizations that have gone paperless with our
smart solutions.
</p>
</div>
<div className="relative flex-1 flex items-center justify-center">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-64 h-64 rounded-full bg-white/10 blur-xl"></div>
</div>
<div className="relative z-10 w-full max-w-md">
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20 shadow-xl">
<div className="flex items-center mb-4">
<Building className="w-8 h-8 mr-3" />
<h3 className="text-xl font-semibold">
Organization Dashboard
</h3>
</div>
<div className="space-y-3">
<div className="flex items-center">
<div className="w-3 h-3 rounded-full bg-primary-400 mr-2"></div>
<span>Real-time document management</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 rounded-full bg-blue-400 mr-2"></div>
<span>Secure cloud storage</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 rounded-full bg-purple-400 mr-2"></div>
<span>Automated workflow</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 rounded-full bg-yellow-400 mr-2"></div>
<span>Multi-language support</span>
</div>
</div>
</div>
<div className="mt-8 relative">
<img
src="/assets/MainDashboard.png"
alt="Dashboard Preview"
className="w-full rounded-xl shadow-2xl border-4 border-white/20"
/>
<div className="absolute -bottom-4 -right-4 bg-white p-2 rounded-lg shadow-lg">
<img
src="/assets/StatsCard.png"
alt="Stats Preview"
className="w-24 h-24 rounded-md"
/>
</div>
</div>
</div>
</div>
<div className="mt-auto pt-6 text-center text-sm opacity-80">
<p>Trusted by 500+ organizations worldwide</p>
</div>
</div>
</div>
</div>
);
};
export default Signup;

View File

@@ -0,0 +1,468 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Link, useSearchParams, useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import {
ShieldCheck,
ArrowLeft,
RefreshCcw,
EyeOff,
Eye,
Lock,
} from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Card, CardContent, CardHeader } from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import { cn } from "@/shared/lib/utils";
import { GenerateVerifcationCodePayload } from "@/shared/services/authService";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useTradeLicenseVerification } from "../hooks/useTradeLicenseVerification";
import { getEtradeLicenseNo } from "../utils/etradeAuthStorage";
interface VerifyOtpProps {
email?: string;
phone?: string;
onComplete?: () => void;
isExternalOrg?: boolean;
userId?: string;
isEtradeVerification?: boolean;
licenseNo?: string;
}
const OTP_LENGTH = 6;
const RESEND_COOLDOWN_SECONDS = 30;
const VerifyOtp: React.FC<VerifyOtpProps> = ({
email,
phone,
onComplete,
isExternalOrg,
userId: propUserId,
isEtradeVerification,
licenseNo,
}: VerifyOtpProps) => {
const [searchParams] = useSearchParams();
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [otpValues, setOtpValues] = useState<string[]>(
Array(OTP_LENGTH).fill("")
);
const userID = propUserId || searchParams.get("userId") || "";
const resolvedLicenseNo =
licenseNo?.trim() ||
searchParams.get("licenseNo")?.trim() ||
getEtradeLicenseNo() ||
"";
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const [error, setError] = useState("");
const [resendLoading, setResendLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [cooldown, setCooldown] = useState(0);
const navigate = useNavigate();
const { t } = useTranslation();
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const [hasNavigated, setHasNavigated] = useState(false);
const requersOtp =
isExternalOrg || searchParams.get("isExternalOrg") === "true";
const {
setPassword,
setFayidaPassword,
resendOtpCode,
setPasswordSuccess,
setPasswordError,
} = useAuthUser();
const { verifyEtradeOtp, isVerifyingOtp } = useTradeLicenseVerification();
useEffect(() => {
inputsRef.current[0]?.focus();
}, []);
useEffect(() => {
if (cooldown <= 0) return;
const t = setInterval(() => setCooldown((s) => (s > 0 ? s - 1 : 0)), 1000);
return () => clearInterval(t);
}, [cooldown]);
useEffect(() => {
if (setPasswordSuccess && !setPasswordError?.message && !hasNavigated) {
setMessage({
type: "success",
text: t("registration.verifyOtp.messages.success"),
});
setHasNavigated(true);
onComplete?.();
navigate("/verification_page");
}
}, [setPasswordSuccess, setPasswordError]);
useEffect(() => {
if (setPasswordError?.message) {
setMessage({
type: "error",
text: setPasswordError.message.includes("OTP")
? setPasswordError.message
: t("registration.verifyOtp.messages.passwordUpdateFailed"),
});
}
}, [setPasswordError]);
const focusInput = (idx: number) => {
inputsRef.current[idx]?.focus();
inputsRef.current[idx]?.select?.();
};
const handleOtpChange = (idx: number, value: string) => {
const char = value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 1);
if (!char) return;
const updated = [...otpValues];
updated[idx] = char;
setOtpValues(updated);
if (idx < OTP_LENGTH - 1) {
setTimeout(() => focusInput(idx + 1), 10); // give DOM time to settle
}
};
const handleOtpKeyDown = (
idx: number,
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "Backspace") {
if (otpValues[idx]) {
setOtpValues((prev) => {
const next = [...prev];
next[idx] = "";
return next;
});
} else if (idx > 0) {
focusInput(idx - 1);
}
} else if (e.key === "ArrowLeft" && idx > 0) {
focusInput(idx - 1);
} else if (e.key === "ArrowRight" && idx < OTP_LENGTH - 1) {
focusInput(idx + 1);
}
};
const handleOtpPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const pasted = e.clipboardData.getData("text").replace(/\D/g, "");
if (!pasted) return;
e.preventDefault();
setOtpValues(
Array(OTP_LENGTH)
.fill("")
.map((_, i) => pasted[i] ?? "")
);
const lastIndex = Math.min(pasted.length, OTP_LENGTH) - 1;
focusInput(lastIndex >= 0 ? lastIndex : 0);
};
const handleResend = async () => {
if (!email && !phone) {
setMessage({
type: "error",
text: t("registration.verifyOtp.messages.missingEmailPhone"),
});
toast.error(t("registration.verifyOtp.errors.missingUserId"));
return;
}
setResendLoading(true);
try {
const payload = {
email: email || "",
phoneNumber: phone || "",
} satisfies GenerateVerifcationCodePayload;
await resendOtpCode(payload);
setMessage({
type: "success",
text: t("registration.verifyOtp.messages.verificationCodeResent"),
});
setCooldown(RESEND_COOLDOWN_SECONDS);
} catch (err) {
setMessage({
type: "error",
text: t("registration.verifyOtp.messages.resendFailed"),
});
toast.error(t("registration.verifyOtp.errors.resendFailed"));
} finally {
setResendLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError("");
setMessage(null);
if (!isEtradeVerification) {
if (!newPassword || !confirmPassword) {
setError(t("registration.verifyOtp.messages.fillPasswords"));
setIsSubmitting(false);
return;
}
if (newPassword.length < 8) {
setError(t("registration.verifyOtp.messages.passwordLength"));
setIsSubmitting(false);
return;
}
if (newPassword !== confirmPassword) {
setError(t("registration.verifyOtp.messages.passwordMismatch"));
setIsSubmitting(false);
return;
}
}
if (requersOtp) {
const code = otpValues.join("");
if (isEtradeVerification) {
if (!resolvedLicenseNo) {
setError(t("registration.etrade.licenseNoRequired"));
setIsSubmitting(false);
return;
}
if (code.length !== OTP_LENGTH || !/^[a-zA-Z0-9]{6}$/.test(code)) {
setError(t("registration.verifyOtp.messages.invalidOtp"));
setIsSubmitting(false);
return;
}
try {
await verifyEtradeOtp({
licenseNo: resolvedLicenseNo,
otp: code,
});
onComplete?.();
navigate("/login");
} catch (err) {
setError(t("registration.verifyOtp.messages.invalidOtp"));
}
setIsSubmitting(false);
return;
}
if (code.length !== OTP_LENGTH || !/^[a-zA-Z0-9]{6}$/.test(code)) {
setError(t("registration.verifyOtp.messages.invalidOtp"));
setIsSubmitting(false);
return;
}
if (!email && !phone) {
setError(t("registration.verifyOtp.messages.missingEmailPhone"));
setIsSubmitting(false);
return;
}
const passwordPayload: any = {
email: email || "",
verificationCode: code,
newPassword,
confirmPassword,
};
if (userID) {
passwordPayload.userId = userID;
}
await setPassword(passwordPayload);
} else {
await setFayidaPassword({
userId: userID,
newPassword,
confirmPassword,
});
navigate("/login");
}
setIsSubmitting(false);
};
const pageFade = { initial: { opacity: 0 }, animate: { opacity: 1 } };
return (
<motion.div
{...pageFade}
className="min-h-screen w-full bg-gradient-to-b from-background to-muted/30 flex items-center justify-center p-4">
<Card className="w-full max-w-md shadow-xl border-0">
<CardHeader className="text-center">
<img
src="/assets/smart-office-logo.svg"
alt="Smart Office Logo"
className="h-10 mx-auto mb-2"
/>
<h1 className="text-2xl font-bold">
{t("registration.verifyOtp.title")}
</h1>
<p className="text-sm text-muted-foreground">
{isEtradeVerification
? t("registration.etrade.otpVerificationDescription")
: t("registration.verifyOtp.description", {
channel: email
? t("registration.verifyOtp.channel.email")
: t("registration.verifyOtp.channel.phone"),
})}
</p>
</CardHeader>
<CardContent className="space-y-6">
{requersOtp && (
<div className="flex justify-center gap-2">
{Array.from({ length: OTP_LENGTH }).map((_, idx) => (
<Input
key={idx}
ref={(el) => {
inputsRef.current[idx] = el;
}}
inputMode="text"
pattern="[a-zA-Z0-9]{1}"
maxLength={1}
value={otpValues[idx]}
onChange={(e) => handleOtpChange(idx, e.target.value)}
onKeyDown={(e) => handleOtpKeyDown(idx, e)}
onPaste={handleOtpPaste}
className="w-10 h-12 text-center text-xl tracking-widest"
aria-label={t("registration.verifyOtp.otpInput.ariaLabel", {
index: idx + 1,
})}
/>
))}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{!isEtradeVerification && (
<>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<Input
type={showPassword ? "text" : "password"}
placeholder={t("registration.verifyOtp.password.newPassword")}
className="h-10 rounded-md border px-4 text-sm ps-10"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
<div
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
onClick={() => setShowPassword(!showPassword)}>
{showPassword ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</div>
</div>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<Input
type={showConfirmPassword ? "text" : "password"}
placeholder={t(
"registration.verifyOtp.password.confirmPassword"
)}
className="h-10 rounded-md border px-4 text-sm ps-10"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
<div
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</div>
</div>
</>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex flex-col gap-2">
<Button
type="submit"
className="w-full"
disabled={isSubmitting || isVerifyingOtp}>
{isSubmitting || isVerifyingOtp
? t("registration.verifyOtp.buttons.verifying")
: isEtradeVerification
? t("registration.verifyOtp.buttons.verifyOtp")
: t("registration.verifyOtp.buttons.completeRegistration")}
</Button>
</div>
</form>
{!isEtradeVerification && (
<Button
variant="outline"
className="w-full"
onClick={() => void handleResend()}
disabled={resendLoading || cooldown > 0}
aria-label={
resendLoading
? t("registration.verifyOtp.buttons.resendLoading")
: cooldown > 0
? t("registration.verifyOtp.buttons.resendCooldown", {
seconds: cooldown,
})
: t("registration.verifyOtp.buttons.resendCode")
}>
{resendLoading ? (
<span className="inline-flex items-center gap-2">
<RefreshCcw className="h-4 w-4 animate-spin" />
{t("registration.verifyOtp.buttons.resending")}
</span>
) : cooldown > 0 ? (
t("registration.verifyOtp.buttons.resendIn", {
seconds: cooldown,
})
) : (
t("registration.verifyOtp.buttons.resendCode")
)}
</Button>
)}
<div className="min-h-12" aria-live="polite" aria-atomic="true">
{message && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
"p-3 rounded-lg flex items-start gap-3",
message.type === "success"
? "bg-primary-50 text-primary-700"
: "bg-red-50 text-red-700"
)}>
<ShieldCheck
className={cn(
"h-5 w-5 flex-shrink-0 mt-0.5",
message.type === "success"
? "text-primary-500"
: "text-red-500"
)}
/>
<p className="text-sm font-medium">{message.text}</p>
</motion.div>
)}
</div>
<div className="text-center text-sm">
<Link
to="/support/resend-sms"
className="text-muted-foreground hover:text-foreground">
{t("registration.verifyOtp.support.needHelp")}
</Link>
</div>
</CardContent>
</Card>
</motion.div>
);
};
export default VerifyOtp;