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,36 @@
import type { ReactNode } from "react";
import Header from "@/layout/components/Header";
import Footer from "@/layout/components/Footer";
interface ComplaintPageLayoutProps {
children: ReactNode;
heroTitle: string;
heroSubtitle?: string;
}
export function ComplaintPageLayout({
children,
heroTitle,
heroSubtitle,
}: ComplaintPageLayoutProps) {
return (
<div>
<Header />
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white flex flex-col pt-16 md:pt-20">
<div className="bg-primary py-12 md:py-16 text-center text-white relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-full bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-10" />
<h1 className="text-3xl md:text-5xl font-bold mb-3 relative z-10">
{heroTitle}
</h1>
{heroSubtitle && (
<p className="max-w-2xl mx-auto text-base md:text-lg opacity-90 relative z-10 px-4">
{heroSubtitle}
</p>
)}
</div>
<div className="flex-1 flex justify-center p-4 md:p-6">{children}</div>
</div>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { Navigate, Outlet } from "react-router-dom";
import { getComplaintVerification } from "../utils/complaintVerificationStorage";
export function ComplaintVerificationGuard() {
const session = getComplaintVerification();
if (!session) {
return <Navigate to="/complaints" replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,69 @@
import { useTranslation } from "react-i18next";
import { BadgeCheck, Building2, IdCard, User } from "lucide-react";
import {
PortalFieldLabel,
PortalReadOnlyField,
} from "@/external-portal/components/shared/PortalFormPrimitives";
import type { ComplaintVerificationSession } from "../types/complaint.types";
interface ComplaintVerifiedInfoPanelProps {
session: ComplaintVerificationSession;
}
export function ComplaintVerifiedInfoPanel({
session,
}: ComplaintVerifiedInfoPanelProps) {
const { t } = useTranslation();
const isTinComplaint = session.method === "tin";
return (
<section className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50/80 p-5 md:p-6">
<h3 className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-emerald-800">
<BadgeCheck className="h-4 w-4" />
{isTinComplaint
? t("complaint.tin.verifiedInfo")
: t("complaint.fayda.verifiedInfo")}
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{isTinComplaint ? (
<>
<PortalReadOnlyField
label={t("complaint.tin.organizationName")}
value={session.organization?.organizationName}
icon={Building2}
/>
<PortalReadOnlyField
label={t("complaint.tin.tinLabel")}
value={session.organization?.tin}
icon={IdCard}
mono
/>
{session.organization?.tradeName ? (
<PortalReadOnlyField
label={t("complaint.tin.tradeName")}
value={session.organization.tradeName}
icon={Building2}
/>
) : null}
{session.organization?.licenseNumber ? (
<PortalReadOnlyField
label={t("complaint.tin.licenseNumber")}
value={session.organization.licenseNumber}
icon={IdCard}
mono
/>
) : null}
</>
) : (
<>
<PortalReadOnlyField
label={t("complaint.fullName")}
value={session.citizen?.fullName}
icon={User}
/>
</>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { useAuth } from "@/shared/context/AuthContext";
import {
isComplaintFaydaState,
} from "@/shared/utils/faydaOidc";
import {
mapRegisterWithFaydaCitizen,
persistFaydaRegistrationAuth,
} from "@/shared/utils/faydaAuthSession";
import {
FaydaOidcError,
verifyComplaintUser,
} from "../services/complaintVerificationService";
import {
clearComplaintVerification,
storeComplaintVerification,
} from "../utils/complaintVerificationStorage";
import { COMPLAINT_RECORDS_PATH } from "../utils/complaintRoutes";
export default function ComplaintCallbackPage() {
const { t, i18n } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { setUser, setSelectedPositionId } = useAuth();
const [error, setError] = useState<string | null>(null);
const hasRun = useRef(false);
useEffect(() => {
if (hasRun.current) return;
hasRun.current = true;
const code = searchParams.get("code");
const state = searchParams.get("state");
const oidcError = searchParams.get("error");
const oidcErrorDescription = searchParams.get("error_description");
if (oidcError) {
clearComplaintVerification();
const message =
oidcErrorDescription ||
t("complaint.fayda.verificationFailed");
setError(message);
return;
}
if (!code) {
clearComplaintVerification();
setError(t("complaint.fayda.missingCode"));
return;
}
if (state && !isComplaintFaydaState(state)) {
clearComplaintVerification();
setError(t("complaint.fayda.verificationFailed"));
return;
}
const processCallback = async () => {
try {
const result = await verifyComplaintUser(
{ code, state },
i18n.language,
);
if (!result.verified || !result.registration) {
throw new Error(t("complaint.fayda.verificationFailed"));
}
const profile = await persistFaydaRegistrationAuth(result.registration);
if (profile) {
setUser(profile);
const firstPositionId =
profile.employee?.[0]?.positions?.[0]?.employeePositionId;
if (firstPositionId) {
setSelectedPositionId(firstPositionId);
}
}
const citizen = mapRegisterWithFaydaCitizen(
result.registration,
i18n.language,
profile,
);
if (!citizen.fullName && !profile) {
throw new FaydaOidcError("FAYDA_PROFILE_INCOMPLETE");
}
storeComplaintVerification({
verified: true,
method: "fayda",
citizen,
verifiedAt: result.verifiedAt,
});
navigate(COMPLAINT_RECORDS_PATH, {
replace: true,
state: { fromComplaintVerification: true },
});
} catch (err: unknown) {
clearComplaintVerification();
if (err instanceof FaydaOidcError) {
const translationKey = `complaint.fayda.errors.${err.code}`;
const translated = t(translationKey);
setError(
translated !== translationKey
? translated
: t("complaint.fayda.verificationFailed"),
);
return;
}
const message =
err instanceof Error
? err.message
: t("complaint.fayda.verificationFailed");
setError(message);
}
};
processCallback();
}, [
searchParams,
navigate,
t,
i18n.language,
setUser,
setSelectedPositionId,
]);
if (error) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-red-50 px-4">
<p className="text-red-600 mb-4 text-center max-w-md">{error}</p>
<Button
onClick={() =>
navigate(
`/complaints?error=${encodeURIComponent(error)}`,
{ replace: true },
)
}>
{t("complaint.fayda.backToComplaints")}
</Button>
</div>
);
}
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-white">
<Loader2 className="h-10 w-10 text-primary animate-spin mb-4" />
<p className="text-primary text-lg font-medium text-center px-4">
{t("complaint.fayda.verifying")}
</p>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Navigate } from "react-router-dom";
import { getComplaintVerification } from "../utils/complaintVerificationStorage";
import { COMPLAINT_RECORDS_PATH } from "../utils/complaintRoutes";
export default function ComplaintFormPage() {
const session = getComplaintVerification();
if (!session) {
return <Navigate to="/complaints" replace />;
}
return <Navigate to={COMPLAINT_RECORDS_PATH} replace />;
}

View File

@@ -0,0 +1,94 @@
import { useEffect } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AlertCircle, Building2, Search, UserRound } from "lucide-react";
import { toast } from "sonner";
import { startComplaintFaydaAuth } from "@/shared/utils/faydaOidc";
import { ComplaintPageLayout } from "../components/ComplaintPageLayout";
import { clearComplaintVerification } from "../utils/complaintVerificationStorage";
export default function ComplaintMethodChoicePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const error = searchParams.get("error");
useEffect(() => {
clearComplaintVerification();
}, []);
useEffect(() => {
if (error) {
toast.error(error);
setSearchParams({}, { replace: true });
}
}, [error, setSearchParams]);
const authOptions = [
{
provider: "fayda" as const,
title: t("registration.auth.continueWithFayda"),
description: t("registration.auth.faydaDescription"),
icon: UserRound,
onClick: () => startComplaintFaydaAuth(),
},
{
provider: "etrade" as const,
title: t("registration.auth.continueWithEtrade"),
description: t("registration.auth.etradeDescription"),
icon: Building2,
onClick: () => navigate("/complaints/tin"),
},
];
return (
<ComplaintPageLayout
heroTitle={t("complaint.choice.title")}
heroSubtitle={t("complaint.choice.subtitle")}>
<div className="w-full max-w-3xl space-y-8">
{error && (
<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">{error}</p>
</div>
)}
<p className="text-center text-sm text-gray-600">
{t("complaint.choice.prompt")}
</p>
<div className="grid gap-6 md:grid-cols-2">
{authOptions.map((option) => {
const Icon = option.icon;
return (
<button
key={option.provider}
type="button"
onClick={option.onClick}
className="group rounded-2xl border border-gray-200 bg-white p-6 text-left transition hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary transition group-hover:bg-primary group-hover:text-white">
<Icon className="h-6 w-6" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
{option.title}
</h3>
<p className="mt-2 text-sm leading-6 text-gray-600">
{option.description}
</p>
</button>
);
})}
</div>
<div className="border-t border-gray-100 pt-6 text-center">
<Link
to="/follow-complaint"
className="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline">
<Search className="h-4 w-4" />
{t("complaint.followTitle")}
</Link>
</div>
</div>
</ComplaintPageLayout>
);
}

View File

@@ -0,0 +1,44 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { CheckCircle, Search } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { ComplaintPageLayout } from "../components/ComplaintPageLayout";
import { clearComplaintVerification } from "../utils/complaintVerificationStorage";
export default function ComplaintSuccessPage() {
const { t } = useTranslation();
const handleDone = () => {
clearComplaintVerification();
};
return (
<ComplaintPageLayout
heroTitle={t("complaint.success")}
heroSubtitle={t("complaint.fayda.successSubtitle")}>
<div className="w-full max-w-lg bg-white shadow-2xl rounded-2xl p-8 border border-gray-100 text-center">
<CheckCircle className="mx-auto mb-4 h-16 w-16 text-primary" />
<h2 className="mb-2 text-xl font-semibold text-gray-900">
{t("complaint.fayda.successMessage")}
</h2>
<p className="mb-6 text-gray-600">
{t("complaint.fayda.successNote")}
</p>
<div className="flex flex-col gap-3">
<Button asChild onClick={handleDone}>
<Link to="/">{t("complaint.fayda.backToHome")}</Link>
</Button>
<Button asChild variant="outline" onClick={handleDone}>
<Link
to="/follow-complaint"
className="inline-flex items-center gap-2">
<Search className="h-4 w-4" />
{t("complaint.followTitle")}
</Link>
</Button>
</div>
</div>
</ComplaintPageLayout>
);
}

View File

@@ -0,0 +1,308 @@
import { FormEvent, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AlertCircle, ArrowLeft, Building2, Loader2 } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useAuth } from "@/shared/context/AuthContext";
import { registerWithEtrade } from "@/shared/services/authService";
import { persistFaydaRegistrationAuth } from "@/shared/utils/faydaAuthSession";
import { ComplaintPageLayout } from "../components/ComplaintPageLayout";
import { TinRegistrationNotFoundError } from "../services/complaintVerificationService";
import { storeComplaintVerification } from "../utils/complaintVerificationStorage";
import { COMPLAINT_RECORDS_PATH } from "../utils/complaintRoutes";
import type { VerifiedOrganization } from "../types/complaint.types";
import {
EtradeBusinessLicenseOption,
fetchRegistrationByTin,
mapEtradeBusinessLicenses,
mapEtradeRegistration,
resolveEtradeLanguage,
resolveEtradePhoneForSignup,
} from "../services/etradeTinService";
import { EtradeLicensePicker } from "@/shared/components/etrade/EtradeLicensePicker";
type VerificationStep = "tin" | "select_license";
export default function ComplaintTinVerificationPage() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { setUser, setSelectedPositionId } = useAuth();
const { handleError, getErrorMessage } = useErrorHandler(t);
const [step, setStep] = useState<VerificationStep>("tin");
const [tin, setTin] = useState("");
const [organization, setOrganization] = useState<VerifiedOrganization | null>(
null,
);
const [organizationName, setOrganizationName] = useState("");
const [licenseOptions, setLicenseOptions] = useState<
EtradeBusinessLicenseOption[]
>([]);
const [selectedLicenseNumber, setSelectedLicenseNumber] = useState("");
const [error, setError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
const [isContinuing, setIsContinuing] = useState(false);
const handleVerify = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
const normalizedTin = tin.trim();
if (!normalizedTin) {
setError(t("complaint.tin.tinRequired"));
return;
}
if (!/^\d{10}$/.test(normalizedTin)) {
setError(t("complaint.tin.tinInvalid"));
return;
}
try {
setIsVerifying(true);
const registration = await fetchRegistrationByTin(
normalizedTin,
resolveEtradeLanguage(i18n.language),
);
const verifiedOrganization = mapEtradeRegistration(
registration,
normalizedTin,
);
if (!verifiedOrganization.organizationName && !verifiedOrganization.tin) {
setError(t("complaint.tin.notFound"));
return;
}
const businesses = mapEtradeBusinessLicenses(
registration,
resolveEtradeLanguage(i18n.language),
);
if (businesses.length === 0) {
setError(t("registration.etrade.noLicensesFound"));
return;
}
setOrganization(verifiedOrganization);
setOrganizationName(verifiedOrganization.organizationName);
setLicenseOptions(businesses);
setSelectedLicenseNumber(businesses[0].licenseNumber);
setStep("select_license");
} catch (verifyError) {
if (verifyError instanceof TinRegistrationNotFoundError) {
setError(t("complaint.tin.notFound"));
return;
}
if (
verifyError instanceof Error &&
verifyError.message === "ETRADE_REFERER_REJECTED"
) {
setError(t("complaint.tin.proxyError"));
return;
}
setError(await getErrorMessage(verifyError));
} finally {
setIsVerifying(false);
}
};
const handleLicenseContinue = async () => {
if (!organization || !selectedLicenseNumber) return;
const selectedLicense = licenseOptions.find(
(option) => option.licenseNumber === selectedLicenseNumber,
);
if (!selectedLicense) return;
try {
setIsContinuing(true);
setError(null);
let phoneNumber: string | undefined;
try {
phoneNumber =
(await resolveEtradePhoneForSignup(
selectedLicense.licenseNumber,
organization.tin,
resolveEtradeLanguage(i18n.language),
)) ?? undefined;
} catch (phoneError) {
console.warn(
"[complaint:eTrade] Failed to resolve phone from GetBusinessByLicenseNo",
phoneError,
);
}
if (!phoneNumber) {
setError(t("registration.etrade.phoneLookupFailed"));
return;
}
const registrationResponse = await registerWithEtrade({
tin: organization.tin,
licenseNumber: selectedLicense.licenseNumber,
phoneNumber,
});
const registration = registrationResponse.data;
if (!registration?.token?.trim()) {
setError(t("complaint.tin.registrationFailed"));
return;
}
const profile = await persistFaydaRegistrationAuth(registration);
if (profile) {
setUser(profile);
const firstPositionId =
profile.employee?.[0]?.positions?.[0]?.employeePositionId;
if (firstPositionId) {
setSelectedPositionId(firstPositionId);
}
}
storeComplaintVerification({
verified: true,
method: "tin",
organization: {
...organization,
licenseNumber: selectedLicense.licenseNumber,
tradeName: selectedLicense.tradeName,
mainGuid: selectedLicense.mainGuid,
},
verifiedAt: new Date().toISOString(),
registration,
});
navigate(COMPLAINT_RECORDS_PATH, {
replace: true,
state: { fromComplaintVerification: true },
});
} catch (continueError) {
handleError(continueError);
} finally {
setIsContinuing(false);
}
};
return (
<ComplaintPageLayout
heroTitle={t("registration.auth.continueWithEtrade")}
heroSubtitle={t("complaint.tin.subtitle")}>
<div className="w-full max-w-xl rounded-2xl border border-gray-200 bg-white p-6 md:p-8">
<Link
to="/complaints"
className="mb-6 inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-primary">
<ArrowLeft className="h-4 w-4" />
{t("complaint.back")}
</Link>
<div className="mb-6 flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-amber-50 text-amber-600">
<Building2 className="h-6 w-6" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">
{step === "tin"
? t("complaint.tin.formTitle")
: t("registration.etrade.selectLicenseTitle")}
</h2>
<p className="mt-1 text-sm text-gray-600">
{step === "tin"
? t("complaint.tin.formDescription")
: t("registration.etrade.selectLicenseDescription")}
</p>
</div>
</div>
{error && (
<div className="mb-6 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">{error}</p>
</div>
)}
{step === "tin" ? (
<form onSubmit={handleVerify} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="tin">{t("complaint.tin.tinLabel")}</Label>
<Input
id="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="font-mono text-lg tracking-wide"
autoComplete="off"
disabled={isVerifying}
required
/>
<p className="text-xs text-gray-500">
{t("complaint.tin.tinHint")}
</p>
</div>
<Button
type="submit"
disabled={isVerifying || tin.trim().length !== 10}
className="w-full py-6 text-base font-semibold">
{isVerifying ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
{t("complaint.tin.verifying")}
</span>
) : (
t("complaint.tin.verify")
)}
</Button>
</form>
) : (
<div className="space-y-6">
<EtradeLicensePicker
organizationName={organizationName}
options={licenseOptions}
selectedLicenseNumber={selectedLicenseNumber}
onSelect={setSelectedLicenseNumber}
/>
<div className="flex justify-center gap-4">
<Button
type="button"
variant="outline"
onClick={() => {
setStep("tin");
setSelectedLicenseNumber("");
setLicenseOptions([]);
setOrganization(null);
setOrganizationName("");
}}
disabled={isContinuing}>
{t("registration.etrade.back")}
</Button>
<Button
type="button"
onClick={handleLicenseContinue}
disabled={!selectedLicenseNumber || isContinuing}
className="min-w-[8rem]">
{isContinuing ? (
<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>
)}
</div>
</ComplaintPageLayout>
);
}

View File

@@ -0,0 +1,72 @@
import type {
ComplaintVerificationResult,
FaydaCallbackData,
} from "../types/complaint.types";
import {
FaydaOidcError,
registerFaydaCitizenFromCode,
} from "./faydaOidcService";
import {
fetchRegistrationByTin,
mapEtradeRegistration,
resolveEtradeLanguage,
TinRegistrationNotFoundError,
} from "./etradeTinService";
/**
* Authenticates or registers a citizen via FAYDA OIDC using a single flow.
*/
export async function verifyComplaintUser(
callbackData: FaydaCallbackData,
language = "en",
): Promise<ComplaintVerificationResult> {
if (!callbackData.code?.trim()) {
throw new Error("Missing authorization code from FAYDA");
}
const { registration } = await registerFaydaCitizenFromCode(
callbackData.code,
language,
);
return {
verified: true,
method: "fayda",
verifiedAt: new Date().toISOString(),
registration,
};
}
/**
* Verifies an organization via the eTrade TIN registration API.
*/
export async function verifyComplaintTin(
tin: string,
language: string,
): Promise<ComplaintVerificationResult> {
const normalizedTin = tin.trim();
if (!/^\d{10}$/.test(normalizedTin)) {
throw new Error("INVALID_TIN");
}
const registration = await fetchRegistrationByTin(
normalizedTin,
resolveEtradeLanguage(language),
);
const organization = mapEtradeRegistration(registration, normalizedTin);
if (!organization.organizationName && !organization.tin) {
throw new TinRegistrationNotFoundError();
}
return {
verified: true,
method: "tin",
organization,
verifiedAt: new Date().toISOString(),
};
}
export { TinRegistrationNotFoundError };
export { FaydaOidcError };

View File

@@ -0,0 +1,365 @@
export type EtradeLanguage = "en" | "am";
export interface EtradeBusinessSubGroup {
Code?: number;
Description?: string;
}
export interface EtradeBusiness {
MainGuid?: string;
OwnerTIN?: string;
DateRegistered?: string;
TradeNameAmh?: string;
TradesName?: string;
LicenceNumber?: string;
LicenseNumber?: string;
RenewalDate?: string;
RenewedFrom?: string;
RenewedTo?: string;
SubGroups?: EtradeBusinessSubGroup[];
}
export interface EtradeBusinessLicenseOption {
mainGuid: string;
licenseNumber: string;
tradeName: string;
activities: string[];
renewedTo?: string;
}
export interface EtradeRegistrationInfo {
Tin?: string;
BusinessName?: string;
BusinessNameAmh?: string;
RegNo?: string;
Businesses?: EtradeBusiness[];
tin?: string;
businessName?: string;
businessNameAmh?: string;
regNo?: string;
businesses?: EtradeBusiness[];
[key: string]: unknown;
}
export class TinRegistrationNotFoundError extends Error {
readonly code = "TIN_NOT_FOUND" as const;
constructor() {
super("TIN_NOT_FOUND");
this.name = "TinRegistrationNotFoundError";
}
}
const ETRADE_API_BASE =
import.meta.env.VITE_ETRADE_API_BASE?.trim() || "/api/etrade";
export function resolveEtradeLanguage(language: string): EtradeLanguage {
return language.toLowerCase().startsWith("am") ? "am" : "en";
}
function hasRegistrationData(data: unknown): data is EtradeRegistrationInfo {
if (data == null) return false;
if (typeof data !== "object") return false;
if (Array.isArray(data)) return data.length > 0;
const record = data as Record<string, unknown>;
const keys = Object.keys(record);
if (keys.length === 0) return false;
const tin = String(record.Tin ?? record.tin ?? "").trim();
const businessName = String(
record.BusinessName ?? record.businessName ?? "",
).trim();
return Boolean(tin || businessName);
}
function normalizeEtradeBusinesses(
data: EtradeRegistrationInfo,
): EtradeBusiness[] {
const businesses = data.Businesses ?? data.businesses;
return Array.isArray(businesses) ? businesses : [];
}
export function mapEtradeBusinessLicenses(
data: EtradeRegistrationInfo,
language: EtradeLanguage,
): EtradeBusinessLicenseOption[] {
const companyName = String(
data.BusinessName ?? data.businessName ?? "",
).trim();
const companyNameAmh = String(
data.BusinessNameAmh ?? data.businessNameAmh ?? "",
).trim();
return normalizeEtradeBusinesses(data).flatMap((business) => {
const licenseNumber = String(
business.LicenceNumber ?? business.LicenseNumber ?? "",
).trim();
if (!licenseNumber) return [];
const tradeName = String(business.TradesName ?? "").trim();
const tradeNameAmh = String(business.TradeNameAmh ?? "").trim();
const activities = (business.SubGroups ?? [])
.map((group) => String(group.Description ?? "").trim())
.filter(Boolean);
const displayTradeName =
language === "am"
? tradeNameAmh || tradeName || companyNameAmh || companyName
: tradeName || tradeNameAmh || companyName || companyNameAmh;
const option: EtradeBusinessLicenseOption = {
mainGuid: String(business.MainGuid ?? licenseNumber).trim(),
licenseNumber,
tradeName: displayTradeName || licenseNumber,
activities,
};
const renewedTo = String(business.RenewedTo ?? "").trim();
if (renewedTo) {
option.renewedTo = renewedTo;
}
return [option];
});
}
export function mapEtradeRegistration(
data: EtradeRegistrationInfo,
fallbackTin: string,
) {
const tin = String(data.Tin ?? data.tin ?? fallbackTin).trim();
const organizationName = String(
data.BusinessName ?? data.businessName ?? "",
).trim();
const regNo = String(data.RegNo ?? data.regNo ?? "").trim();
return {
organizationName: organizationName || tin,
tin,
...(regNo ? { regNo } : {}),
};
}
export interface EtradeBusinessByLicense {
MainGuid?: string;
OwnerTIN?: string;
TradeName?: string;
LicenceNumber?: string;
LicenseNumber?: string;
AssociateShortInfos?: Array<{
Position?: string;
ManagerName?: string;
ManagerNameEng?: string;
MobilePhone?: string;
RegularPhone?: string;
}>;
AddressInfo?: {
Region?: string;
Zone?: string;
Woreda?: string;
Kebele?: string;
HouseNo?: string;
MobilePhone?: string;
RegularPhone?: string;
};
[key: string]: unknown;
}
/**
* Normalize Ethiopian phone numbers for IAM auth.
* Accepts 09/07 mobiles and common 0X landline forms; returns +251...
*/
export function normalizeEtradePhoneNumber(
raw: string | null | undefined,
): string | null {
if (!raw?.trim()) return null;
let digits = raw.trim().replace(/[^\d+]/g, "");
if (digits.startsWith("+")) {
digits = digits.slice(1);
}
// Already international without +
if (digits.startsWith("251") && digits.length >= 12) {
return `+${digits}`;
}
// Local with leading 0 (mobile or landline): 09xxxxxxxx / 07xxxxxxxx / 0Xxxxxxxx
if (digits.startsWith("0") && digits.length >= 9) {
return `+251${digits.slice(1)}`;
}
// Mobile without leading 0: 9xxxxxxxx / 7xxxxxxxx
if (/^[97]\d{8}$/.test(digits)) {
return `+251${digits}`;
}
// Bare landline-ish digits (e.g. 52543000) — prefix country code only if 89 digits
if (/^\d{8,9}$/.test(digits)) {
return `+251${digits}`;
}
return null;
}
function pickRawPhoneFromBusiness(data: EtradeBusinessByLicense): string | null {
const associates = Array.isArray(data.AssociateShortInfos)
? data.AssociateShortInfos
: [];
// Prefer RegularPhone — eTrade MobilePhone is often a landline/invalid value
// (e.g. "52543000", "222400000") while RegularPhone holds the mobile (09...).
for (const associate of associates) {
const regular = String(associate.RegularPhone ?? "").trim();
if (regular) return regular;
}
const addressRegular = String(data.AddressInfo?.RegularPhone ?? "").trim();
if (addressRegular) return addressRegular;
for (const associate of associates) {
const mobile = String(associate.MobilePhone ?? "").trim();
if (mobile) return mobile;
}
const addressMobile = String(data.AddressInfo?.MobilePhone ?? "").trim();
if (addressMobile) return addressMobile;
return null;
}
export function extractEtradePhoneNumber(
data: EtradeBusinessByLicense,
): string | null {
return normalizeEtradePhoneNumber(pickRawPhoneFromBusiness(data));
}
export async function fetchBusinessByLicenseNo(
licenseNo: string,
tin: string,
language: EtradeLanguage,
): Promise<EtradeBusinessByLicense> {
const params = new URLSearchParams({
LicenseNo: licenseNo.trim(),
Tin: tin.trim(),
Lang: language,
});
const url = `${ETRADE_API_BASE}/BusinessMain/GetBusinessByLicenseNo?${params.toString()}`;
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: {
Accept: "application/json",
},
});
} catch (error) {
console.error("[eTrade:License] Network request failed", error);
throw error;
}
const responseText = (await response.text()).trim();
if (response.status === 417) {
console.error(
"[eTrade:License] Referer rejected by eTrade. Ensure /api/etrade proxy is configured.",
responseText,
);
throw new Error("ETRADE_REFERER_REJECTED");
}
if (!response.ok && response.status !== 404) {
throw new Error(`ETRADE_HTTP_${response.status}`);
}
if (!responseText) {
throw new Error("ETRADE_BUSINESS_NOT_FOUND");
}
let data: unknown;
try {
data = JSON.parse(responseText);
} catch (parseError) {
console.error(
"[eTrade:License] Invalid JSON response",
parseError,
responseText,
);
throw new Error("INVALID_ETRADE_RESPONSE");
}
if (data == null || typeof data !== "object") {
throw new Error("ETRADE_BUSINESS_NOT_FOUND");
}
return data as EtradeBusinessByLicense;
}
/**
* Looks up business details by license + TIN and returns a normalized phone
* suitable for IAM signup (`+251...`).
*/
export async function resolveEtradePhoneForSignup(
licenseNo: string,
tin: string,
language: EtradeLanguage = "en",
): Promise<string | null> {
const business = await fetchBusinessByLicenseNo(licenseNo, tin, language);
return extractEtradePhoneNumber(business);
}
export async function fetchRegistrationByTin(
tin: string,
language: EtradeLanguage,
): Promise<EtradeRegistrationInfo> {
const normalizedTin = tin.trim();
const url = `${ETRADE_API_BASE}/Registration/GetRegistrationInfoByTin/${encodeURIComponent(normalizedTin)}/${language}`;
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: {
Accept: "application/json",
},
});
} catch (error) {
console.error("[eTrade:TIN] Network request failed", error);
throw error;
}
const responseText = (await response.text()).trim();
if (response.status === 417) {
console.error(
"[eTrade:TIN] Referer rejected by eTrade. Ensure /api/etrade proxy is configured.",
responseText,
);
throw new Error("ETRADE_REFERER_REJECTED");
}
if (!response.ok && response.status !== 404) {
throw new Error(`ETRADE_HTTP_${response.status}`);
}
if (!responseText) {
throw new TinRegistrationNotFoundError();
}
let data: unknown;
try {
data = JSON.parse(responseText);
} catch (parseError) {
console.error("[eTrade:TIN] Invalid JSON response", parseError, responseText);
throw new Error("INVALID_ETRADE_RESPONSE");
}
if (data == null || !hasRegistrationData(data)) {
throw new TinRegistrationNotFoundError();
}
return data;
}

