Refactor UI imports and update component paths
Some checks failed
Build Frontend / build (push) Failing after 7s

Replaces imports from 'components/ui' with 'components/common' across the app and dashboard pages, and updates model and API imports to use new paths under 'lib'. Removes redundant authentication checks from several dashboard pages. Adds new dashboard components and utility files, and reorganizes hooks and services into the 'lib' directory for improved structure.
This commit is contained in:
g
2026-01-13 05:02:13 +00:00
parent a6e6cd0757
commit fe01f31538
173 changed files with 1512 additions and 867 deletions

View File

@@ -4,7 +4,7 @@
let toast: any;
try {
// Try to import from the UI components
toast = require("@/components/ui/use-toast").toast;
toast = require("@/components/common/use-toast").toast;
} catch (error) {
// Fallback toast function if not available
toast = {
@@ -512,4 +512,4 @@ export async function clientFetchWithCache<T = any>(
}
return result;
}
}

View File

@@ -33,7 +33,7 @@ export {
type Product,
type ProductsResponse,
type StockData,
} from './services/product-service';
} from '../services/product-service';
// Re-export shipping services
export {
@@ -46,7 +46,7 @@ export {
// Types
type ShippingOption,
type ShippingOptionsResponse,
} from './services/shipping-service';
} from '../services/shipping-service';
// Re-export analytics services
export {
@@ -61,15 +61,15 @@ export {
getCustomerInsightsWithStore,
getOrderAnalyticsWithStore,
getStoreIdForUser,
formatGBP,
// Types
type AnalyticsOverview,
type RevenueData,
type ProductPerformance,
type CustomerInsights,
type OrderAnalytics,
} from './services/analytics-service';
} from '../services/analytics-service';
export { formatGBP, formatNumber } from '../utils/format';
// Define the PlatformStats interface to match the expected format
export interface PlatformStats {
@@ -89,7 +89,7 @@ export interface PlatformStats {
export {
getPlatformStats,
getVendorStats,
} from './services/stats-service';
} from '../services/stats-service';
// Re-export server API functions
export {
@@ -111,8 +111,8 @@ export {
// Get clientFetch first so we can use it in the compatibility functions
import { clientFetch } from './api-client';
import { getProductDetails, updateProduct } from './services/product-service';
import { getPlatformStats } from './services/stats-service';
import { getProductDetails, updateProduct } from '../services/product-service';
import { getPlatformStats } from '../services/stats-service';
// Add missing functions for backward compatibility
// These are functions from the old style that we need to maintain compatibility

View File

@@ -22,20 +22,20 @@ try {
*/
function getServerApiUrl(endpoint: string): string {
const apiUrl = process.env.SERVER_API_URL || 'http://localhost:3001/api';
// Validate API URL to prevent 500 errors
if (!apiUrl || apiUrl === 'undefined' || apiUrl === 'null') {
console.warn('SERVER_API_URL not properly set, using localhost fallback');
const fallbackUrl = 'http://localhost:3001/api';
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint;
return fallbackUrl.endsWith('/')
return fallbackUrl.endsWith('/')
? `${fallbackUrl}${cleanEndpoint}`
: `${fallbackUrl}/${cleanEndpoint}`;
}
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint;
return apiUrl.endsWith('/')
return apiUrl.endsWith('/')
? `${apiUrl}${cleanEndpoint}`
: `${apiUrl}/${cleanEndpoint}`;
}
@@ -58,18 +58,18 @@ export async function fetchServer<T = unknown>(
"For client components, use clientFetch or fetchClient instead."
);
}
// Get auth token from cookies
const cookieStore = await cookiesModule.cookies();
const authToken = cookieStore.get('Authorization')?.value;
// Redirect to login if not authenticated
if (!authToken) redirect('/login');
if (!authToken) redirect('/auth/login');
try {
// Get the complete backend URL using the utility function
const url = getServerApiUrl(endpoint);
// Make the request with proper auth headers
const res = await fetch(url, {
...options,
@@ -82,8 +82,8 @@ export async function fetchServer<T = unknown>(
});
// Handle auth failures
if (res.status === 401) redirect('/login');
if (res.status === 401) redirect('/auth/login');
// Handle other errors
if (!res.ok) {
let errorData;
@@ -92,7 +92,7 @@ export async function fetchServer<T = unknown>(
} catch {
errorData = {};
}
// Handle new error format: { success: false, error: { message: "...", code: "..." } }
// or old format: { error: "...", message: "..." }
let errorMessage: string;
@@ -105,7 +105,7 @@ export async function fetchServer<T = unknown>(
} else {
errorMessage = `Request failed: ${res.status} ${res.statusText}`;
}
throw new Error(errorMessage);
}
@@ -158,22 +158,22 @@ export const getCustomerDetailsServer = async (userId: string): Promise<Customer
export async function getPlatformStatsServer() {
try {
const url = getServerApiUrl('/stats/platform');
// Make direct request without auth
const res = await fetch(url, {
cache: 'no-store',
cache: 'no-store',
headers: {
'Content-Type': 'application/json'
}
});
const data = await res.json();
// If we have real data, use it
if (data && Object.keys(data).length > 0) {
return data;
}
console.info('Using sample stats data for demo');
return {
orders: {
@@ -296,7 +296,7 @@ export const getAnalyticsOverviewServer = async (storeId?: string): Promise<Anal
export const getRevenueTrendsServer = async (period: string = '30', storeId?: string): Promise<RevenueData[]> => {
const params = new URLSearchParams({ period });
if (storeId) params.append('storeId', storeId);
const url = `/analytics/revenue-trends?${params.toString()}`;
return fetchServer<RevenueData[]>(url);
};
@@ -314,7 +314,7 @@ export const getCustomerInsightsServer = async (storeId?: string): Promise<Custo
export const getOrderAnalyticsServer = async (period: string = '30', storeId?: string): Promise<OrderAnalytics> => {
const params = new URLSearchParams({ period });
if (storeId) params.append('storeId', storeId);
const url = `/analytics/order-analytics?${params.toString()}`;
return fetchServer<OrderAnalytics>(url);
};

View File

@@ -0,0 +1,160 @@
import { useEffect, useCallback } from 'react';
/**
* Hook for enhanced keyboard navigation on Chromebooks
*/
export function useChromebookKeyboard() {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
// Enhanced keyboard shortcuts for Chromebooks
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
// Chromebook-specific shortcuts
if (metaKey || ctrlKey) {
switch (key) {
case 'k':
// Focus search or command palette
e.preventDefault();
const searchInput = document.querySelector('input[type="search"], input[placeholder*="search" i]') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
searchInput.select();
}
break;
case 'Enter':
// Submit forms with Ctrl/Cmd + Enter
e.preventDefault();
const form = document.querySelector('form') as HTMLFormElement;
if (form) {
const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
if (submitButton && !submitButton.disabled) {
submitButton.click();
}
}
break;
case 'ArrowUp':
case 'ArrowDown':
// Navigate through messages or list items
e.preventDefault();
const focusableElements = document.querySelectorAll(
'button, input, textarea, [tabindex]:not([tabindex="-1"]), [role="button"], [role="tab"]'
) as NodeListOf<HTMLElement>;
const currentIndex = Array.from(focusableElements).indexOf(document.activeElement as HTMLElement);
let nextIndex;
if (key === 'ArrowUp') {
nextIndex = currentIndex > 0 ? currentIndex - 1 : focusableElements.length - 1;
} else {
nextIndex = currentIndex < focusableElements.length - 1 ? currentIndex + 1 : 0;
}
focusableElements[nextIndex]?.focus();
break;
}
}
// Escape key handling
if (key === 'Escape') {
// Close modals, clear inputs, or go back
const activeElement = document.activeElement as HTMLElement;
if (activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA') {
// Clear input on Escape
(activeElement as HTMLInputElement).value = '';
activeElement.blur();
} else {
// Look for close buttons or back buttons
const closeButton = document.querySelector('[aria-label*="close" i], [aria-label*="back" i]') as HTMLElement;
if (closeButton) {
closeButton.click();
}
}
}
// Tab navigation enhancement
if (key === 'Tab') {
// Ensure proper tab order for Chromebooks
const focusableElements = document.querySelectorAll(
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [role="button"]:not([disabled]), [role="tab"]'
);
// Add visual focus indicators
const addFocusIndicator = (element: Element) => {
element.classList.add('keyboard-focus');
};
const removeFocusIndicator = (element: Element) => {
element.classList.remove('keyboard-focus');
};
// Handle focus events
focusableElements.forEach(element => {
element.addEventListener('focus', () => addFocusIndicator(element));
element.addEventListener('blur', () => removeFocusIndicator(element));
});
}
}, []);
useEffect(() => {
// Add global keyboard event listener
document.addEventListener('keydown', handleKeyDown);
// Cleanup
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
return {
handleKeyDown
};
}
/**
* Hook for managing focus in chat interfaces
*/
export function useChatFocus() {
const focusMessageInput = useCallback(() => {
const messageInput = document.querySelector('input[aria-label*="message" i], textarea[aria-label*="message" i]') as HTMLInputElement;
if (messageInput) {
messageInput.focus();
messageInput.select();
}
}, []);
const focusNextMessage = useCallback(() => {
const messages = document.querySelectorAll('[role="article"]');
const currentMessage = document.activeElement?.closest('[role="article"]');
if (currentMessage) {
const currentIndex = Array.from(messages).indexOf(currentMessage);
const nextMessage = messages[currentIndex + 1] as HTMLElement;
if (nextMessage) {
nextMessage.focus();
}
} else if (messages.length > 0) {
(messages[0] as HTMLElement).focus();
}
}, []);
const focusPreviousMessage = useCallback(() => {
const messages = document.querySelectorAll('[role="article"]');
const currentMessage = document.activeElement?.closest('[role="article"]');
if (currentMessage) {
const currentIndex = Array.from(messages).indexOf(currentMessage);
const previousMessage = messages[currentIndex - 1] as HTMLElement;
if (previousMessage) {
previousMessage.focus();
}
}
}, []);
return {
focusMessageInput,
focusNextMessage,
focusPreviousMessage
};
}

View File

@@ -0,0 +1,86 @@
import { useEffect, useRef } from 'react';
/**
* Hook to enhance scrolling behavior for Chromebooks and touch devices
*/
export function useChromebookScroll() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// Enhanced scrolling for Chromebooks
const handleTouchStart = (e: TouchEvent) => {
// Prevent default touch behavior that might interfere with scrolling
if (e.touches.length === 1) {
// Single touch - allow normal scrolling
return;
}
// Multi-touch - prevent zoom gestures
if (e.touches.length > 1) {
e.preventDefault();
}
};
const handleTouchMove = (e: TouchEvent) => {
// Allow momentum scrolling on Chromebooks
if (e.touches.length === 1) {
// Single touch scrolling - allow default behavior
return;
}
// Multi-touch - prevent zoom
if (e.touches.length > 1) {
e.preventDefault();
}
};
const handleWheel = (e: WheelEvent) => {
// Enhanced wheel scrolling for Chromebook trackpads
const delta = e.deltaY;
const container = e.currentTarget as HTMLElement;
// Smooth scrolling for Chromebook trackpads
if (Math.abs(delta) > 0) {
container.scrollBy({
top: delta * 0.5, // Reduce scroll speed for better control
behavior: 'smooth'
});
}
};
// Add event listeners
container.addEventListener('touchstart', handleTouchStart, { passive: false });
container.addEventListener('touchmove', handleTouchMove, { passive: false });
container.addEventListener('wheel', handleWheel, { passive: true });
// Cleanup
return () => {
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('wheel', handleWheel);
};
}, []);
return containerRef;
}
/**
* Hook for smooth scrolling to bottom (useful for chat interfaces)
*/
export function useSmoothScrollToBottom() {
const scrollToBottom = (container: HTMLElement) => {
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
};
const scrollToBottomInstant = (container: HTMLElement) => {
container.scrollTop = container.scrollHeight;
};
return { scrollToBottom, scrollToBottomInstant };
}

52
lib/hooks/use-mobile.tsx Normal file
View File

@@ -0,0 +1,52 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
export function useIsTouchDevice() {
const [isTouch, setIsTouch] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const checkTouch = () => {
const hasTouch = 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
(navigator.userAgent.includes('CrOS') && 'ontouchstart' in window) ||
window.matchMedia('(pointer: coarse)').matches ||
!window.matchMedia('(hover: hover)').matches
setIsTouch(hasTouch)
}
checkTouch()
const mediaQuery = window.matchMedia('(pointer: coarse)')
const hoverQuery = window.matchMedia('(hover: hover)')
const handleChange = () => checkTouch()
mediaQuery.addEventListener('change', handleChange)
hoverQuery.addEventListener('change', handleChange)
return () => {
mediaQuery.removeEventListener('change', handleChange)
hoverQuery.removeEventListener('change', handleChange)
}
}, [])
return !!isTouch
}

194
lib/hooks/use-toast.ts Normal file
View File

@@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/common/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

165
lib/hooks/useFilterState.ts Normal file
View File

@@ -0,0 +1,165 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { usePathname, useSearchParams, useRouter } from "next/navigation"
import { DateRange } from "react-day-picker"
interface FilterState {
searchQuery?: string
statusFilter?: string
dateRange?: DateRange
page?: number
itemsPerPage?: number
sortColumn?: string
sortDirection?: "asc" | "desc"
}
interface UseFilterStateOptions {
/** Unique key for this page's filter state */
storageKey: string
/** Initialize from URL params on mount */
syncWithUrl?: boolean
/** Default values */
defaults?: Partial<FilterState>
}
/**
* useFilterState - Persist filter state across navigation
* Uses sessionStorage to remember filters per page
*/
export function useFilterState({
storageKey,
syncWithUrl = false,
defaults = {}
}: UseFilterStateOptions) {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const fullKey = `filterState:${storageKey}`
// Initialize state from sessionStorage or URL params
const getInitialState = (): FilterState => {
if (typeof window === "undefined") return defaults
// First try URL params if syncWithUrl is enabled
if (syncWithUrl && searchParams) {
const urlState: FilterState = {}
const status = searchParams.get("status")
const search = searchParams.get("search")
const page = searchParams.get("page")
if (status) urlState.statusFilter = status
if (search) urlState.searchQuery = search
if (page) urlState.page = parseInt(page)
if (Object.keys(urlState).length > 0) {
return { ...defaults, ...urlState }
}
}
// Then try sessionStorage
try {
const stored = sessionStorage.getItem(fullKey)
if (stored) {
const parsed = JSON.parse(stored)
// Restore dateRange as Date objects
if (parsed.dateRange) {
if (parsed.dateRange.from) parsed.dateRange.from = new Date(parsed.dateRange.from)
if (parsed.dateRange.to) parsed.dateRange.to = new Date(parsed.dateRange.to)
}
return { ...defaults, ...parsed }
}
} catch (e) {
console.warn("Failed to load filter state from storage:", e)
}
return defaults
}
const [filterState, setFilterState] = useState<FilterState>(getInitialState)
// Save to sessionStorage whenever state changes
useEffect(() => {
if (typeof window === "undefined") return
try {
sessionStorage.setItem(fullKey, JSON.stringify(filterState))
} catch (e) {
console.warn("Failed to save filter state to storage:", e)
}
}, [filterState, fullKey])
// Update URL if syncWithUrl is enabled
useEffect(() => {
if (!syncWithUrl) return
const params = new URLSearchParams()
if (filterState.statusFilter && filterState.statusFilter !== "all") {
params.set("status", filterState.statusFilter)
}
if (filterState.searchQuery) {
params.set("search", filterState.searchQuery)
}
if (filterState.page && filterState.page > 1) {
params.set("page", filterState.page.toString())
}
const queryString = params.toString()
const newUrl = queryString ? `${pathname}?${queryString}` : pathname
// Only update if URL would change
const currentQuery = searchParams?.toString() || ""
if (queryString !== currentQuery) {
router.replace(newUrl, { scroll: false })
}
}, [filterState, syncWithUrl, pathname, router, searchParams])
// Convenience setters
const setSearchQuery = useCallback((query: string) => {
setFilterState(prev => ({ ...prev, searchQuery: query, page: 1 }))
}, [])
const setStatusFilter = useCallback((status: string) => {
setFilterState(prev => ({ ...prev, statusFilter: status, page: 1 }))
}, [])
const setDateRange = useCallback((range: DateRange | undefined) => {
setFilterState(prev => ({ ...prev, dateRange: range, page: 1 }))
}, [])
const setPage = useCallback((page: number) => {
setFilterState(prev => ({ ...prev, page }))
}, [])
const setItemsPerPage = useCallback((count: number) => {
setFilterState(prev => ({ ...prev, itemsPerPage: count, page: 1 }))
}, [])
const setSort = useCallback((column: string, direction: "asc" | "desc") => {
setFilterState(prev => ({ ...prev, sortColumn: column, sortDirection: direction }))
}, [])
const clearFilters = useCallback(() => {
setFilterState(defaults)
}, [defaults])
const hasActiveFilters = Boolean(
filterState.searchQuery ||
(filterState.statusFilter && filterState.statusFilter !== "all") ||
filterState.dateRange?.from
)
return {
...filterState,
setFilterState,
setSearchQuery,
setStatusFilter,
setDateRange,
setPage,
setItemsPerPage,
setSort,
clearFilters,
hasActiveFilters
}
}

View File

@@ -0,0 +1,57 @@
import { useEffect, useRef } from "react";
import { clientFetch } from "@/lib/api";
interface UseKeepOnlineOptions {
interval?: number; // in milliseconds
enabled?: boolean;
onError?: (error: any) => void;
}
export const useKeepOnline = (options: UseKeepOnlineOptions = {}) => {
const {
interval = 1000 * 60 * 1,
enabled = true,
onError
} = options;
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!enabled) {
return;
}
const updateOnlineStatus = async () => {
try {
console.log("Updating online status...");
await clientFetch('/auth/me');
} catch (error) {
console.error("Failed to update online status:", error);
onError?.(error);
}
};
// Initial call
updateOnlineStatus();
// Set up interval
intervalRef.current = setInterval(updateOnlineStatus, interval);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [enabled, interval, onError]);
return {
isActive: enabled && intervalRef.current !== null,
stop: () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
};
};

50
lib/hooks/useUser.ts Normal file
View File

@@ -0,0 +1,50 @@
"use client"
import { useState, useEffect } from 'react'
import { clientFetch } from '@/lib/api/api-client'
interface Vendor {
_id: string;
username: string;
storeId: string;
pgpKey: string;
__v: number;
}
interface User {
vendor: Vendor;
}
export function useUser() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true)
const userData = await clientFetch<User>("/auth/me")
setUser(userData)
setError(null)
} catch (err) {
console.error("Failed to fetch user:", err)
setError("Failed to fetch user data")
setUser(null)
} finally {
setLoading(false)
}
}
fetchUser()
}, [])
const isAdmin = user?.vendor?.username === 'admin1'
return {
user,
loading,
error,
isAdmin
}
}

