Files
Investidor-V2/resources/js/lib/portal-api.ts
T

242 lines
7.6 KiB
TypeScript

export type Sector = {
url?: string;
slug: string;
name: string;
specializations?: { id: number; name: string; slug: string; url: 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 = {
declared_origin_country_code?: string;
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');
if (options.method && !['GET', 'HEAD'].includes(options.method.toUpperCase())) {
const token = typeof document === 'undefined' ? undefined : document.cookie.split('; ').find(value => value.startsWith('XSRF-TOKEN='))?.slice(11);
if (token) headers.set('X-XSRF-TOKEN', decodeURIComponent(token));
}
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)[] = [
'declared_origin_country_code',
'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}`;
}
}