380 lines
19 KiB
TypeScript
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),
|
|
};
|