View File

@@ -0,0 +1,138 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import type { WidgetConfig, WidgetSettings } from "@/lib/types/dashboard"
const DEFAULT_WIDGETS: WidgetConfig[] = [
{ id: "quick-actions", title: "Quick Actions", visible: true, order: 0 },
{ id: "overview", title: "Overview", visible: true, order: 1, settings: { showChange: false } },
{ id: "recent-activity", title: "Recent Activity", visible: true, order: 2, settings: { itemCount: 10 } },
{ id: "top-products", title: "Top Products", visible: true, order: 3, settings: { itemCount: 5, showRevenue: true } },
{ id: "revenue-chart", title: "Revenue Chart", visible: false, order: 4, settings: { days: 7, showComparison: false } },
{ id: "low-stock", title: "Low Stock Alerts", visible: false, order: 5, settings: { threshold: 5, itemCount: 5 } },
{ id: "recent-customers", title: "Recent Customers", visible: false, order: 6, settings: { itemCount: 5, showSpent: true } },
{ id: "pending-chats", title: "Pending Chats", visible: false, order: 7, settings: { showPreview: true } },
]
const STORAGE_KEY = "dashboard-widget-layout-v4"
/**
* useWidgetLayout - Persist and manage dashboard widget visibility, order, and settings
*/
export function useWidgetLayout() {
const [widgets, setWidgets] = useState<WidgetConfig[]>(DEFAULT_WIDGETS)
const [isLoaded, setIsLoaded] = useState(false)
// Load from localStorage on mount
useEffect(() => {
if (typeof window === "undefined") return
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as WidgetConfig[]
// Merge with defaults to handle new widgets added in future
const merged = DEFAULT_WIDGETS.map(defaultWidget => {
const savedWidget = parsed.find(w => w.id === defaultWidget.id)
return savedWidget
? { ...defaultWidget, ...savedWidget, settings: { ...defaultWidget.settings, ...savedWidget.settings } }
: defaultWidget
})
setWidgets(merged.sort((a, b) => a.order - b.order))
}
} catch (e) {
console.warn("Failed to load widget layout:", e)
}
setIsLoaded(true)
}, [])
// Save to localStorage whenever widgets change
useEffect(() => {
if (!isLoaded || typeof window === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(widgets))
} catch (e) {
console.warn("Failed to save widget layout:", e)
}
}, [widgets, isLoaded])
const toggleWidget = useCallback((widgetId: string) => {
setWidgets(prev =>
prev.map(w => w.id === widgetId ? { ...w, visible: !w.visible } : w)
)
}, [])
const moveWidget = useCallback((widgetId: string, direction: "up" | "down") => {
setWidgets(prev => {
const index = prev.findIndex(w => w.id === widgetId)
if (index === -1) return prev
const newIndex = direction === "up" ? index - 1 : index + 1
if (newIndex < 0 || newIndex >= prev.length) return prev
const newWidgets = [...prev]
const [widget] = newWidgets.splice(index, 1)
newWidgets.splice(newIndex, 0, widget)
// Update order values
return newWidgets.map((w, i) => ({ ...w, order: i }))
})
}, [])
const updateWidgetSettings = useCallback((widgetId: string, newSettings: Record<string, any>) => {
setWidgets(prev =>
prev.map(w => w.id === widgetId
? { ...w, settings: { ...w.settings, ...newSettings } }
: w
)
)
}, [])
const getWidgetSettings = useCallback(<T extends Record<string, any>>(widgetId: string): T | undefined => {
return widgets.find(w => w.id === widgetId)?.settings as T | undefined
}, [widgets])
const resetLayout = useCallback(() => {
setWidgets(DEFAULT_WIDGETS)
}, [])
const getVisibleWidgets = useCallback(() => {
return widgets.filter(w => w.visible).sort((a, b) => a.order - b.order)
}, [widgets])
const isWidgetVisible = useCallback((widgetId: string) => {
return widgets.find(w => w.id === widgetId)?.visible ?? true
}, [widgets])
// Reorder widgets by moving activeId to overId's position
const reorderWidgets = useCallback((activeId: string, overId: string) => {
setWidgets(prev => {
const oldIndex = prev.findIndex(w => w.id === activeId)
const newIndex = prev.findIndex(w => w.id === overId)
if (oldIndex === -1 || newIndex === -1) return prev
const newWidgets = [...prev]
const [removed] = newWidgets.splice(oldIndex, 1)
newWidgets.splice(newIndex, 0, removed)
// Update order values
return newWidgets.map((w, i) => ({ ...w, order: i }))
})
}, [])
return {
widgets,
toggleWidget,
moveWidget,
reorderWidgets,
updateWidgetSettings,
getWidgetSettings,
resetLayout,
getVisibleWidgets,
isWidgetVisible,
isLoaded
}
}