View File

@@ -0,0 +1,75 @@
import axios from "axios";
import {
registerWithFayda,
type RegisterWithFaydaResponse,
} from "@/shared/services/authService";
import { getComplaintFaydaRedirectUri } from "@/shared/utils/faydaOidc";
import type { VerifiedCitizen } from "../types/complaint.types";
export class FaydaOidcError extends Error {
constructor(
readonly code: string,
message?: string,
) {
super(message ?? code);
this.name = "FaydaOidcError";
}
}
export interface FaydaRegistrationResult {
registration: RegisterWithFaydaResponse;
citizen?: VerifiedCitizen;
}
function mapRegisterWithFaydaError(error: unknown): never {
if (axios.isAxiosError(error)) {
const payload = error.response?.data;
const serverCode =
payload && typeof payload === "object" && "code" in payload
? String((payload as { code?: string }).code ?? "")
: "";
if (serverCode.startsWith("FAYDA_")) {
throw new FaydaOidcError(serverCode);
}
if (!error.response) {
throw new FaydaOidcError("FAYDA_TOKEN_NETWORK_ERROR");
}
const status = error.response.status;
if (status === 400) {
throw new FaydaOidcError("FAYDA_TOKEN_HTTP_400");
}
if (status === 401) {
throw new FaydaOidcError("FAYDA_TOKEN_HTTP_401");
}
if (status >= 500) {
throw new FaydaOidcError("FAYDA_TOKEN_HTTP_500");
}
}
if (error instanceof FaydaOidcError) {
throw error;
}
throw new FaydaOidcError("FAYDA_EXCHANGE_FAILED");
}
export async function registerFaydaCitizenFromCode(
code: string,
_preferredLocale = "en",
): Promise<FaydaRegistrationResult> {
try {
const response = await registerWithFayda({ code });
const registration = response.data;
if (!registration?.token?.trim()) {
throw new FaydaOidcError("FAYDA_EXCHANGE_FAILED");
}
return { registration };
} catch (error) {
mapRegisterWithFaydaError(error);
}
}

