Files
Investidor-V2/resources/js/lib/portal-api.ts
T
nala 250ac35fd1
tests / ci (push) Canceled after 0s
first commit
2026-09-28 10:01:09 +02:00

228 lines
7.1 KiB
TypeScript

export type Sector = {
slug: string;
name: string;
shortDescription: string | null;
opportunitiesCount: number;
};
export type Opportunity = {
slug: string;
reference: string;
title: string;
sectorName: string;
province: string | null;
maturity: string | null;
amount: string | null;
currency: string;
financing: string | null;
impact: string | null;
summary: string | null;
description: string | null;
featured: boolean;
};
export type OpportunityRecord = {
slug: string;
reference: string;
title: string;
sector: { name: string; slug: string } | null;
province: string | null;
maturity_label: string | null;
estimated_investment_amount: string | null;
estimated_investment_currency: string;
financing_modality: string | null;
expected_impact: string | null;
summary: string | null;
description: string | null;
is_featured: boolean;
};
export type HomeResponse = {
success: boolean;
data: {
sectors: Sector[];
stats: { opportunities: number; sectors: number; provinces: number };
featuredOpportunities: {
reference: string;
title: string;
sector: string | null;
province: string | null;
maturity: string | null;
estimatedInvestment: { amount: string | null; currency: string };
url: string;
}[];
};
};
export type SearchResponse = {
data: OpportunityRecord[];
current_page: number;
last_page: number;
total: number;
};
export type Filters = {
minAmount?: string;
maxAmount?: string;
currency?: string;
query: string;
sector: string;
province: string;
maturity: string;
};
export type InterestFormData = {
name: string;
email: string;
phone: string;
investorType: string;
country: string;
sectorInterest: string;
opportunityReference: string;
investmentRange: string;
message: string;
privacyConsent: boolean;
};
export type FormErrors = Partial<Record<keyof InterestFormData, string>>;
export class PortalApiError extends Error {
status: number;
errors: Record<string, string[]>;
constructor(status: number, errors: Record<string, string[]> = {}) {
super(
status === 429
? "Foram enviados demasiados pedidos. Aguarde um minuto e tente novamente."
: status === 404
? "O registo solicitado já não está disponível."
: status === 422
? "Verifique os campos assinalados."
: "Não foi possível comunicar com o portal. Tente novamente.",
);
this.status = status;
this.errors = errors;
}
}
export async function portalRequest<T>(url: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
headers.set("Accept", "application/json");
const response = await fetch(url, {
...options,
credentials: "same-origin",
headers,
});
const body = await response.json().catch(() => null);
if (!response.ok || body === null) {
throw new PortalApiError(
response.status,
response.status === 422 ? body?.errors : undefined,
);
}
return body as T;
}
export function toOpportunity(record: OpportunityRecord): Opportunity {
return {
slug: record.slug,
reference: record.reference,
title: record.title,
sectorName: record.sector?.name ?? "Sector não indicado",
province: record.province,
maturity: record.maturity_label,
amount: record.estimated_investment_amount,
currency: record.estimated_investment_currency,
financing: record.financing_modality,
impact: record.expected_impact,
summary: record.summary,
description: record.description,
featured: record.is_featured,
};
}
export function toFeaturedOpportunity(
record: HomeResponse["data"]["featuredOpportunities"][number],
): Opportunity {
return {
slug: record.url.split("/").filter(Boolean).at(-1) ?? "",
reference: record.reference,
title: record.title,
sectorName: record.sector ?? "Sector não indicado",
province: record.province,
maturity: record.maturity,
amount: record.estimatedInvestment.amount,
currency: record.estimatedInvestment.currency,
financing: null,
impact: null,
summary: null,
description: null,
featured: true,
};
}
export function searchUrl(filters: Filters, page: number): string {
const params = new URLSearchParams({ page: String(page), per_page: "12" });
if (filters.query.trim()) params.set("q", filters.query.trim());
if (filters.sector) params.set("sector", filters.sector);
if (filters.province) params.set("province", filters.province);
if (filters.maturity) params.set("maturity_level", filters.maturity);
if (filters.minAmount) params.set("min_amount", filters.minAmount);
if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
if (filters.currency) params.set("currency", filters.currency);
return `/api/v1/opportunities/search?${params.toString()}`;
}
export function interestPayload(form: InterestFormData) {
const { name, ...fields } = form;
return { ...fields, fullName: name.trim() };
}
export function newSubmissionKey(): string {
// getRandomValues also supports local HTTP development origins.
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
export function fieldErrors(error: PortalApiError): FormErrors {
const result: FormErrors = {};
const fields: (keyof InterestFormData)[] = [
"name",
"email",
"phone",
"investorType",
"country",
"sectorInterest",
"opportunityReference",
"investmentRange",
"message",
"privacyConsent",
];
for (const field of fields) {
const messages = error.errors[field === "name" ? "fullName" : field];
if (Array.isArray(messages) && typeof messages[0] === "string") {
result[field] = messages[0];
}
}
return result;
}
export function money(amount: string | null, currency: string): string {
if (amount === null || !/^-?\d+(\.\d+)?$/.test(amount)) return "Por indicar";
try {
// Intl accepts decimal strings without converting them to a lossy Number.
const formatter = new Intl.NumberFormat("pt-MZ", {
style: "currency",
currency,
maximumFractionDigits: 2,
});
// TypeScript's declaration omits the decimal-string input supported by Intl.
return formatter.format(amount as unknown as number);
} catch {
return `${amount} ${currency}`;
}
}