Files
mercadodevida/project/apps/admin/src/lib/api-client.ts
2026-08-22 18:13:37 +02:00

380 lines
19 KiB
TypeScript

import { ApiError } from '@/types';
/**
* All requests go to /api/* (relative paths) — the Next.js catch-all
* route handler proxies them to the backend. This keeps all traffic
* within the same origin, avoiding CORS preflights entirely.
*/
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: { 'Content-Type': 'application/json' },
body: body != null ? JSON.stringify(body) : undefined,
credentials: 'include',
});
if (res.status === 401) {
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new ApiError(401, 'UNAUTHORIZED', 'Authentication required');
}
if (res.status === 403) {
throw new ApiError(403, 'FORBIDDEN', 'Insufficient permissions');
}
if (!res.ok) {
const body = await res.json().catch(() => ({ message: 'Request failed' }));
const envelope = body as { code?: string; message?: string; error?: { code?: string; message?: string } };
const error = envelope.error ?? envelope;
throw new ApiError(
res.status,
error.code ?? 'REQUEST_FAILED',
error.message ?? 'Request failed',
);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
};
// ── Auth ──────────────────────────────────────────────────────────────────────
// FIX-14: admin panel auth resolves through the backoffice endpoint.
const BACKOFFICE_AUTH_BASE = '/api/backoffice/auth';
export const authApi = {
login: (email: string, password: string) =>
api.post<{ id: string; email: string; role: string }>(`${BACKOFFICE_AUTH_BASE}/login`, { email, password }),
logout: () => api.post(`${BACKOFFICE_AUTH_BASE}/logout`),
me: () =>
api.get<{ id: string; email: string; role: string } | { user: null }>(`${BACKOFFICE_AUTH_BASE}/me`),
};
// ── Products ──────────────────────────────────────────────────────────────────
// Backend: /catalog/products (admin list), /products/:id (admin CRUD)
export const productsApi = {
list: (params?: { limit?: number; offset?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Product[]; total: number }>(
`/api/catalog/products${qs ? `?${qs}` : ''}`,
);
},
get: (id: string) => api.get<import('@/types').Product>(`/api/products/${id}`),
getVariants: (id: string) =>
api.get<{ items: import('@/types').ProductVariant[] }>(`/api/products/${id}/variants`),
createVariant: (productId: string, data: { sku: string; ean?: string | null }) =>
api.post<import('@/types').ProductVariant>(`/api/products/${productId}/variants`, data),
create: (data: unknown) => api.post<import('@/types').Product>('/api/products', data),
update: (id: string, data: unknown) =>
api.patch<import('@/types').Product>(`/api/products/${id}`, data),
generateSeo: (id: string) =>
api.post<import('@/types').Product>(`/api/products/${id}/generate-seo`),
setState: (id: string, state: 'active' | 'archived') =>
api.patch(`/api/products/${id}/state`, { state }),
delete: (id: string) => api.delete(`/api/products/${id}`),
updateVariant: (productId: string, variantId: string, data: unknown) =>
api.patch<import('@/types').ProductVariant>(`/api/products/${productId}/variants/${variantId}`, data),
};
// ── Orders ────────────────────────────────────────────────────────────────────
export const ordersApi = {
list: (params?: { limit?: number; offset?: number; status?: string; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.status) sp.set('status', params.status);
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Order[]; total: number } | import('@/types').Order[]>(
`/api/orders${qs ? `?${qs}` : ''}`,
).then((data) => (Array.isArray(data) ? { items: data, total: data.length } : data));
},
get: (id: string) => api.get<import('@/types').Order>(`/api/orders/${id}/admin`),
transition: (id: string, state: string, trackingNumber?: string, courier?: string) =>
api.post<import('@/types').Order & { notified?: boolean; notificationError?: string }>(
`/api/orders/${id}/transitions/admin`,
{
state,
...(trackingNumber?.trim() ? { trackingNumber: trackingNumber.trim() } : {}),
...(courier?.trim() ? { courier: courier.trim() } : {}),
},
),
editItems: (id: string, items: { variantId: string; quantity: number }[]) =>
api.put<import('@/types').Order>(`/api/orders/${id}/items`, { items }),
updateShipping: (id: string, trackingNumber: string, note?: string, courier?: string) =>
api.post<import('@/types').Order>(
`/api/orders/${id}/shipping`,
{
trackingNumber,
...(courier?.trim() ? { courier: courier.trim() } : {}),
...(note?.trim() ? { note } : {}),
},
),
history: (id: string) =>
api.get<{ items: import('@/types').OrderHistoryEvent[] }>(`/api/orders/${id}/history`),
};
// ── Customers ─────────────────────────────────────────────────────────────────
export const customersApi = {
list: (params?: { offset?: number; limit?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.offset !== undefined) sp.set('offset', String(params.offset));
if (params?.limit !== undefined) sp.set('limit', String(params.limit));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Customer[]; total: number }>(`/api/users${qs ? `?${qs}` : ''}`);
},
get: (id: string) => api.get<import('@/types').Customer>(`/api/users/${id}`),
update: (id: string, data: { displayName?: string; phone?: string }) =>
api.patch<import('@/types').Customer>(`/api/users/${id}`, data),
create: (data: { email: string; password: string; displayName?: string; phone?: string }) =>
api.post<import('@/types').Customer>('/api/auth/register', data),
sendPasswordResetLink: (email: string) =>
api.post<{ ok: boolean }>('/api/auth/password-reset/request', { email }),
// ── Direcciones (FIX-18) ───────────────────────────────────────────────────
listAddresses: (id: string) =>
api.get<{ items: import('@/types').Address[] }>(`/api/users/${id}/addresses`),
createAddress: (id: string, data: Omit<import('@/types').Address, 'id' | 'userId' | 'createdAt' | 'updatedAt'>) =>
api.post<import('@/types').Address>(`/api/users/${id}/addresses`, data),
updateAddress: (id: string, addressId: string, data: Partial<Omit<import('@/types').Address, 'id' | 'userId' | 'createdAt' | 'updatedAt'>>) =>
api.patch<import('@/types').Address>(`/api/users/${id}/addresses/${addressId}`, data),
deleteAddress: (id: string, addressId: string) =>
api.delete<void>(`/api/users/${id}/addresses/${addressId}`),
};
// ── Brands ────────────────────────────────────────────────────────────────────
export const brandsApi = {
list: () => api.get<{ items: import('@/types').Brand[] }>('/api/brands'),
create: (data: unknown) => api.post<import('@/types').Brand>('/api/brands', data),
update: (id: string, data: unknown) =>
api.patch<import('@/types').Brand>(`/api/brands/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/brands/${id}`),
};
// ── Categories ────────────────────────────────────────────────────────────────
export const categoriesApi = {
list: () => api.get<{ items: import('@/types').Category[] }>('/api/categories/tree'),
create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data),
update: (id: string, data: unknown) =>
api.patch<import('@/types').Category>(`/api/categories/${id}`, data),
generateSeo: (id: string) =>
api.post<import('@/types').Category>(`/api/categories/${id}/generate-seo`),
delete: (id: string) => api.delete<void>(`/api/categories/${id}`),
};
// ── Inventory ─────────────────────────────────────────────────────────────────
export const inventoryApi = {
getAvailability: (variantId: string) =>
api.get<import('@/types').StockAvailability>(`/api/inventory/${variantId}/availability`),
setStock: (id: string, quantity: number) =>
api.put<import('@/types').StockItem>(`/api/inventory/${id}/stock`, { quantity }),
};
// ── Pricing ───────────────────────────────────────────────────────────────────
export const pricingApi = {
getVariantPrice: (id: string) => api.get<import('@/types').VariantPrice>(`/api/pricing/variants/${id}`),
setVariantPrice: (
id: string,
netUnitAmountCents: number,
vatRate: 'general' | 'reduced' | 'super-reduced',
offerCents?: number | null,
costCents?: number | null,
) =>
api.put<import('@/types').VariantPrice>(`/api/pricing/variants/${id}`, {
netUnitAmountCents,
vatRate,
offerCents: offerCents ?? null,
costCents: costCents ?? null,
}),
};
// ── Promotions ────────────────────────────────────────────────────────────────
export const promotionsApi = {
list: () => api.get<{ items: unknown[] }>('/api/promotions'),
create: (data: unknown) => api.post('/api/promotions', data),
update: (code: string, data: unknown) => api.patch(`/api/promotions/${code}`, data),
delete: (code: string) => api.delete(`/api/promotions/${code}`),
};
// ── Reviews ───────────────────────────────────────────────────────────────────
export const reviewsApi = {
listAdmin: (params?: { status?: string; limit?: number; offset?: number }) => {
const sp = new URLSearchParams();
if (params?.status) sp.set('status', params.status);
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
const qs = sp.toString();
return api.get<{ items: unknown[]; total: number }>(`/api/reviews/admin${qs ? `?${qs}` : ''}`);
},
moderate: (id: string, status: 'published' | 'rejected') =>
api.patch(`/api/reviews/${id}/moderate`, { status }),
};
// ── CMS ───────────────────────────────────────────────────────────────────────
export const cmsApi = {
list: () => api.get<{ items: unknown[] }>('/api/cms/pages'),
get: (slug: string) => api.get(`/api/cms/pages/${slug}`),
create: (data: unknown) => api.post('/api/cms/pages', data),
update: (id: string, data: unknown) => api.patch(`/api/cms/pages/${id}`, data),
publish: (id: string) => api.post(`/api/cms/pages/${id}/publish`, {}),
unpublish: (id: string) => api.post(`/api/cms/pages/${id}/unpublish`, {}),
};
// ── Admin Users ─────────────────────────────────────────────────────────────────
export const adminUsersApi = {
list: (params?: { limit?: number; offset?: number; role?: string; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.role) sp.set('role', params.role);
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: { id: string; email: string; role: string; createdAt: string }[]; total: number }>(
`/api/admin/users${qs ? `?${qs}` : ''}`,
);
},
create: (data: { email: string; password: string; role: string }) =>
api.post<{ id: string; email: string; role: string; createdAt: string }>('/api/admin/users', data),
update: (id: string, data: { role?: string; password?: string }) =>
api.patch<{ id: string; email: string; role: string; createdAt: string }>(`/api/admin/users/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/admin/users/${id}`),
};
// ── Tax Rates ────────────────────────────────────────────────────────────────────
export interface TaxRate {
id: string; name: string; ratePercent: number; country: string; appliesTo: string; active: boolean;
}
export const taxApi = {
list: () => api.get<{ items: TaxRate[] }>('/api/admin/tax-rates'),
update: (id: string, data: Partial<{ name: string; ratePercent: number; active: boolean; appliesTo: 'general' | 'reduced' | 'super-reduced' }>) =>
api.patch('/api/admin/tax-rates/' + id, data),
};
// ── Payments ──────────────────────────────────────────────────────────────────────
export interface PaymentTransaction {
id: string; provider: string; providerPaymentId: string | null;
orderId: string | null; amountCents: number; currency: string;
status: string; raw: unknown; createdAt: string;
}
export const paymentsApi = {
list: (params?: { limit?: number; offset?: number; status?: string; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.status) sp.set('status', params.status);
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: PaymentTransaction[]; total: number }>(`/api/admin/payments${qs ? `?${qs}` : ''}`);
},
refund: (id: string) => api.post<{ ok: boolean }>(`/api/admin/payments/${id}/refund`, {}),
};
// ── Shipping ───────────────────────────────────────────────────────────────────────
export interface ShippingZone {
id: string; name: string; country: string; postalCodePrefix: string | null; active: boolean;
}
export interface ShippingMethod {
id: string; zoneId: string; zoneName: string; name: string;
baseCostCents: number; freeShippingThresholdCents: number | null; description: string | null;
maxWeightKg: number | null; freeShippingMaxWeightKg: number | null; active: boolean;
}
export const shippingApi = {
listZones: () => api.get<{ items: ShippingZone[] }>('/api/admin/shipping/zones'),
createZone: (data: { name: string; country: string; postalCodePrefix?: string | null; active?: boolean }) =>
api.post<{ id: string }>('/api/admin/shipping/zones', data),
updateZone: (id: string, data: Partial<{ name: string; country: string; postalCodePrefix?: string | null; active: boolean }>) =>
api.patch('/api/admin/shipping/zones/' + id, data),
deleteZone: (id: string) => api.delete<void>('/api/admin/shipping/zones/' + id),
listMethods: () => api.get<{ items: ShippingMethod[] }>('/api/admin/shipping/methods'),
createMethod: (data: { zoneId: string; name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; description?: string | null; maxWeightKg?: number | null; freeShippingMaxWeightKg?: number | null; active?: boolean }) =>
api.post<{ id: string }>('/api/admin/shipping/methods', data),
updateMethod: (id: string, data: Partial<{ name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; description?: string | null; maxWeightKg?: number | null; freeShippingMaxWeightKg?: number | null; active: boolean }>) =>
api.patch('/api/admin/shipping/methods/' + id, data),
deleteMethod: (id: string) => api.delete<void>('/api/admin/shipping/methods/' + id),
};
// ── Store Settings ─────────────────────────────────────────────────────────────────
export const auditApi = {
list: (params?: { actorId?: string; action?: string; limit?: number; offset?: number }) => {
const sp = new URLSearchParams();
if (params?.action) sp.set('action', params.action);
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
const qs = sp.toString();
return api.get<{ items: AuditEntry[]; total: number }>(`/api/admin/audit${qs ? `?${qs}` : ''}`);
},
};
export interface AuditEntry {
id: string;
actorId: string | null;
action: string;
target: string;
metadata: Record<string, unknown>;
createdAt: string;
}
export interface StoreSettings {
storeName: string;
storeTagline: string;
contactEmail: string;
contactPhone: string;
contactAddress: string;
footerText: string;
facebookUrl: string;
instagramUrl: string;
aiProvider: string;
aiBaseUrl: string;
aiModel: string;
aiApiKey: string;
aiApiKeyConfigured?: boolean;
aiSeoTitlePrompt: string;
aiSeoDescriptionPrompt: string;
aiProductDescriptionPrompt: string;
aiCategoryDescriptionPrompt: string;
aiCategorySeoTitlePrompt: string;
aiCategorySeoDescriptionPrompt: string;
smtpHost: string;
smtpPort: string;
smtpSecure: boolean;
smtpUser: string;
smtpPass: string;
smtpPassConfigured?: boolean;
smtpFrom: string;
couriers?: string[];
}
export const settingsApi = {
get: () => api.get<StoreSettings>('/api/admin/settings'),
update: (data: Partial<StoreSettings>) => api.patch<StoreSettings>('/api/admin/settings', data),
};