Added 'Back to Dashboard' buttons to all admin dashboard pages for improved navigation. Introduced AbortSignal timeouts to API client and middleware requests to prevent hanging network calls. Also enabled messaging customers from the order details page if Telegram info is available.
420 lines
12 KiB
TypeScript
420 lines
12 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
|
|
url = `${apiUrl}${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}`);
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
}
|