mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
upload
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useToast, UseToastOptions } from '@chakra-ui/react';
|
||||
|
||||
interface UseAdminPageOptions {
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
toastPosition?: UseToastOptions['position'];
|
||||
}
|
||||
|
||||
export const useAdminPage = (options: UseAdminPageOptions = {}) => {
|
||||
const {
|
||||
successMessage = 'Operation completed successfully',
|
||||
errorMessage = 'An error occurred',
|
||||
toastPosition = 'top-right',
|
||||
} = options;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const handleAsyncOperation = useCallback(
|
||||
async <T,>(
|
||||
operation: () => Promise<T>,
|
||||
customMessages?: {
|
||||
success?: string;
|
||||
error?: string;
|
||||
}
|
||||
): Promise<T | undefined> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: customMessages?.success || successMessage,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
position: toastPosition,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(errorMessage);
|
||||
setError(error);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: customMessages?.error || error.message || errorMessage,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: toastPosition,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[successMessage, errorMessage, toast, toastPosition]
|
||||
);
|
||||
|
||||
const resetError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
handleAsyncOperation,
|
||||
resetError,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
render?: (item: T) => React.ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export interface UseAdminTableOptions<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
itemsPerPage?: number;
|
||||
initialSort?: { key: keyof T; direction: 'asc' | 'desc' };
|
||||
searchKeys?: (keyof T)[];
|
||||
}
|
||||
|
||||
export function useAdminTable<T extends Record<string, any>>({
|
||||
data,
|
||||
columns,
|
||||
itemsPerPage = 10,
|
||||
initialSort,
|
||||
searchKeys = [],
|
||||
}: UseAdminTableOptions<T>) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState<keyof T | null>(initialSort?.key || null);
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(initialSort?.direction || 'asc');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||
|
||||
// Filter data by search query
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchQuery || searchKeys.length === 0) return data;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return data.filter((item) =>
|
||||
searchKeys.some((key) => {
|
||||
const value = item[key];
|
||||
return value && String(value).toLowerCase().includes(query);
|
||||
})
|
||||
);
|
||||
}, [data, searchQuery, searchKeys]);
|
||||
|
||||
// Sort data
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortKey) return filteredData;
|
||||
|
||||
return [...filteredData].sort((a, b) => {
|
||||
const aValue = a[sortKey];
|
||||
const bValue = b[sortKey];
|
||||
|
||||
if (aValue === bValue) return 0;
|
||||
|
||||
let comparison = 0;
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
comparison = aValue - bValue;
|
||||
} else if ((aValue as unknown) instanceof Date && (bValue as unknown) instanceof Date) {
|
||||
comparison = (aValue as Date).getTime() - (bValue as Date).getTime();
|
||||
} else {
|
||||
comparison = String(aValue).localeCompare(String(bValue));
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}, [filteredData, sortKey, sortDirection]);
|
||||
|
||||
// Paginate data
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
const end = start + itemsPerPage;
|
||||
return sortedData.slice(start, end);
|
||||
}, [sortedData, currentPage, itemsPerPage]);
|
||||
|
||||
// Calculate pagination info
|
||||
const totalPages = Math.ceil(sortedData.length / itemsPerPage);
|
||||
const hasNextPage = currentPage < totalPages;
|
||||
const hasPrevPage = currentPage > 1;
|
||||
|
||||
// Handle sort
|
||||
const handleSort = useCallback((key: keyof T) => {
|
||||
setSortKey((prevKey) => {
|
||||
if (prevKey === key) {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
return key;
|
||||
} else {
|
||||
setSortDirection('asc');
|
||||
return key;
|
||||
}
|
||||
});
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// Handle search
|
||||
const handleSearch = useCallback((query: string) => {
|
||||
setSearchQuery(query);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// Handle page change
|
||||
const goToPage = useCallback((page: number) => {
|
||||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||||
}, [totalPages]);
|
||||
|
||||
const nextPage = useCallback(() => {
|
||||
if (hasNextPage) setCurrentPage((prev) => prev + 1);
|
||||
}, [hasNextPage]);
|
||||
|
||||
const prevPage = useCallback(() => {
|
||||
if (hasPrevPage) setCurrentPage((prev) => prev - 1);
|
||||
}, [hasPrevPage]);
|
||||
|
||||
// Handle selection
|
||||
const toggleItem = useCallback((index: number) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((_, i) => i)));
|
||||
}
|
||||
}, [paginatedData, selectedItems]);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedItems(new Set());
|
||||
}, []);
|
||||
|
||||
const getSelectedItems = useCallback(() => {
|
||||
return Array.from(selectedItems).map((i) => paginatedData[i]);
|
||||
}, [selectedItems, paginatedData]);
|
||||
|
||||
return {
|
||||
// Data
|
||||
data: paginatedData,
|
||||
totalItems: sortedData.length,
|
||||
|
||||
// Pagination
|
||||
currentPage,
|
||||
totalPages,
|
||||
itemsPerPage,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
goToPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
|
||||
// Sorting
|
||||
sortKey,
|
||||
sortDirection,
|
||||
handleSort,
|
||||
|
||||
// Search
|
||||
searchQuery,
|
||||
handleSearch,
|
||||
|
||||
// Selection
|
||||
selectedItems,
|
||||
toggleItem,
|
||||
toggleAll,
|
||||
clearSelection,
|
||||
getSelectedItems,
|
||||
isAllSelected: selectedItems.size === paginatedData.length && paginatedData.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { usePublicSettings } from './usePublicSettings';
|
||||
import { useMemo } from 'react';
|
||||
import { PublicSettings } from '../services/settings';
|
||||
|
||||
interface ThemeColors {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
text: string;
|
||||
background: string;
|
||||
accent: string;
|
||||
[key: string]: string; // For any additional color keys
|
||||
}
|
||||
|
||||
interface ClubTheme {
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
accentColor: string;
|
||||
isDarkMode: boolean;
|
||||
logoUrl?: string;
|
||||
faviconUrl?: string;
|
||||
colors: ThemeColors;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME: ClubTheme = {
|
||||
// Modern cool palette defaults (no red/yellow)
|
||||
primaryColor: '#1e3a8a', // blue-800
|
||||
secondaryColor: '#0ea5a4', // teal-500
|
||||
textColor: '#0f172a', // slate-900
|
||||
backgroundColor: '#ffffff',
|
||||
accentColor: '#2563eb', // blue-600
|
||||
isDarkMode: false,
|
||||
colors: {
|
||||
primary: '#1e3a8a',
|
||||
secondary: '#0ea5a4',
|
||||
text: '#0f172a',
|
||||
background: '#ffffff',
|
||||
accent: '#2563eb'
|
||||
}
|
||||
};
|
||||
|
||||
export const useClubTheme = (): ClubTheme => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
|
||||
return useMemo<ClubTheme>(() => {
|
||||
if (!settings) return DEFAULT_THEME;
|
||||
|
||||
const primaryColor = settings.primary_color || DEFAULT_THEME.primaryColor;
|
||||
const secondaryColor = settings.secondary_color || DEFAULT_THEME.secondaryColor;
|
||||
const textColor = settings.text_color || DEFAULT_THEME.textColor;
|
||||
const backgroundColor = settings.background_color || DEFAULT_THEME.backgroundColor;
|
||||
const accentColor = settings.accent_color || DEFAULT_THEME.accentColor;
|
||||
|
||||
return {
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
textColor,
|
||||
backgroundColor,
|
||||
accentColor,
|
||||
isDarkMode: false, // No dark_mode in PublicSettings, default to false
|
||||
logoUrl: settings.club_logo_url,
|
||||
faviconUrl: undefined, // No favicon_url in PublicSettings
|
||||
colors: {
|
||||
primary: primaryColor,
|
||||
secondary: secondaryColor,
|
||||
text: textColor,
|
||||
background: backgroundColor,
|
||||
accent: accentColor,
|
||||
// Add any additional color mappings here
|
||||
}
|
||||
};
|
||||
}, [settings]);
|
||||
};
|
||||
|
||||
export default useClubTheme;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook for countdown functionality
|
||||
* @param targetDate - The target date/time for the countdown
|
||||
* @param updateInterval - How often to update (in milliseconds), defaults to 1000ms
|
||||
* @returns Object with countdown string and time remaining in milliseconds
|
||||
*/
|
||||
export const useCountdown = (targetDate: Date | string | null, updateInterval: number = 1000) => {
|
||||
const [timeRemaining, setTimeRemaining] = useState<number>(0);
|
||||
const [isActive, setIsActive] = useState<boolean>(false);
|
||||
|
||||
const targetTime = useMemo(() => {
|
||||
if (!targetDate) return null;
|
||||
const date = typeof targetDate === 'string' ? new Date(targetDate) : targetDate;
|
||||
return isNaN(date.getTime()) ? null : date.getTime();
|
||||
}, [targetDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetTime) {
|
||||
setTimeRemaining(0);
|
||||
setIsActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateCountdown = () => {
|
||||
const now = Date.now();
|
||||
const remaining = targetTime - now;
|
||||
|
||||
if (remaining <= 0) {
|
||||
setTimeRemaining(0);
|
||||
setIsActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeRemaining(remaining);
|
||||
setIsActive(true);
|
||||
};
|
||||
|
||||
// Initial update
|
||||
updateCountdown();
|
||||
|
||||
// Set up interval only if countdown is still active
|
||||
const interval = setInterval(() => {
|
||||
updateCountdown();
|
||||
}, updateInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [targetTime, updateInterval]);
|
||||
|
||||
const countdownString = useMemo(() => {
|
||||
if (timeRemaining <= 0) return '';
|
||||
|
||||
const days = Math.floor(timeRemaining / (24 * 60 * 60 * 1000));
|
||||
const hours = Math.floor((timeRemaining % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
|
||||
const minutes = Math.floor((timeRemaining % (60 * 60 * 1000)) / (60 * 1000));
|
||||
const seconds = Math.floor((timeRemaining % (60 * 1000)) / 1000);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}, [timeRemaining]);
|
||||
|
||||
return {
|
||||
countdownString,
|
||||
timeRemaining,
|
||||
isActive,
|
||||
targetTime
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Optimized hook for multiple countdowns
|
||||
* @param matches - Array of match objects with date and time
|
||||
* @param updateInterval - How often to update all countdowns (in milliseconds)
|
||||
* @returns Object with countdown strings for each match ID
|
||||
*/
|
||||
export const useMultipleCountdowns = (matches: Array<{ id: string | number; date: string; time: string }>, updateInterval: number = 10000) => {
|
||||
const [countdowns, setCountdowns] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!matches.length) {
|
||||
setCountdowns({});
|
||||
return;
|
||||
}
|
||||
|
||||
const updateAllCountdowns = () => {
|
||||
const now = Date.now();
|
||||
const newCountdowns: Record<string, string> = {};
|
||||
|
||||
matches.forEach((match) => {
|
||||
const targetTime = new Date(`${match.date}T${match.time || '00:00'}:00`).getTime();
|
||||
|
||||
if (isNaN(targetTime) || targetTime <= now) {
|
||||
return; // Skip past matches or invalid dates
|
||||
}
|
||||
|
||||
const diff = targetTime - now;
|
||||
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
|
||||
const hours = Math.floor((diff % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
|
||||
const minutes = Math.floor((diff % (60 * 60 * 1000)) / (60 * 1000));
|
||||
|
||||
if (days > 0) {
|
||||
newCountdowns[String(match.id)] = `${days}d ${hours}h`;
|
||||
} else if (hours > 0) {
|
||||
newCountdowns[String(match.id)] = `${hours}h ${minutes}m`;
|
||||
} else {
|
||||
newCountdowns[String(match.id)] = `${minutes}m`;
|
||||
}
|
||||
});
|
||||
|
||||
setCountdowns(newCountdowns);
|
||||
};
|
||||
|
||||
// Initial update
|
||||
updateAllCountdowns();
|
||||
|
||||
// Set up interval
|
||||
const interval = setInterval(updateAllCountdowns, updateInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [matches, updateInterval]);
|
||||
|
||||
return countdowns;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Debounces a value - useful for search inputs and API calls
|
||||
* @param value - The value to debounce
|
||||
* @param delay - Delay in milliseconds (default: 500ms)
|
||||
* @returns Debounced value
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number = 500): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced callback hook
|
||||
* @param callback - Function to debounce
|
||||
* @param delay - Delay in milliseconds
|
||||
* @returns Debounced callback function
|
||||
*/
|
||||
export function useDebouncedCallback<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number = 500
|
||||
): (...args: Parameters<T>) => void {
|
||||
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const newTimeoutId = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
|
||||
setTimeoutId(newTimeoutId);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
SearchResponse,
|
||||
ClubInfo,
|
||||
Competition,
|
||||
Match,
|
||||
} from '../services/facr/types';
|
||||
import facrApi from '../services/facr/facrApi';
|
||||
|
||||
interface UseFacrApiReturn {
|
||||
// Search for clubs by query
|
||||
searchClubs: (query: string) => Promise<SearchResponse>;
|
||||
searchResults: SearchResponse['results'] | [];
|
||||
searchLoading: boolean;
|
||||
searchError: Error | null;
|
||||
|
||||
// Get club details by ID and type
|
||||
getClub: (clubId: string, clubType?: 'football' | 'futsal') => Promise<ClubInfo>;
|
||||
|
||||
// Get club table/standings by ID and type
|
||||
getClubTable: (clubId: string, clubType?: 'football' | 'futsal') => Promise<ClubInfo>;
|
||||
|
||||
// Get all competitions for a club
|
||||
getClubCompetitions: (clubId: string, clubType?: 'football' | 'futsal') => Promise<Competition[]>;
|
||||
|
||||
// Get matches for a specific competition
|
||||
getCompetitionMatches: (competitionId: string) => Promise<Match[]>;
|
||||
|
||||
// Clear the API cache
|
||||
clearCache: () => void;
|
||||
|
||||
// Loading state
|
||||
loading: boolean;
|
||||
|
||||
// Error state
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export const useFacrApi = (): UseFacrApiReturn => {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<SearchResponse['results']>([]);
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
const [searchError, setSearchError] = useState<Error | null>(null);
|
||||
|
||||
const handleApiCall = useCallback(
|
||||
async <T>(apiCall: () => Promise<T>): Promise<T> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
return await apiCall();
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('An unknown error occurred');
|
||||
setError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const searchClubs = useCallback(
|
||||
async (query: string): Promise<SearchResponse> => {
|
||||
setSearchLoading(true);
|
||||
setSearchError(null);
|
||||
try {
|
||||
const response = await handleApiCall(() => facrApi.searchClubs(query));
|
||||
setSearchResults(response.results || []);
|
||||
return response;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Failed to search clubs');
|
||||
setSearchError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
},
|
||||
[handleApiCall]
|
||||
);
|
||||
|
||||
const getClub = useCallback(
|
||||
(clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<ClubInfo> =>
|
||||
handleApiCall(() => facrApi.getClub(clubId, clubType)),
|
||||
[handleApiCall]
|
||||
);
|
||||
|
||||
const getClubTable = useCallback(
|
||||
(clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<ClubInfo> =>
|
||||
handleApiCall(() => facrApi.getClubTable(clubId, clubType)),
|
||||
[handleApiCall]
|
||||
);
|
||||
|
||||
const getClubCompetitions = useCallback(
|
||||
(clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<Competition[]> =>
|
||||
handleApiCall(() => facrApi.getClubCompetitions(clubId, clubType)),
|
||||
[handleApiCall]
|
||||
);
|
||||
|
||||
const getCompetitionMatches = useCallback(
|
||||
(competitionId: string): Promise<Match[]> =>
|
||||
handleApiCall(() => facrApi.getCompetitionMatches(competitionId)),
|
||||
[handleApiCall]
|
||||
);
|
||||
|
||||
const clearCache = useCallback(() => {
|
||||
facrApi.clearCache();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
searchClubs,
|
||||
searchResults,
|
||||
searchLoading,
|
||||
searchError,
|
||||
getClub,
|
||||
getClubTable,
|
||||
getClubCompetitions,
|
||||
getCompetitionMatches,
|
||||
clearCache,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFacrApi;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect } from 'react';
|
||||
import { usePublicSettings } from './usePublicSettings';
|
||||
import { getFontPairing, applyFontPairing, getDefaultFontPairing } from '../config/fonts';
|
||||
|
||||
/**
|
||||
* Hook to load and apply club fonts from settings
|
||||
*/
|
||||
export const useFontLoader = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
|
||||
useEffect(() => {
|
||||
// Determine which font pairing to use
|
||||
const fontId = settings?.font_heading || settings?.font_body;
|
||||
|
||||
let pairing;
|
||||
if (fontId) {
|
||||
// Try to find matching pairing by heading or body font name
|
||||
pairing = getFontPairing(fontId);
|
||||
|
||||
// If not found by ID, try to find by font name
|
||||
if (!pairing) {
|
||||
const allPairings = require('../config/fonts').FONT_PAIRINGS;
|
||||
pairing = allPairings.find((p: any) =>
|
||||
p.heading === fontId ||
|
||||
p.body === fontId ||
|
||||
p.id === fontId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default
|
||||
if (!pairing) {
|
||||
pairing = getDefaultFontPairing();
|
||||
}
|
||||
|
||||
// Apply the font pairing
|
||||
applyFontPairing(pairing);
|
||||
|
||||
}, [settings?.font_heading, settings?.font_body]);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface UseIntersectionObserverOptions extends IntersectionObserverInit {
|
||||
freezeOnceVisible?: boolean;
|
||||
}
|
||||
|
||||
export function useIntersectionObserver(
|
||||
options: UseIntersectionObserverOptions = {}
|
||||
): [React.RefObject<HTMLDivElement>, boolean] {
|
||||
const { threshold = 0.1, root = null, rootMargin = '0px', freezeOnceVisible = false } = options;
|
||||
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [hasBeenVisible, setHasBeenVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
// If already visible and frozen, don't observe again
|
||||
if (freezeOnceVisible && hasBeenVisible) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
const visible = entry.isIntersecting;
|
||||
setIsVisible(visible);
|
||||
|
||||
if (visible) {
|
||||
setHasBeenVisible(true);
|
||||
if (freezeOnceVisible) {
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold, root, rootMargin }
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [threshold, root, rootMargin, freezeOnceVisible, hasBeenVisible]);
|
||||
|
||||
return [elementRef, freezeOnceVisible ? hasBeenVisible : isVisible];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface KeyboardShortcut {
|
||||
key: string;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
callback: () => void;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[], enabled: boolean = true) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const shortcut = shortcuts.find(
|
||||
(s) =>
|
||||
s.key.toLowerCase() === event.key.toLowerCase() &&
|
||||
(s.ctrlKey === undefined || s.ctrlKey === event.ctrlKey) &&
|
||||
(s.shiftKey === undefined || s.shiftKey === event.shiftKey) &&
|
||||
(s.altKey === undefined || s.altKey === event.altKey)
|
||||
);
|
||||
|
||||
if (shortcut) {
|
||||
event.preventDefault();
|
||||
shortcut.callback();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [shortcuts, enabled]);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Hook for persisting state in localStorage
|
||||
* @param key - localStorage key
|
||||
* @param initialValue - initial value if key doesn't exist
|
||||
*/
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void, () => void] {
|
||||
// Get initial value from localStorage or use initialValue
|
||||
const readValue = useCallback((): T => {
|
||||
if (typeof window === 'undefined') {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
}, [initialValue, key]);
|
||||
|
||||
const [storedValue, setStoredValue] = useState<T>(readValue);
|
||||
|
||||
// Return a wrapped version of useState's setter function that persists to localStorage
|
||||
const setValue = useCallback((value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
// Allow value to be a function so we have same API as useState
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
|
||||
// Save state
|
||||
setStoredValue(valueToStore);
|
||||
|
||||
// Save to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
}, [key, storedValue]);
|
||||
|
||||
// Remove value from localStorage
|
||||
const remove = useCallback(() => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(key);
|
||||
setStoredValue(initialValue);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error removing localStorage key "${key}":`, error);
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
// Re-read from storage when key changes
|
||||
useEffect(() => {
|
||||
setStoredValue(readValue());
|
||||
}, [key, readValue]);
|
||||
|
||||
return [storedValue, setValue, remove];
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageElementConfig, getPageElementConfigs } from '../services/pageElements';
|
||||
|
||||
export const usePageElementConfig = (pageType: string, elementName: string, defaultVariant: string = 'unified') => {
|
||||
const [variant, setVariant] = useState<string>(defaultVariant);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const configs = await getPageElementConfigs(pageType);
|
||||
if (active) {
|
||||
const config = configs.find(c => c.element_name === elementName);
|
||||
if (config) {
|
||||
setVariant(config.variant);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load config for ${elementName}:`, error);
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [pageType, elementName, defaultVariant]);
|
||||
|
||||
return { variant, loading };
|
||||
};
|
||||
|
||||
export const useAllPageElementConfigs = (pageType: string) => {
|
||||
const [configs, setConfigs] = useState<Record<string, string>>({});
|
||||
const [visibility, setVisibility] = useState<Record<string, boolean>>({});
|
||||
const [styles, setStyles] = useState<Record<string, Record<string, any>>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const data = await getPageElementConfigs(pageType);
|
||||
if (active) {
|
||||
const configMap: Record<string, string> = {};
|
||||
const visMap: Record<string, boolean> = {};
|
||||
|
||||
data.forEach(config => {
|
||||
configMap[config.element_name] = config.variant;
|
||||
visMap[config.element_name] = config.visible !== false;
|
||||
});
|
||||
|
||||
setConfigs(configMap);
|
||||
setVisibility(visMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load page element configs:', error);
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadConfigs();
|
||||
|
||||
// Listen for live updates from MyUIbrix editor (ONLY in preview mode)
|
||||
const handleMyUIbrixChange = ((event: CustomEvent) => {
|
||||
const { elementName, variant, visible, previewMode } = event.detail;
|
||||
|
||||
// Only apply changes if in preview mode (editing)
|
||||
// This prevents production users from seeing draft changes
|
||||
if (previewMode) {
|
||||
setConfigs(prev => ({
|
||||
...prev,
|
||||
[elementName]: variant
|
||||
}));
|
||||
|
||||
setVisibility(prev => ({
|
||||
...prev,
|
||||
[elementName]: visible
|
||||
}));
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
// Listen for reorder events
|
||||
const handleMyUIbrixReorder = ((event: CustomEvent) => {
|
||||
const { order } = event.detail;
|
||||
// Trigger re-render with new order
|
||||
// The actual reordering happens in the parent component
|
||||
window.dispatchEvent(new CustomEvent('myuibrix-order-changed', {
|
||||
detail: { order }
|
||||
}));
|
||||
}) as EventListener;
|
||||
|
||||
// Listen for style changes from VisualStylePanel
|
||||
const handleMyUIbrixStyleChange = ((event: CustomEvent) => {
|
||||
const { elementName, styles: newStyles, previewMode } = event.detail;
|
||||
|
||||
if (previewMode) {
|
||||
setStyles(prev => ({
|
||||
...prev,
|
||||
[elementName]: newStyles
|
||||
}));
|
||||
|
||||
// Apply styles to DOM element immediately
|
||||
const element = document.querySelector(`[data-element="${elementName}"]`) as HTMLElement;
|
||||
if (element) {
|
||||
// Convert style object to CSS
|
||||
Object.keys(newStyles).forEach(key => {
|
||||
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
let value = newStyles[key];
|
||||
|
||||
// Handle numeric values that need units
|
||||
if (typeof value === 'number' && !['fontWeight', 'lineHeight', 'opacity', 'zIndex'].includes(key)) {
|
||||
value = `${value}px`;
|
||||
}
|
||||
|
||||
element.style.setProperty(cssKey, String(value));
|
||||
});
|
||||
}
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener('myuibrix-change', handleMyUIbrixChange);
|
||||
window.addEventListener('myuibrix-reorder', handleMyUIbrixReorder);
|
||||
window.addEventListener('myuibrix-style-change', handleMyUIbrixStyleChange);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener('myuibrix-change', handleMyUIbrixChange);
|
||||
window.removeEventListener('myuibrix-reorder', handleMyUIbrixReorder);
|
||||
window.removeEventListener('myuibrix-style-change', handleMyUIbrixStyleChange);
|
||||
};
|
||||
}, [pageType]);
|
||||
|
||||
const getVariant = (elementName: string, defaultVariant: string = 'unified'): string => {
|
||||
return configs[elementName] || defaultVariant;
|
||||
};
|
||||
|
||||
const isVisible = (elementName: string, defaultVisible: boolean = true): boolean => {
|
||||
return visibility[elementName] !== undefined ? visibility[elementName] : defaultVisible;
|
||||
};
|
||||
|
||||
const getStyles = (elementName: string): Record<string, any> | undefined => {
|
||||
return styles[elementName];
|
||||
};
|
||||
|
||||
return { configs, visibility, styles, getVariant, isVisible, getStyles, loading };
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
|
||||
/**
|
||||
* Hook to track page views and user engagement
|
||||
* Automatically tracks when component mounts and route changes
|
||||
*/
|
||||
export const usePageTracking = (pageName?: string, metadata?: Record<string, any>) => {
|
||||
const location = useLocation();
|
||||
const hasTracked = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Avoid double-tracking in development (React StrictMode)
|
||||
if (hasTracked.current) return;
|
||||
hasTracked.current = true;
|
||||
|
||||
const trackPageView = async () => {
|
||||
try {
|
||||
const path = location.pathname;
|
||||
const page = pageName || path;
|
||||
|
||||
// Get session info from localStorage (or generate)
|
||||
let sessionId = localStorage.getItem('session_id');
|
||||
if (!sessionId) {
|
||||
sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
localStorage.setItem('session_id', sessionId);
|
||||
}
|
||||
|
||||
// Track the view
|
||||
await api.post('/analytics/track', {
|
||||
event_type: 'page_view',
|
||||
page_path: path,
|
||||
page_name: page,
|
||||
session_id: sessionId,
|
||||
referrer: document.referrer || null,
|
||||
user_agent: navigator.userAgent,
|
||||
screen_resolution: `${window.screen.width}x${window.screen.height}`,
|
||||
viewport_size: `${window.innerWidth}x${window.innerHeight}`,
|
||||
metadata: metadata || {},
|
||||
});
|
||||
} catch (error) {
|
||||
// Silent fail - don't break user experience if tracking fails
|
||||
console.debug('Analytics tracking failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
trackPageView();
|
||||
|
||||
// Reset tracking flag when location changes
|
||||
return () => {
|
||||
hasTracked.current = false;
|
||||
};
|
||||
}, [location.pathname, pageName, metadata]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to track article reads with time spent
|
||||
*/
|
||||
export const useArticleTracking = (articleId: number | string | undefined, articleTitle?: string) => {
|
||||
const startTime = useRef(Date.now());
|
||||
const hasTracked = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!articleId || hasTracked.current) return;
|
||||
hasTracked.current = true;
|
||||
|
||||
const trackArticleView = async () => {
|
||||
try {
|
||||
await api.post(`/articles/${articleId}/read`, {
|
||||
title: articleTitle,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.debug('Article tracking failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
trackArticleView();
|
||||
|
||||
// Track time spent when user leaves
|
||||
return () => {
|
||||
const timeSpent = Math.floor((Date.now() - startTime.current) / 1000);
|
||||
if (timeSpent > 5) { // Only track if spent more than 5 seconds
|
||||
api.post('/analytics/track', {
|
||||
event_type: 'article_read_time',
|
||||
article_id: articleId,
|
||||
time_spent: timeSpent,
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [articleId, articleTitle]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Track custom events (clicks, downloads, etc.)
|
||||
*/
|
||||
export const trackEvent = async (eventType: string, eventData?: Record<string, any>) => {
|
||||
try {
|
||||
await api.post('/analytics/track', {
|
||||
event_type: eventType,
|
||||
...eventData,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.debug('Event tracking failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export default usePageTracking;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Hook for prefetching resources on hover or focus
|
||||
* Improves perceived performance by loading data before user clicks
|
||||
*/
|
||||
export function usePrefetch() {
|
||||
const prefetchedUrls = new Set<string>();
|
||||
|
||||
const prefetch = useCallback((url: string, type: 'image' | 'page' | 'data' = 'page') => {
|
||||
// Don't prefetch the same URL twice
|
||||
if (prefetchedUrls.has(url)) return;
|
||||
prefetchedUrls.add(url);
|
||||
|
||||
if (type === 'image') {
|
||||
// Prefetch image
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
} else if (type === 'page') {
|
||||
// Prefetch page using link rel="prefetch"
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.href = url;
|
||||
link.as = 'document';
|
||||
document.head.appendChild(link);
|
||||
} else if (type === 'data') {
|
||||
// Prefetch data
|
||||
fetch(url, { method: 'GET', credentials: 'same-origin' }).catch(() => {
|
||||
// Silently fail - it's just a prefetch
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createPrefetchHandlers = useCallback((url: string, type: 'image' | 'page' | 'data' = 'page') => ({
|
||||
onMouseEnter: () => prefetch(url, type),
|
||||
onFocus: () => prefetch(url, type),
|
||||
}), [prefetch]);
|
||||
|
||||
return { prefetch, createPrefetchHandlers };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPublicSettings, PublicSettings } from '../services/settings';
|
||||
|
||||
export const usePublicSettings = () =>
|
||||
useQuery<PublicSettings>({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: getPublicSettings,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes - settings don't change often
|
||||
cacheTime: 30 * 60 * 1000, // 30 minutes - keep in cache longer
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Settings } from '../types';
|
||||
import { api } from '../services/api';
|
||||
|
||||
const fetchSettings = async (): Promise<Settings> => {
|
||||
const { data } = await api.get<Settings>('/settings');
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateSettings = async (settings: Partial<Settings>): Promise<Settings> => {
|
||||
const { data } = await api.put<Settings>('/settings', settings);
|
||||
return data;
|
||||
};
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
id: 'default',
|
||||
clubId: '',
|
||||
clubType: '',
|
||||
primaryColor: '#3182ce', // Direct access to primary color
|
||||
theme: {
|
||||
primaryColor: '#3182ce',
|
||||
secondaryColor: '#2b6cb0',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
},
|
||||
socialLinks: {},
|
||||
contactInfo: {
|
||||
address: 'Ulice a číslo, Město, PSČ',
|
||||
phone: '+420 123 456 789',
|
||||
email: 'info@vas-klub.cz',
|
||||
website: 'www.vas-klub.cz',
|
||||
workingHours: 'Pondělí - Pátek: 9:00 - 17:00',
|
||||
},
|
||||
features: {
|
||||
darkMode: false,
|
||||
notifications: true,
|
||||
analytics: false,
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export const useSettings = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings = defaultSettings, isLoading } = useQuery<Settings>({
|
||||
queryKey: ['settings'],
|
||||
queryFn: fetchSettings,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
onError: (error) => {
|
||||
console.error('Failed to load settings:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const updateSettingsMutation = useMutation({
|
||||
mutationFn: updateSettings,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData<Settings>(['settings'], (old) => ({
|
||||
...defaultSettings,
|
||||
...old,
|
||||
...data,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const updateTheme = (theme: Settings['theme']) => {
|
||||
updateSettingsMutation.mutate({
|
||||
theme: { ...settings.theme, ...theme },
|
||||
});
|
||||
};
|
||||
|
||||
const updateSocialLinks = (socialLinks: Settings['socialLinks']) => {
|
||||
updateSettingsMutation.mutate({
|
||||
socialLinks: { ...settings.socialLinks, ...socialLinks },
|
||||
});
|
||||
};
|
||||
|
||||
const updateFeatures = (features: Partial<Settings['features']>) => {
|
||||
updateSettingsMutation.mutate({
|
||||
features: { ...settings.features, ...features },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
isLoading,
|
||||
updateTheme,
|
||||
updateSocialLinks,
|
||||
updateFeatures,
|
||||
updateSettings: updateSettingsMutation.mutateAsync,
|
||||
isUpdating: updateSettingsMutation.isLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
interface SmoothScrollOptions {
|
||||
offset?: number;
|
||||
duration?: number;
|
||||
behavior?: ScrollBehavior;
|
||||
}
|
||||
|
||||
export function useSmoothScroll() {
|
||||
const scrollToElement = useCallback((
|
||||
elementOrSelector: HTMLElement | string,
|
||||
options: SmoothScrollOptions = {}
|
||||
) => {
|
||||
const { offset = 0, behavior = 'smooth' } = options;
|
||||
|
||||
const element = typeof elementOrSelector === 'string'
|
||||
? document.querySelector(elementOrSelector) as HTMLElement
|
||||
: elementOrSelector;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const scrollToTop = useCallback((options: SmoothScrollOptions = {}) => {
|
||||
const { behavior = 'smooth' } = options;
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { scrollToElement, scrollToTop };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../services/api';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { trackEvent as trackAnalyticsEvent } from '../services/analyticsService';
|
||||
|
||||
interface UmamiConfig {
|
||||
enabled: boolean;
|
||||
website_id: string;
|
||||
script_url: string;
|
||||
}
|
||||
|
||||
// Extend window object to include umami
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (eventName: string, eventData?: Record<string, any>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const useUmami = () => {
|
||||
const [config, setConfig] = useState<UmamiConfig | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Helper to check if current path is an admin route or system page
|
||||
const isAdminRoute = (pathname: string) => {
|
||||
return pathname.startsWith('/admin') ||
|
||||
pathname === '/login' ||
|
||||
pathname === '/setup';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Don't load Umami for admin pages
|
||||
if (isAdminRoute(location.pathname)) {
|
||||
console.log('Umami tracking disabled for admin pages');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch Umami configuration from backend
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/umami/config`);
|
||||
const umamiConfig = response.data as UmamiConfig;
|
||||
setConfig(umamiConfig);
|
||||
|
||||
// If enabled and not already loaded, inject the script
|
||||
if (umamiConfig.enabled && umamiConfig.website_id && !isLoaded) {
|
||||
loadUmamiScript(umamiConfig.script_url, umamiConfig.website_id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load Umami config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchConfig();
|
||||
}, [location.pathname]);
|
||||
|
||||
// Track page views when location changes (skip admin routes for Umami)
|
||||
useEffect(() => {
|
||||
if (location.pathname && !isAdminRoute(location.pathname)) {
|
||||
// Track with our analytics backend (public pages only)
|
||||
trackAnalyticsEvent({
|
||||
event_type: 'page_view',
|
||||
page_path: location.pathname,
|
||||
page_name: document.title,
|
||||
}).catch(err => {
|
||||
console.debug('Analytics tracking failed:', err);
|
||||
});
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const loadUmamiScript = (scriptUrl: string, websiteId: string) => {
|
||||
// Check if script already exists
|
||||
const existingScript = document.querySelector(`script[data-website-id="${websiteId}"]`);
|
||||
if (existingScript) {
|
||||
setIsLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and inject the script
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.src = scriptUrl;
|
||||
script.setAttribute('data-website-id', websiteId);
|
||||
|
||||
script.onload = () => {
|
||||
console.log('Umami tracking loaded');
|
||||
setIsLoaded(true);
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
console.error('Failed to load Umami script');
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
|
||||
// Track custom events (skip admin routes)
|
||||
const trackEvent = (eventName: string, eventData?: Record<string, any>) => {
|
||||
// Skip tracking for admin routes
|
||||
if (isAdminRoute(location.pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track with Umami if available
|
||||
if (window.umami && isLoaded) {
|
||||
window.umami.track(eventName, eventData);
|
||||
}
|
||||
|
||||
// Also track with our backend analytics
|
||||
trackAnalyticsEvent({
|
||||
event_type: eventName,
|
||||
page: location.pathname,
|
||||
data: eventData,
|
||||
}).catch(err => {
|
||||
console.debug('Backend tracking failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isEnabled: config?.enabled || false,
|
||||
isLoaded,
|
||||
trackEvent,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user