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>; export class PortalApiError extends Error { status: number; errors: Record; constructor(status: number, errors: Record = {}) { 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.' : status === 419 ? 'A sessão não pôde ser validada. Actualize a página e tente novamente.' : 'Não foi possível comunicar com o portal. Tente novamente.', ); this.status = status; this.errors = errors; } } export async function portalRequest( url: string, options: RequestInit = {}, ): Promise { 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(';') .map((value) => value.trim()) .find((value) => value.startsWith('XSRF-TOKEN=')) ?.slice(11); try { if (token) headers.set('X-XSRF-TOKEN', decodeURIComponent(token)); } catch { throw new PortalApiError(419); } } 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}`; } }