/** * BoostMedia AI Content Generator Admin - API Client * * @package BoostMedia_AI * @license GPL-2.0-or-later */ import type { ApiResponse, ApiError, JobsListResponse, UpcomingPlan, OnboardingState } from '../types' let currentDataLanguage = window.bmaiSettings?.dataLanguage || 'he' function getApiBase(): string { const url = window.bmaiSettings?.apiUrl || '/wp-json/bmai/v1/' return url.replace(/\/+$/, '') } class ApiClient { private nonce: string private baseUrl: string constructor() { this.nonce = window.bmaiSettings?.nonce || '' this.baseUrl = getApiBase() } private getHeaders(): HeadersInit { const freshNonce = (window as any).bmaiSettings?.nonce || this.nonce return { 'Content-Type': 'application/json', 'X-WP-Nonce': freshNonce, 'X-BMAI-Data-Language': currentDataLanguage, } } private async handleResponse(response: Response): Promise> { if (!response.ok) { if (response.status === 403) { console.warn('BMAI: 403 response — nonce may be expired. Reload the page if errors persist.') } const errorData = await response.json().catch(() => ({})) const nestedError = errorData && typeof errorData === 'object' && 'error' in errorData ? (errorData.error as Record) : null const error: ApiError = { code: (nestedError?.code as string) || errorData.code || 'unknown_error', message: (nestedError?.message as string) || errorData.message || 'An unknown error occurred', status: response.status, } throw error } const jsonResponse = await response.json() // The WordPress API returns { success: true, data: [...] } // Extract the inner data to avoid double-wrapping if (jsonResponse && typeof jsonResponse === 'object' && 'data' in jsonResponse) { return { success: jsonResponse.success ?? true, data: jsonResponse.data as T, } } return { success: true, data: jsonResponse as T, } } private url(endpoint: string): string { const path = endpoint.replace(/^\/+/, '') return `${this.baseUrl}/${path}` } async get(endpoint: string): Promise> { const response = await fetch(this.url(endpoint), { method: 'GET', headers: this.getHeaders(), credentials: 'same-origin', }) return this.handleResponse(response) } async post(endpoint: string, data?: unknown): Promise> { const response = await fetch(this.url(endpoint), { method: 'POST', headers: this.getHeaders(), credentials: 'same-origin', body: data ? JSON.stringify(data) : undefined, }) return this.handleResponse(response) } async put(endpoint: string, data?: unknown): Promise> { const response = await fetch(this.url(endpoint), { method: 'PUT', headers: this.getHeaders(), credentials: 'same-origin', body: data ? JSON.stringify(data) : undefined, }) return this.handleResponse(response) } async delete(endpoint: string): Promise> { const response = await fetch(this.url(endpoint), { method: 'DELETE', headers: this.getHeaders(), credentials: 'same-origin', }) return this.handleResponse(response) } /** * Multipart/form-data upload (for endpoints that accept files, e.g. feedback screenshots). * Do NOT set Content-Type — the browser fills in the boundary automatically. */ async upload(endpoint: string, formData: FormData): Promise> { const freshNonce = (window as any).bmaiSettings?.nonce || this.nonce const response = await fetch(this.url(endpoint), { method: 'POST', headers: { 'X-WP-Nonce': freshNonce, 'X-BMAI-Data-Language': currentDataLanguage, }, credentials: 'same-origin', body: formData, }) return this.handleResponse(response) } } export const api = new ApiClient() export function getErrorMessage(err: unknown, fallback = 'An error occurred'): string { if (err instanceof Error) return err.message if (typeof err === 'object' && err !== null && 'message' in err) { const msg = (err as { message: unknown }).message if (typeof msg === 'string') return msg } if (typeof err === 'string') return err return fallback } export function getCurrentDataLanguage(): string { return currentDataLanguage } export function setCurrentDataLanguage(language: string): void { currentDataLanguage = language || 'he' } export interface AffiliatePurchasePayload { amount: number currency: string package_id?: string } // Convenience functions for common endpoints export const endpoints = { // Scan scanAll: () => api.post('/scan/all'), scanPostType: (postType: string) => api.post(`/scan/${postType}`), getScanStatus: () => api.get('/scan/status'), // Structures getStructures: () => api.get('/structures'), getStructure: (postType: string) => api.get(`/structures/${postType}`), getStructureSamples: (postType: string) => api.get(`/structures/${postType}/samples`), getTaxonomyTerms: (postType: string, taxonomy: string) => api.get(`/structures/${postType}/terms/${taxonomy}`), deleteStructure: (postType: string) => api.delete(`/structures/${postType}`), // Analysis (Week 3) analyzePostType: (postType: string, options?: { use_ai?: boolean; samples_count?: number; taxonomy_slug?: string; taxonomy_term?: string }) => api.post(`/analyze/${postType}`, options), getAnalysis: (postType: string) => api.get(`/analysis/${postType}`), getAnalysisByTaxonomy: (postType: string, taxonomy: string) => api.get(`/analysis/${postType}/${taxonomy}`), getAnalysisStatus: () => api.get('/analysis/status'), deleteAnalysis: (postType: string) => api.delete(`/analysis/${postType}`), getAnalysisSummary: (postType: string, taxonomy?: string) => { const path = taxonomy ? `/analysis/${postType}/summary?taxonomy=${taxonomy}` : `/analysis/${postType}/summary` return api.get(path) }, // Generate (Week 6) generate: (data: unknown) => api.post('/generate', data), generatePreview: (data: unknown) => api.post('/generate/preview', data), getJobStatus: (jobId: string) => api.get(`/generate/job-status?job_id=${jobId}`), getGenerateStats: () => api.get('/generate/stats'), getGenerateStatus: () => api.get('/generate/status'), getGenerated: (params?: { planId?: number; status?: string; page?: number; perPage?: number }) => { const query = new URLSearchParams() if (params?.planId) query.set('plan_id', String(params.planId)) if (params?.status) query.set('status', params.status) if (params?.page) query.set('page', String(params.page)) if (params?.perPage) query.set('per_page', String(params.perPage)) const qs = query.toString() return api.get(`/generated${qs ? `?${qs}` : ''}`) }, getGeneratedById: (id: number) => api.get(`/generated/${id}`), updateGenerated: (id: number, data: unknown) => api.put(`/generated/${id}`, data), deleteGenerated: (id: number) => api.delete(`/generated/${id}`), regenerateContent: (id: number) => api.post(`/generated/${id}/regenerate`), regenerateImage: (postId: number, notes?: string) => api.post<{ image_url: string; thumbnail_url: string; post_id: number }>(`/posts/${postId}/regenerate-image`, { notes }), publishGenerated: (id: number) => api.post(`/generated/${id}/publish`), scheduleGenerated: (id: number, scheduleAt: string) => api.post(`/generated/${id}/schedule`, { scheduled_at: scheduleAt }), // Credits & Pricing getCreditsStatus: () => api.get('/credits/status'), getPricingCatalog: () => api.get('/pricing/catalog'), createPurchaseOrder: (packageId: string) => api.post('/purchases/create-order', { package_id: packageId }), capturePurchaseOrder: (orderId: string) => api.post('/purchases/capture-order', { order_id: orderId }), getPaypalConfig: () => api.get('/purchases/paypal-config'), reportAffiliatePurchase: (data: AffiliatePurchasePayload) => api.post('/affiliate/report-purchase', data), // Subscriptions createSubscription: (packageId: string) => api.post('/subscriptions/create', { package_id: packageId }), getSubscriptionStatus: () => api.get('/subscriptions/status'), cancelSubscription: () => api.post('/subscriptions/cancel'), // Links getInternalLinks: () => api.get('/links/internal'), getInternalLinkStats: () => api.get('/links/internal/stats'), getLinkRules: () => api.get('/links/rules'), saveLinkRules: (rules: unknown) => api.post('/links/rules', { rules }), getLinkScopeOptions: () => api.get('/links/scope-options'), getLinkTerms: (taxonomy: string) => api.get(`/links/terms?taxonomy=${encodeURIComponent(taxonomy)}`), getLinkPosts: (postType: string, search = '', limit = 20) => api.get(`/links/posts?post_type=${encodeURIComponent(postType)}&search=${encodeURIComponent(search)}&limit=${encodeURIComponent(String(limit))}`), collectLinks: (rules: unknown) => api.post('/links/collect', { rules }), deleteLinkIndex: () => api.delete('/links/index'), getInternalLinkSources: () => api.get('/links/internal/sources'), updateInternalLinkSources: (sources: unknown) => api.put('/links/internal/sources', { sources }), previewInternalLinkSources: (sources: unknown) => api.post('/links/internal/preview', { sources }), collectInternalLinks: (data?: { mode?: 'replace' | 'add'; sources?: unknown }) => api.post('/links/internal/collect', data), updateInternalLink: (id: number, data: unknown) => api.put(`/links/internal/${id}`, data), deleteInternalLink: (id: number) => api.delete(`/links/internal/${id}`), bulkDeleteInternalLinks: (ids: number[]) => api.post('/links/internal/bulk-delete', { ids }), getExternalLinks: () => api.get('/links/external'), createExternalLink: (data: { url: string; keyword?: string; title?: string; context?: string }) => api.post('/links/external', data), updateExternalLink: (id: number, data: unknown) => api.put(`/links/external/${id}`, data), deleteExternalLink: (id: number) => api.delete(`/links/external/${id}`), bulkDeleteExternalLinks: (ids: number[]) => api.post('/links/external/bulk-delete', { ids }), analyzeExternalLinks: (options?: { limit?: number; ids?: number[]; include_analyzed?: boolean }) => api.post('/links/external/analyze', options), removeLinkFromPosts: (linkId: string) => api.post('/links/remove-from-posts', { link_id: linkId }), bulkRemoveLinksFromPosts: (linkIds: string[]) => api.post<{ affected_posts: number }>('/links/remove-from-posts', { link_ids: linkIds }), analyzeInternalLinks: (options?: { limit?: number; ids?: number[]; includeAnalyzed?: boolean }) => api.post('/links/internal/analyze', options), // Reporters getReporters: (status?: 'all') => api.get(`/reporters${status ? `?status=${status}` : ''}`), getReporter: (id: number) => api.get(`/reporters/${id}`), createReporter: (data: unknown) => api.post('/reporters', data), updateReporter: (id: number, data: unknown) => api.put(`/reporters/${id}`, data), deleteReporter: (id: number) => api.delete(`/reporters/${id}`), duplicateReporter: (id: number) => api.post(`/reporters/${id}/duplicate`), setDefaultReporter: (id: number) => api.post(`/reporters/${id}/set-default`), completeReporterBuilderSession: (sessionId: string) => api.post(`/reporters/builder/session/${sessionId}/complete`), // Shared AI chat getChatCapabilities: () => api.get('/ai/chat/capabilities'), createChatSession: (data: unknown) => api.post('/ai/chat/session', data), getChatSession: (sessionId: string) => api.get(`/ai/chat/session/${sessionId}`), sendChatRespond: (data: unknown) => api.post('/ai/chat/respond', data), getChatResponse: (responseId: string) => api.get(`/ai/chat/respond/${responseId}`), // Post type rules / reusable plans getPostTypeRules: (postType: string, params?: { reporter_id?: number; structure_hash?: string; taxonomy_scope?: string; term_scope?: string }) => { const query = new URLSearchParams() if (params?.reporter_id) query.set('reporter_id', String(params.reporter_id)) if (params?.structure_hash) query.set('structure_hash', params.structure_hash) if (params?.taxonomy_scope) query.set('taxonomy_scope', params.taxonomy_scope) if (params?.term_scope) query.set('term_scope', params.term_scope) return api.get(`/post-type-rules/${postType}${query.toString() ? `?${query.toString()}` : ''}`) }, savePostTypeRules: (data: unknown) => api.post('/post-type-rules', data), getContentPlans: () => api.get('/content-plans'), getContentPlanById: (id: number) => api.get(`/content-plans/${id}`), deleteContentPlan: (id: number) => api.delete(`/content-plans/${id}`), duplicateContentPlan: (id: number) => api.post(`/content-plans/${id}/duplicate`), toggleContentPlanActive: (id: number, isActive: boolean) => api.post(`/content-plans/${id}/toggle-active`, { is_active: isActive }), markContentPlanRun: (id: number) => api.post(`/content-plans/${id}/mark-run`), // Sprint Generation (2.0) sprintGenerate: (data: unknown) => api.post('/sprint-generate', data), getSprintStatus: (sprintJobId: string) => api.get(`/sprint-status/${sprintJobId}`), // Jobs list listJobs: (params?: { limit?: number; before?: string; status?: string }) => { const query = new URLSearchParams() if (params?.limit != null) query.set('limit', String(params.limit)) if (params?.before) query.set('before', params.before) if (params?.status) query.set('status', params.status) const qs = query.toString() return api.get(`/jobs/list${qs ? `?${qs}` : ''}`) }, // Upcoming plans getUpcomingPlans: () => api.get('/content-plans/upcoming'), // Onboarding getOnboardingState: () => api.get('/user/onboarding'), updateOnboardingState: (updates: Partial) => api.post('/user/onboarding', updates), // Settings (Week 6) getSettings: () => api.get('/settings'), updateSettings: (data: unknown) => api.put('/settings', data), testApiKey: () => api.post('/settings/test-api'), getModels: () => api.get('/settings/models'), deleteApiKey: () => api.delete('/settings/api-key'), resetSettings: () => api.post('/settings/reset'), // Logs getLogs: (params?: { limit?: number; action_type?: string }) => { const queryParams = new URLSearchParams() if (params?.limit) queryParams.set('limit', String(params.limit)) if (params?.action_type) queryParams.set('action_type', params.action_type) const query = queryParams.toString() return api.get(`/logs${query ? `?${query}` : ''}`) }, deleteLogs: () => api.delete('/logs'), // Stats getStats: () => api.get('/stats'), }