Files
ember-market-frontend/lib/api-client.ts
g 2db13cc9b7 Add admin order details modal and improve admin UI
Introduces an admin-only OrderDetailsModal component for viewing and managing order details, including status updates and transaction info. Updates OrdersTable to support the modal, and enhances the admin dashboard page with lazy loading and skeletons for better UX. Also fixes API client base URL handling for /api prefix.
2025-12-17 23:38:17 +00:00

515 lines
15 KiB
TypeScript

'use client';
// Import toast conditionally to prevent build errors
let toast: any;
try {
// Try to import from the UI components
toast = require("@/components/ui/use-toast").toast;
} catch (error) {
// Fallback toast function if not available
toast = {
error: (message: string) => console.error(message)
};
}
// Types
type FetchMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
interface FetchOptions {
method?: FetchMethod;
body?: any;
cache?: RequestCache;
headers?: HeadersInit;
}
// Customer types
export interface CustomerStats {
userId: string;
telegramUserId: number;
telegramUsername: string;
totalOrders: number;
totalSpent: number;
ordersByStatus: {
paid: number;
completed: number;
acknowledged: number;
shipped: number;
};
lastOrderDate: string | null;
firstOrderDate: string;
chatId: number;
hasOrders?: boolean;
}
export interface CustomerResponse {
customers: CustomerStats[];
total: number;
success?: boolean;
}
// Add cache invalidation and order refresh utilities
interface CacheEntry {
data: any;
timestamp: number;
ttl: number; // Time to live in milliseconds
}
class ApiCache {
private cache = new Map<string, CacheEntry>();
private orderRefreshCallbacks = new Set<() => void>();
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > entry.ttl) {
this.cache.delete(key);
return null;
}
return entry.data;
}
set(key: string, data: any, ttl: number = 60000): void { // Default 1 minute TTL
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl
});
}
invalidate(pattern?: string): void {
if (pattern) {
// Remove entries matching pattern
for (const [key] of this.cache) {
if (key.includes(pattern)) {
this.cache.delete(key);
}
}
} else {
// Clear all cache
this.cache.clear();
}
}
// Order-specific cache invalidation
invalidateOrderData(orderId?: string): void {
if (orderId) {
this.invalidate(`orders/${orderId}`);
this.invalidate(`orders?`); // Invalidate order lists
} else {
this.invalidate('orders');
}
// Show a subtle notification when data is refreshed
if (typeof window !== 'undefined') {
console.log('🔄 Order data cache invalidated, refreshing components...');
}
// Trigger order refresh callbacks
this.orderRefreshCallbacks.forEach(callback => {
try {
callback();
} catch (error) {
console.error('Error in order refresh callback:', error);
}
});
}
// Register callback for order data refresh
onOrderRefresh(callback: () => void): () => void {
this.orderRefreshCallbacks.add(callback);
// Return unsubscribe function
return () => {
this.orderRefreshCallbacks.delete(callback);
};
}
}
// Global cache instance
const apiCache = new ApiCache();
// Export cache utilities
export const cacheUtils = {
invalidateOrderData: (orderId?: string) => apiCache.invalidateOrderData(orderId),
onOrderRefresh: (callback: () => void) => apiCache.onOrderRefresh(callback),
invalidateAll: () => apiCache.invalidate(),
};
/**
* Normalizes a URL to ensure it has the correct /api prefix
* This prevents double prefixing which causes API errors
*/
function normalizeApiUrl(url: string): string {
// Remove any existing /api or api prefix
const cleanPath = url.replace(/^\/?(api\/)+/, '');
// Add a single /api prefix
return `/api/${cleanPath.replace(/^\//, '')}`;
}
/**
* Get the authentication token from cookies or localStorage
* Note: HTTP-only cookies cannot be read by JavaScript, so we return null
* and rely on the browser to automatically include them in requests
*/
export function getAuthToken(): string | null {
if (typeof document === 'undefined') return null; // Guard for SSR
// Try localStorage first (for non-HTTP-only tokens)
const localToken = localStorage.getItem('Authorization');
if (localToken) {
return localToken;
}
// For HTTP-only cookies, we can't read them from JavaScript
// The browser will automatically include them in requests
// Check if the cookie exists (we can't read its value)
const hasAuthCookie = document.cookie
.split('; ')
.some(row => row.startsWith('Authorization='));
if (hasAuthCookie) {
// Return a special marker to indicate the cookie exists
// The actual token will be sent automatically by the browser
return 'HTTP_ONLY_COOKIE';
}
return null;
}
/**
* Get a cookie value by name
*/
export function getCookie(name: string): string | undefined {
if (typeof document === 'undefined') return undefined; // Guard for SSR
return document.cookie
.split('; ')
.find(row => row.startsWith(`${name}=`))
?.split('=')[1];
}
/**
* Creates standard API request headers with authentication
*
* @param token Optional auth token (fetched automatically if not provided)
* @param customHeaders Additional headers to include
* @returns Headers object ready for fetch requests
*/
function createApiHeaders(token?: string | null, customHeaders: Record<string, string> = {}): Headers {
const headers = new Headers({
'Content-Type': 'application/json',
'accept': '*/*',
...customHeaders
});
const authToken = token || getAuthToken();
if (authToken && authToken !== 'HTTP_ONLY_COOKIE') {
// Only add Authorization header for non-HTTP-only tokens
headers.set('authorization', `Bearer ${authToken}`);
}
// For HTTP_ONLY_COOKIE, the browser will automatically include the cookie
return headers;
}
/**
* Simple client-side fetch function for making API calls with Authorization header.
* Uses the Next.js API proxy to make requests through the same domain.
*/
export async function clientFetch<T = any>(url: string, options: RequestInit = {}): Promise<T> {
try {
// Create headers with authentication
const headers = createApiHeaders(null, options.headers as Record<string, string>);
// Normalize URL to ensure it uses the Next.js API proxy
const fullUrl = normalizeApiUrl(url);
const res = await fetch(fullUrl, {
...options,
headers,
credentials: 'include',
mode: 'cors',
referrerPolicy: 'strict-origin-when-cross-origin',
signal: AbortSignal.timeout(30000), // 30 second timeout
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
const errorMessage = errorData.message || errorData.error || `Request failed: ${res.status} ${res.statusText}`;
throw new Error(errorMessage);
}
// Handle 204 No Content responses
if (res.status === 204) {
return {} as T;
}
return await res.json();
} catch (error) {
console.error(`Client fetch error at ${url}:`, error);
throw error;
}
}
/**
* Enhanced client-side fetch function with error toast notifications.
* Use this when you want automatic error handling with toast notifications.
*/
export async function fetchClient<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<T> {
const { method = 'GET', body, headers = {}, ...rest } = options;
// Get the base API URL from environment or fallback
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
// Ensure the endpoint starts with a slash
const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
// Handle API endpoint construction without exposing internal domains
let url;
// Check if this is a proxied API call (relative URLs starting with /api)
if (apiUrl === '/api' || apiUrl.startsWith('/api')) {
// For proxied requests, use relative URLs
if (normalizedEndpoint.startsWith('/api/')) {
url = normalizedEndpoint; // Use as-is for proxied requests
} else {
url = `/api${normalizedEndpoint}`;
}
} else {
// For direct API calls, construct full URL
// Ensure /api prefix is included if apiUrl doesn't already have it
const baseUrl = apiUrl.endsWith('/api') ? apiUrl : `${apiUrl}/api`;
url = `${baseUrl}${normalizedEndpoint}`;
}
// Get auth token from cookies
const authToken = getAuthToken();
// Prepare headers with authentication if token exists
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...(headers as Record<string, string>),
};
if (authToken && authToken !== 'HTTP_ONLY_COOKIE') {
// Backend expects "Bearer TOKEN" format
requestHeaders['Authorization'] = `Bearer ${authToken}`;
}
// For HTTP_ONLY_COOKIE, the browser will automatically include the cookie
const fetchOptions: RequestInit = {
method,
credentials: 'include',
headers: requestHeaders,
signal: AbortSignal.timeout(30000), // 30 second timeout
...rest,
};
if (body && method !== 'GET') {
fetchOptions.body = JSON.stringify(body);
}
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.message || errorData.error || 'An error occurred';
throw new Error(errorMessage);
}
if (response.status === 204) {
return {} as T;
}
const data = await response.json();
return data;
} catch (error) {
console.error('API request failed:', error);
// Only show toast if this is a client-side error (not during SSR)
if (typeof window !== 'undefined') {
const message = error instanceof Error ? error.message : 'Failed to connect to server';
// Handle different toast implementations
if (toast?.title && toast?.description) {
// shadcn/ui toast format
toast({
title: 'Error',
description: message,
variant: 'destructive',
});
} else if (typeof toast?.error === 'function') {
// sonner or other simple toast
toast.error(message);
} else {
// Fallback to console
console.error('API error:', message);
}
}
throw error;
}
}
// =========== API SERVICES ===========
/**
* Get a paginated list of customers
* @param page Page number (starting from 1)
* @param limit Number of items per page
* @returns Promise with customers data and total count
*/
export const getCustomers = async (page: number = 1, limit: number = 25): Promise<CustomerResponse> => {
return clientFetch(`/customers?page=${page}&limit=${limit}`);
};
/**
* Get detailed stats for a specific customer
* @param userId The customer's user ID
* @returns Promise with detailed customer stats
*/
export const getCustomerDetails = async (userId: string): Promise<CustomerStats> => {
return clientFetch(`/customers/${userId}`);
};
/**
* Export orders by status to CSV
* @param status Order status to filter by
* @param storeId Optional store ID (uses authenticated user's store if not provided)
* @returns Promise that resolves when download is triggered
*/
export const exportOrdersToCSV = async (status: string, storeId?: string): Promise<void> => {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const normalizedEndpoint = '/orders/export-csv';
// Handle API endpoint construction
let url;
if (apiUrl === '/api' || apiUrl.startsWith('/api')) {
url = `/api${normalizedEndpoint}`;
} else {
url = `${apiUrl}${normalizedEndpoint}`;
}
// Add query parameters
const params = new URLSearchParams({ status });
if (storeId) {
params.append('storeId', storeId);
}
url = `${url}?${params.toString()}`;
// Get auth token
const authToken = getAuthToken();
// Prepare headers
const headers: Record<string, string> = {};
if (authToken && authToken !== 'HTTP_ONLY_COOKIE') {
headers['Authorization'] = `Bearer ${authToken}`;
}
// Fetch the CSV file
const response = await fetch(url, {
method: 'GET',
credentials: 'include',
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.message || errorData.error || 'Failed to export orders';
throw new Error(errorMessage);
}
// Get the CSV content
const blob = await response.blob();
// Get filename from Content-Disposition header or generate one
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `orders_${status}_${new Date().toISOString().split('T')[0]}.csv`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// Create download link and trigger download
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
} catch (error) {
console.error('Error exporting orders to CSV:', error);
const message = error instanceof Error ? error.message : 'Failed to export orders';
// Show error toast
if (typeof window !== 'undefined') {
if (toast?.title && toast?.description) {
toast({
title: 'Export Failed',
description: message,
variant: 'destructive',
});
} else if (typeof toast?.error === 'function') {
toast.error(message);
} else {
console.error('CSV export error:', message);
}
}
throw error;
}
};
/**
* Enhanced client-side fetch function with caching and automatic invalidation
*/
export async function clientFetchWithCache<T = any>(
url: string,
options: RequestInit = {},
cacheKey?: string,
ttl?: number
): Promise<T> {
// Check cache first for GET requests
if (options.method === 'GET' || !options.method) {
const cached = apiCache.get(cacheKey || url);
if (cached) {
return cached;
}
}
// Make the request
const result = await clientFetch<T>(url, options);
// Cache GET requests
if ((options.method === 'GET' || !options.method) && cacheKey) {
apiCache.set(cacheKey, result, ttl);
}
// Invalidate cache for mutations that affect orders
if (options.method && ['PUT', 'POST', 'DELETE', 'PATCH'].includes(options.method)) {
if (url.includes('/orders/') && url.includes('/status')) {
// Order status update - invalidate order cache
const orderIdMatch = url.match(/\/orders\/([^\/]+)\/status/);
if (orderIdMatch) {
apiCache.invalidateOrderData(orderIdMatch[1]);
}
} else if (url.includes('/orders')) {
// General order mutation
apiCache.invalidateOrderData();
}
}
return result;
}