This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+76
View File
@@ -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,
};
};
+174
View File
@@ -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,
};
}
+76
View File
@@ -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;
+131
View File
@@ -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;
};
+48
View File
@@ -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);
};
}
+125
View File
@@ -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;
+40
View File
@@ -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]);
}
+62
View File
@@ -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];
}
+157
View File
@@ -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 };
};
+110
View File
@@ -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;
+40
View File
@@ -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 };
}
+12
View File
@@ -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,
});
+92
View File
@@ -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,
};
};
+40
View File
@@ -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 };
}
+128
View File
@@ -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,
};
};