'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(); 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 = {}): 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(url: string, options: RequestInit = {}): Promise { try { // Create headers with authentication const headers = createApiHeaders(null, options.headers as Record); // 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( endpoint: string, options: FetchOptions = {} ): Promise { 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 = { 'Content-Type': 'application/json', ...(headers as Record), }; 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 => { 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 => { 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 => { 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 = {}; 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( url: string, options: RequestInit = {}, cacheKey?: string, ttl?: number ): Promise { // 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(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; }