6
lib/models/categories.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface Category {
_id: string;
name: string;
parentId?: string;
order?: number;
}

19
lib/models/products.ts Normal file
View File

@@ -0,0 +1,19 @@
export interface Product {
_id?: string;
name: string;
description: string;
unitType: "pcs" | "gr" | "kg" | "ml" | "oz" | "lb";
category: string;
enabled?: boolean;
// Stock management fields
stockTracking?: boolean;
currentStock?: number;
lowStockThreshold?: number;
stockStatus?: "in_stock" | "low_stock" | "out_of_stock";
pricing: Array<{
minQuantity: number;
pricePerUnit: number;
}>;
image?: string | File | null | undefined;
costPerUnit?: number;
}

View File

@@ -4,7 +4,7 @@ import React, { createContext, useContext, useState, useEffect, useRef, ReactNod
import { clientFetch } from "@/lib/api";
import { toast } from "sonner";
import { getCookie } from "@/lib/api";
import { cacheUtils } from '@/lib/api-client';
import { cacheUtils } from '@/lib/api/api-client';
import { Package } from "lucide-react";
interface Order {
@@ -343,4 +343,4 @@ export function useNotifications() {
throw new Error('useNotifications must be used within a NotificationProvider');
}
return context;
}
}

View File

@@ -1,6 +1,7 @@
"use client";
import { clientFetch } from "../api-client";
import { clientFetch } from '@/lib/api/api-client';
import { formatGBP } from '@/lib/utils/format';
// Analytics Types
export interface AnalyticsOverview {
@@ -293,15 +294,6 @@ export const getGrowthAnalyticsWithStore =
return getGrowthAnalytics(storeId);
};
export function formatGBP(value: number) {
return value.toLocaleString("en-GB", {
style: "currency",
currency: "GBP",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
// Prediction Types
export interface SalesPrediction {
predicted: number | null;

View File

@@ -0,0 +1,8 @@
// This file is maintained for backward compatibility
// Import from the new consolidated API
export {
getCustomers,
getCustomerDetails,
type CustomerStats,
type CustomerResponse
} from '../lib/api';

View File

@@ -1,5 +1,3 @@
// Re-export all service functionality
export * from './product-service';
export * from './shipping-service';
export * from './stats-service';
export * from '../api-client';
// Re-export all services from the lib directory
// This provides backward compatibility
export * from '../lib/api';

View File

@@ -1,4 +1,4 @@
import { clientFetch } from '../api-client';
import { clientFetch } from '@/lib/api/api-client';
// Product data types
export interface Product {

View File

@@ -1,4 +1,4 @@
import { clientFetch } from '../api-client';
import { clientFetch } from '@/lib/api/api-client';
/**
* Shipping service - Handles shipping options

View File

@@ -1,4 +1,4 @@
import { clientFetch } from '../api-client';
import { clientFetch } from '@/lib/api/api-client';
// Stats data types
export interface PlatformStats {

61
lib/types/dashboard.ts Normal file
View File

@@ -0,0 +1,61 @@
export interface RecentActivitySettings {
itemCount: number // 5, 10, 15
}
export interface TopProductsSettings {
itemCount: number // 3, 5, 10
showRevenue: boolean
}
export interface OverviewSettings {
showChange: boolean // Show % change from previous period
}
export interface RevenueChartSettings {
days: number // 7, 14, 30
showComparison: boolean
}
export interface LowStockSettings {
threshold: number // Show items with stock below this
itemCount: number
}
export interface RecentCustomersSettings {
itemCount: number
showSpent: boolean
}
export interface PendingChatsSettings {
showPreview: boolean
}
export type WidgetSettings =
| { type: "quick-actions" }
| { type: "overview"; settings: OverviewSettings }
| { type: "recent-activity"; settings: RecentActivitySettings }
| { type: "top-products"; settings: TopProductsSettings }
| { type: "revenue-chart"; settings: RevenueChartSettings }
| { type: "low-stock"; settings: LowStockSettings }
| { type: "recent-customers"; settings: RecentCustomersSettings }
| { type: "pending-chats"; settings: PendingChatsSettings }
export interface WidgetConfig {
id: string
title: string
visible: boolean
order: number
colSpan?: number
settings?: Record<string, any>
}
export interface TopProduct {
id: string;
name: string;
price: number | number[];
image: string;
count: number;
revenue: number;
unitType?: string;
currentStock?: number;
}

28
lib/utils/format.ts Normal file
View File

@@ -0,0 +1,28 @@
export const formatCurrency = (amount: number | undefined | null): string => {
if (amount === undefined || amount === null || isNaN(Number(amount))) {
return '£0.00';
}
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(Number(amount));
};
export function formatGBP(value: number | undefined | null) {
if (value === undefined || value === null || isNaN(Number(value))) {
return '£0.00';
}
return Number(value).toLocaleString('en-GB', {
style: 'currency',
currency: 'GBP',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export function formatNumber(value: number | undefined | null, options: Intl.NumberFormatOptions = {}) {
if (value === undefined || value === null || isNaN(Number(value))) {
return '0';
}
return Number(value).toLocaleString('en-GB', options);
}