View File

@@ -0,0 +1,42 @@
export type ComplaintVerificationMethod = "fayda" | "tin";
export interface VerifiedCitizen {
fullName: string;
faydaId: string;
}
export interface VerifiedOrganization {
organizationName: string;
tin: string;
regNo?: string;
licenseNumber?: string;
tradeName?: string;
mainGuid?: string;
}
import type { RegisterWithFaydaResponse } from "@/shared/services/authService";
export interface ComplaintVerificationResult {
verified: boolean;
method: ComplaintVerificationMethod;
citizen?: VerifiedCitizen;
organization?: VerifiedOrganization;
verifiedAt: string;
registration?: RegisterWithFaydaResponse;
}
export interface ComplaintVerificationSession extends ComplaintVerificationResult {
verified: true;
method: ComplaintVerificationMethod;
}
export interface FaydaCallbackData {
code: string;
state?: string | null;
}
export interface ComplaintFormDraft {
recipient: string;
subject: string;
description: string;
}

View File

@@ -0,0 +1,4 @@
export const COMPLAINT_SUBMIT_PATH =
"/external-portal/portal-outgoing/submit-letter";
export const COMPLAINT_RECORDS_PATH = "/external-portal/portal-outgoing";

View File

@@ -0,0 +1,109 @@
import type { ComplaintVerificationSession } from "../types/complaint.types";
const STORAGE_KEY = "complaint-verification";
const COMPLAINT_VERIFICATION_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
export function storeComplaintVerification(
session: ComplaintVerificationSession,
): void {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session));
}
export function getComplaintVerification(): ComplaintVerificationSession | null {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const session = JSON.parse(raw) as ComplaintVerificationSession;
if (!session.verified) {
return null;
}
const method = session.method ?? "fayda";
if (method === "fayda" && !session.citizen) {
return null;
}
if (method === "tin" && !session.organization?.tin) {
return null;
}
return { ...session, method };
} catch {
clearComplaintVerification();
return null;
}
}
export function clearComplaintVerification(): void {
sessionStorage.removeItem(STORAGE_KEY);
}
export function hasComplaintVerification(): boolean {
return getComplaintVerification() !== null;
}
export function isComplaintAuthContext(pathname = ""): boolean {
return (
pathname.startsWith("/complaints") ||
pathname === "/complaint-form" ||
pathname === "/follow-complaint" ||
pathname === "/callback"
);
}
export function setupComplaintVerificationIdleCleanup(
timeoutMs = COMPLAINT_VERIFICATION_IDLE_TIMEOUT_MS,
): () => void {
let timeoutId: number | null = null;
const scheduleCleanup = () => {
if (timeoutId) {
window.clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
clearComplaintVerification();
}, timeoutMs);
};
const handleActivity = () => {
if (!sessionStorage.getItem(STORAGE_KEY)) {
if (timeoutId) {
window.clearTimeout(timeoutId);
timeoutId = null;
}
return;
}
scheduleCleanup();
};
const events: (keyof WindowEventMap)[] = [
"mousemove",
"mousedown",
"keydown",
"scroll",
"touchstart",
"click",
"focus",
];
events.forEach((eventName) => {
window.addEventListener(eventName, handleActivity, { passive: true });
});
document.addEventListener("visibilitychange", handleActivity);
handleActivity();
return () => {
if (timeoutId) {
window.clearTimeout(timeoutId);
timeoutId = null;
}
events.forEach((eventName) => {
window.removeEventListener(eventName, handleActivity);
});
document.removeEventListener("visibilitychange", handleActivity);
};
}