242 lines
7.6 KiB
TypeScript
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}`;
|
|
}
|
|
}
|