diff --git a/frontend/src/components/GlobalThemeProvider.tsx b/frontend/src/components/GlobalThemeProvider.tsx index d3fb8b3a..ee32207e 100644 --- a/frontend/src/components/GlobalThemeProvider.tsx +++ b/frontend/src/components/GlobalThemeProvider.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useRef } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { useTheme } from '../contexts/ThemeContext'; -import { api } from '../config/api'; +import { usePublicSettings } from '../hooks/usePublicSettings'; interface GlobalThemeProviderProps { children: React.ReactNode; @@ -10,22 +9,13 @@ interface GlobalThemeProviderProps { export const GlobalThemeProvider: React.FC = ({ children }) => { const { setTheme } = useTheme(); const themeAppliedRef = useRef(false); - - // Fetch public settings including theme config - const { data: settingsData } = useQuery({ - queryKey: ['global-theme-settings'], - queryFn: async () => { - const response = await api.get('/public/settings'); - return response.data; - }, - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - }); + const { data: settingsData } = usePublicSettings(); // Apply global theme when settings are loaded (but not on gallery pages) useEffect(() => { // Skip if we're on a gallery page - gallery pages handle their own themes const isGalleryPage = window.location.pathname.includes('/gallery/'); - + if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) { themeAppliedRef.current = true; setTheme(settingsData.theme_config); diff --git a/frontend/src/components/MaintenanceMode.tsx b/frontend/src/components/MaintenanceMode.tsx index 68bab84c..09a50978 100644 --- a/frontend/src/components/MaintenanceMode.tsx +++ b/frontend/src/components/MaintenanceMode.tsx @@ -1,38 +1,13 @@ import React, { useEffect } from 'react'; import { AlertTriangle } from 'lucide-react'; -import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { api } from '../config/api'; +import { usePublicSettings } from '../hooks/usePublicSettings'; import { buildResourceUrl } from '../utils/url'; -interface BrandingSettings { - branding_company_name?: string; - branding_company_tagline?: string; - branding_support_email?: string; - branding_footer_text?: string; - branding_favicon_url?: string; - branding_logo_url?: string; - default_language?: string; -} - export const MaintenanceMode: React.FC = () => { const { t, i18n } = useTranslation(); - - // Fetch branding settings - const { data: settings } = useQuery({ - queryKey: ['public-settings-maintenance'], - queryFn: async () => { - try { - const response = await api.get('/public/settings'); - return response.data; - } catch { - // Return empty object if settings can't be fetched - return {}; - } - }, - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - retry: false, // Don't retry on failure - }); + + const { data: settings } = usePublicSettings({ retry: false }); // Set language based on system settings useEffect(() => { diff --git a/frontend/src/components/MaintenanceWrapper.tsx b/frontend/src/components/MaintenanceWrapper.tsx index 0356a12f..ccd3e159 100644 --- a/frontend/src/components/MaintenanceWrapper.tsx +++ b/frontend/src/components/MaintenanceWrapper.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; import { MaintenanceMode } from './MaintenanceMode'; import { useMaintenanceMode } from '../contexts/MaintenanceContext'; import { setMaintenanceModeCallback, api } from '../config/api'; @@ -9,12 +8,16 @@ interface MaintenanceWrapperProps { children: React.ReactNode; } +// Maintenance detection now lives in two places: +// 1. The axios interceptor in config/api.ts flips the flag on any 503 response. +// 2. MaintenanceContext polls /public/settings every 30s and reads the explicit +// maintenance_mode field (via the shared usePublicSettings hook). +// This wrapper only needs to gate the rendered tree on the resulting state. export const MaintenanceWrapper: React.FC = ({ children }) => { const location = useLocation(); const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode(); const [hasAdminSession, setHasAdminSession] = useState(false); - - // Check if current route is admin route + const isAdminRoute = location.pathname.startsWith('/admin'); useEffect(() => { @@ -45,40 +48,12 @@ export const MaintenanceWrapper: React.FC = ({ children }; }, [isAdminRoute]); - // Register the maintenance mode callback useEffect(() => { setMaintenanceModeCallback((enabled: boolean) => { setMaintenanceMode(enabled); }); }, [setMaintenanceMode]); - // Check maintenance mode on mount and when location changes - useQuery({ - queryKey: ['maintenance-check', location.pathname], - queryFn: async () => { - try { - // Make a lightweight request to check maintenance status - await api.get('/public/settings'); - // If successful, maintenance mode is off - setMaintenanceMode(false); - return { maintenance: false }; - } catch (error: any) { - if (error.response?.status === 503) { - // Only set maintenance mode for non-admin routes or unauthenticated admin routes - if (!isAdminRoute || !hasAdminSession) { - setMaintenanceMode(true); - return { maintenance: true }; - } - } - return { maintenance: false }; - } - }, - staleTime: 30000, // Check every 30 seconds - retry: false, // Don't retry on failure - enabled: (!isAdminRoute || !hasAdminSession) && !isMaintenanceMode, // Don't check if already in maintenance - }); - - // Show maintenance page if in maintenance mode and not on admin route with auth if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) { return ; } diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index 0aff97e8..10b3c9f7 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -9,11 +9,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useAdminAuth } from '../../contexts'; import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext'; import { useOnClickOutside } from '../../hooks/useOnClickOutside'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; import { PasswordChangeModal } from './PasswordChangeModal'; import { LanguageSelector } from '../common'; import { notificationsService } from '../../services/notifications.service'; import { toast } from 'react-toastify'; -import { buildResourceUrl, getApiBaseUrl } from '../../utils/url'; +import { buildResourceUrl } from '../../utils/url'; interface AdminHeaderProps { onMenuClick: () => void; @@ -31,16 +32,7 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { const [showPasswordModal, setShowPasswordModal] = useState(false); const queryClient = useQueryClient(); - // Fetch branding settings - const { data: brandingSettings } = useQuery({ - queryKey: ['admin-settings', 'branding'], - queryFn: async () => { - const response = await fetch(`${getApiBaseUrl()}/public/settings`); - if (response.ok) return response.json(); - return null; - }, - staleTime: 5 * 60 * 1000, - }); + const { data: brandingSettings } = usePublicSettings(); const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak'; const logoUrl = brandingSettings?.branding_logo_url?.trim(); diff --git a/frontend/src/components/common/CMSContentBlock.tsx b/frontend/src/components/common/CMSContentBlock.tsx index ee93cf3c..4bba4d38 100644 --- a/frontend/src/components/common/CMSContentBlock.tsx +++ b/frontend/src/components/common/CMSContentBlock.tsx @@ -6,7 +6,7 @@ import DOMPurify from 'dompurify'; import { Card } from './Card'; import { Loading } from './Loading'; import { cmsService } from '../../services/cms.service'; -import { api } from '../../config/api'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; import { buildResourceUrl } from '../../utils/url'; import '../../styles/prose-overrides.css'; @@ -33,14 +33,7 @@ const ALLOWED_ATTR = ['href', 'target', 'rel', 'class', 'style', 'src', 'alt', ' export const CMSContentBlock: React.FC = ({ slug, fallback }) => { const { i18n } = useTranslation(); - const { data: settings } = useQuery({ - queryKey: ['public-settings'], - queryFn: async () => { - const response = await api.get('/public/settings'); - return response.data; - }, - staleTime: 5 * 60 * 1000, - }); + const { data: settings } = usePublicSettings(); const lang = settings?.default_language || i18n.language || 'en'; diff --git a/frontend/src/components/common/DynamicFavicon.tsx b/frontend/src/components/common/DynamicFavicon.tsx index ac4ebfaf..d8226eab 100644 --- a/frontend/src/components/common/DynamicFavicon.tsx +++ b/frontend/src/components/common/DynamicFavicon.tsx @@ -1,25 +1,11 @@ import { useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { getApiBaseUrl, buildResourceUrl } from '../../utils/url'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; +import { buildResourceUrl } from '../../utils/url'; const DEFAULT_TITLE = 'PicPeak - Photo Sharing Platform'; export const DynamicFavicon: React.FC = () => { - const { data: settings } = useQuery({ - queryKey: ['public-settings'], - queryFn: async () => { - try { - const response = await fetch(`${getApiBaseUrl()}/public/settings`); - if (response.ok) { - return response.json(); - } - return null; - } catch { - return null; - } - }, - staleTime: 5 * 60 * 1000, // 5 minutes - }); + const { data: settings } = usePublicSettings({ retry: false }); // Update favicon when branding settings change useEffect(() => { @@ -82,4 +68,4 @@ export const DynamicFavicon: React.FC = () => { }, [settings?.branding_company_name, settings?.branding_company_tagline]); return null; -}; \ No newline at end of file +}; diff --git a/frontend/src/components/common/ReCaptcha.tsx b/frontend/src/components/common/ReCaptcha.tsx index f65ea482..9aa21039 100644 --- a/frontend/src/components/common/ReCaptcha.tsx +++ b/frontend/src/components/common/ReCaptcha.tsx @@ -1,7 +1,6 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import ReCAPTCHA from 'react-google-recaptcha'; -import { useQuery } from '@tanstack/react-query'; -import { getApiBaseUrl } from '../../utils/url'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; interface ReCaptchaProps { onChange: (token: string | null) => void; @@ -9,31 +8,16 @@ interface ReCaptchaProps { size?: 'normal' | 'compact'; } -export const ReCaptcha: React.FC = ({ - onChange, +export const ReCaptcha: React.FC = ({ + onChange, onExpired, - size = 'normal' + size = 'normal' }) => { const recaptchaRef = React.useRef(null); - const [siteKey, setSiteKey] = useState(''); + const { data: settings } = usePublicSettings(); - // Fetch public settings to get reCAPTCHA site key - const { data: settings } = useQuery({ - queryKey: ['public-settings'], - queryFn: async () => { - const response = await fetch(`${getApiBaseUrl()}/public/settings`); - return response.json(); - }, - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - }); + const siteKey = settings?.recaptcha_site_key ?? ''; - useEffect(() => { - if (settings?.recaptcha_site_key) { - setSiteKey(settings.recaptcha_site_key); - } - }, [settings]); - - // If reCAPTCHA is not enabled or site key is not available, return null if (!settings?.enable_recaptcha || !siteKey) { return null; } @@ -52,4 +36,4 @@ export const ReCaptcha: React.FC = ({ ); }; -export default ReCaptcha; \ No newline at end of file +export default ReCaptcha; diff --git a/frontend/src/components/common/RobotsMetaTags.tsx b/frontend/src/components/common/RobotsMetaTags.tsx index e9975037..b7c1f3bf 100644 --- a/frontend/src/components/common/RobotsMetaTags.tsx +++ b/frontend/src/components/common/RobotsMetaTags.tsx @@ -1,23 +1,8 @@ import { useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { getApiBaseUrl } from '../../utils/url'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; export const RobotsMetaTags: React.FC = () => { - const { data: settings } = useQuery({ - queryKey: ['public-settings'], - queryFn: async () => { - try { - const response = await fetch(`${getApiBaseUrl()}/public/settings`); - if (response.ok) { - return response.json(); - } - return null; - } catch { - return null; - } - }, - staleTime: 5 * 60 * 1000, - }); + const { data: settings } = usePublicSettings({ retry: false }); useEffect(() => { // Remove any existing robots meta tags we previously injected diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index f51afb58..f0264389 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -25,6 +25,7 @@ import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react'; import { galleryService } from '../../services/gallery.service'; import { useWatermarkSettings } from '../../hooks/useWatermarkSettings'; import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; import type { Photo } from '../../types'; import { GALLERY_THEME_PRESETS } from '../../types/theme.types'; import { useQueryClient } from '@tanstack/react-query'; @@ -199,15 +200,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { return () => window.removeEventListener('resize', handleResize); }, []); - // Fetch branding settings - const { data: settingsData } = useQuery({ - queryKey: ['gallery-settings'], - queryFn: async () => { - const response = await api.get('/public/settings'); - return response.data; - }, - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - }); + const { data: settingsData } = usePublicSettings(); // Fetch feedback settings const { data: feedbackSettings } = useQuery({ diff --git a/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx index 1a7f637c..fa45e2ff 100644 --- a/frontend/src/components/gallery/UserPhotoUpload.tsx +++ b/frontend/src/components/gallery/UserPhotoUpload.tsx @@ -1,11 +1,10 @@ import React, { useState, useMemo } from 'react'; import { Upload, X, CheckCircle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { useQuery } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import { Button } from '../common'; import { api } from '../../config/api'; -import { publicSettingsService } from '../../services/publicSettings.service'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes'; interface UserPhotoUploadProps { @@ -26,11 +25,7 @@ export const UserPhotoUpload: React.FC = ({ const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({}); - const { data: publicSettings } = useQuery({ - queryKey: ['public-settings'], - queryFn: () => publicSettingsService.getPublicSettings(), - staleTime: 5 * 60 * 1000, - }); + const { data: publicSettings } = usePublicSettings(); const allowedMimeTypes = useMemo( () => extensionsToMimeTypes(publicSettings?.allowed_file_types), diff --git a/frontend/src/contexts/MaintenanceContext.tsx b/frontend/src/contexts/MaintenanceContext.tsx index cba3babf..50552c23 100644 --- a/frontend/src/contexts/MaintenanceContext.tsx +++ b/frontend/src/contexts/MaintenanceContext.tsx @@ -1,7 +1,6 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { setMaintenanceModeCallback } from '../config/api'; -import { getApiBaseUrl } from '../utils/url'; +import { usePublicSettings } from '../hooks/usePublicSettings'; interface MaintenanceContextType { isMaintenanceMode: boolean; @@ -25,34 +24,18 @@ interface MaintenanceProviderProps { export const MaintenanceProvider: React.FC = ({ children }) => { const [isMaintenanceMode, setIsMaintenanceMode] = useState(false); - // Check maintenance mode status on mount - const { data: settings } = useQuery({ - queryKey: ['public-settings-maintenance'], - queryFn: async () => { - try { - const response = await fetch(`${getApiBaseUrl()}/public/settings`); - if (response.status === 503) { - setIsMaintenanceMode(true); - return null; - } - return response.json(); - } catch (error) { - // If we can't reach the server, don't assume maintenance mode - return null; - } - }, - staleTime: 30 * 1000, // Check every 30 seconds - refetchInterval: 30 * 1000, - }); + // Polls /public/settings every 30s so a maintenance flag flipped server-side propagates + // without a refresh. 503 responses are caught by the axios interceptor in config/api.ts + // (which calls setMaintenanceModeCallback below), so we only need to read the explicit + // maintenance_mode flag here. + const { data: settings } = usePublicSettings({ refetchInterval: 30_000 }); - // Update maintenance mode based on settings useEffect(() => { if (settings?.maintenance_mode !== undefined) { setIsMaintenanceMode(settings.maintenance_mode); } }, [settings]); - // Set up the callback for API interceptor useEffect(() => { setMaintenanceModeCallback((enabled: boolean) => { setIsMaintenanceMode(enabled); diff --git a/frontend/src/hooks/__tests__/usePublicSettings.test.tsx b/frontend/src/hooks/__tests__/usePublicSettings.test.tsx new file mode 100644 index 00000000..d2371019 --- /dev/null +++ b/frontend/src/hooks/__tests__/usePublicSettings.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +import { usePublicSettings } from '../usePublicSettings'; +import { publicSettingsService } from '../../services/publicSettings.service'; + +vi.mock('../../services/publicSettings.service', () => ({ + publicSettingsService: { + getPublicSettings: vi.fn(), + }, +})); + +const getPublicSettingsMock = vi.mocked(publicSettingsService.getPublicSettings); + +function makeWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { client, Wrapper }; +} + +describe('usePublicSettings', () => { + beforeEach(() => { + getPublicSettingsMock.mockReset(); + }); + + it('returns settings from the public settings service', async () => { + getPublicSettingsMock.mockResolvedValue({ + branding_company_name: 'PicPeak Test', + maintenance_mode: false, + } as Awaited>); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => usePublicSettings(), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.branding_company_name).toBe('PicPeak Test'); + expect(getPublicSettingsMock).toHaveBeenCalledTimes(1); + }); + + it('dedupes parallel callers in the same QueryClient', async () => { + getPublicSettingsMock.mockResolvedValue({ + branding_company_name: 'PicPeak Test', + } as Awaited>); + + const { Wrapper } = makeWrapper(); + const { result: first } = renderHook(() => usePublicSettings(), { wrapper: Wrapper }); + const { result: second } = renderHook(() => usePublicSettings(), { wrapper: Wrapper }); + const { result: third } = renderHook(() => usePublicSettings(), { wrapper: Wrapper }); + + await waitFor(() => { + expect(first.current.isSuccess).toBe(true); + expect(second.current.isSuccess).toBe(true); + expect(third.current.isSuccess).toBe(true); + }); + + // Single network call regardless of how many components mount the hook. + expect(getPublicSettingsMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index a29d2fa0..0e2ac578 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -2,4 +2,5 @@ export * from './useSessionTimeout'; export * from './useOnClickOutside'; export * from './useLocalizedDate'; export * from './useLocalizedTimeAgo'; -export * from './usePermission'; \ No newline at end of file +export * from './usePermission'; +export * from './usePublicSettings'; \ No newline at end of file diff --git a/frontend/src/hooks/useLocalizedDate.ts b/frontend/src/hooks/useLocalizedDate.ts index ff29167b..9ad2b3f0 100644 --- a/frontend/src/hooks/useLocalizedDate.ts +++ b/frontend/src/hooks/useLocalizedDate.ts @@ -1,8 +1,7 @@ import { useTranslation } from 'react-i18next'; import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns'; import { de, enUS, ptBR } from 'date-fns/locale'; -import { useQuery } from '@tanstack/react-query'; -import { publicSettingsService } from '../services/publicSettings.service'; +import { usePublicSettings } from './usePublicSettings'; // Convert old date format strings to new date-fns format const convertDateFormat = (format: string): string => { @@ -15,13 +14,7 @@ const convertDateFormat = (format: string): string => { export const useLocalizedDate = () => { const { i18n } = useTranslation(); - // Fetch public settings to get the date format - const { data: settings } = useQuery({ - queryKey: ['public-settings'], - queryFn: () => publicSettingsService.getPublicSettings(), - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - retry: 1, // Only retry once to avoid blocking the UI - }); + const { data: settings } = usePublicSettings(); const getLocale = () => { if (i18n.language === 'de') return de; diff --git a/frontend/src/hooks/usePublicSettings.ts b/frontend/src/hooks/usePublicSettings.ts new file mode 100644 index 00000000..f8660c0a --- /dev/null +++ b/frontend/src/hooks/usePublicSettings.ts @@ -0,0 +1,18 @@ +import { useQuery, type UseQueryOptions } from '@tanstack/react-query'; +import { publicSettingsService, type PublicSettings } from '../services/publicSettings.service'; + +export const PUBLIC_SETTINGS_QUERY_KEY = ['public-settings'] as const; + +type PublicSettingsQueryOptions = Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' +>; + +export function usePublicSettings(options?: PublicSettingsQueryOptions) { + return useQuery({ + queryKey: PUBLIC_SETTINGS_QUERY_KEY, + queryFn: () => publicSettingsService.getPublicSettings(), + staleTime: 60_000, + ...options, + }); +} diff --git a/frontend/src/hooks/useWatermarkSettings.ts b/frontend/src/hooks/useWatermarkSettings.ts index 54844297..3058378a 100644 --- a/frontend/src/hooks/useWatermarkSettings.ts +++ b/frontend/src/hooks/useWatermarkSettings.ts @@ -1,27 +1,10 @@ -import { useState, useEffect } from 'react'; -import { api } from '../config/api'; +import { usePublicSettings } from './usePublicSettings'; export function useWatermarkSettings() { - const [watermarkEnabled, setWatermarkEnabled] = useState(false); - const [loading, setLoading] = useState(true); + const { data: settings, isLoading } = usePublicSettings(); - useEffect(() => { - const fetchSettings = async () => { - try { - // Use public settings endpoint that doesn't require authentication - const response = await api.get('/public/settings'); - setWatermarkEnabled(response.data.branding_watermark_enabled || false); - } catch (error) { - console.error('Failed to fetch watermark settings:', error); - // Default to false if we can't fetch settings - setWatermarkEnabled(false); - } finally { - setLoading(false); - } - }; - - fetchSettings(); - }, []); - - return { watermarkEnabled, loading }; -} \ No newline at end of file + return { + watermarkEnabled: Boolean(settings?.branding_watermark_enabled), + loading: isLoading, + }; +} diff --git a/frontend/src/pages/ClientAccessPage.tsx b/frontend/src/pages/ClientAccessPage.tsx index 180c312b..4048eee2 100644 --- a/frontend/src/pages/ClientAccessPage.tsx +++ b/frontend/src/pages/ClientAccessPage.tsx @@ -2,12 +2,11 @@ import React, { useState } from 'react'; import { useParams, useSearchParams, useNavigate, Link } from 'react-router-dom'; import { AlertCircle, Lock } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { useQuery } from '@tanstack/react-query'; import { Card, CardContent, Input, Button, Loading } from '../components/common'; import { useGalleryAuth } from '../contexts'; import { useGalleryInfo } from '../hooks/useGallery'; -import { api } from '../config/api'; +import { usePublicSettings } from '../hooks/usePublicSettings'; import { buildResourceUrl } from '../utils/url'; export const ClientAccessPage: React.FC = () => { @@ -22,14 +21,7 @@ export const ClientAccessPage: React.FC = () => { const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug); - const { data: settingsData } = useQuery({ - queryKey: ['gallery-settings'], - queryFn: async () => { - const response = await api.get('/public/settings'); - return response.data; - }, - staleTime: 5 * 60 * 1000, - }); + const { data: settingsData } = usePublicSettings(); // If already authenticated as client, redirect to gallery React.useEffect(() => { diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index f440bbc7..0427f2b1 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -4,7 +4,7 @@ import { AlertCircle, Clock } from 'lucide-react'; import { differenceInDays, parseISO } from 'date-fns'; import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../hooks/useLocalizedDate'; -import { useQuery } from '@tanstack/react-query'; +import { usePublicSettings } from '../hooks/usePublicSettings'; import { Card, CardContent, Input, Button, ReCaptcha, CMSContentBlock } from '../components/common'; import { useGalleryAuth, useTheme } from '../contexts'; @@ -13,7 +13,6 @@ import { GalleryView } from '../components/gallery'; import { GallerySkeleton } from '../components/gallery/GallerySkeleton'; import { analyticsService } from '../services/analytics.service'; import { galleryService } from '../services'; -import { api } from '../config/api'; import { GALLERY_THEME_PRESETS } from '../types/theme.types'; import { buildResourceUrl } from '../utils/url'; import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl'; @@ -106,15 +105,7 @@ export const GalleryPage: React.FC = () => { setAutoLoginAttempted(false); }, [resolvedSlug]); - // Fetch branding settings - const { data: settingsData, isLoading: isLoadingSettings } = useQuery({ - queryKey: ['gallery-settings'], - queryFn: async () => { - const response = await api.get('/public/settings'); - return response.data; - }, - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - }); + const { data: settingsData, isLoading: isLoadingSettings } = usePublicSettings(); // Set language from admin settings when on login page React.useEffect(() => { diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx index 0415a2da..188484d0 100644 --- a/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/frontend/src/pages/admin/AdminLoginPage.tsx @@ -2,12 +2,12 @@ import React, { useState, useEffect } from 'react'; import { Navigate, useSearchParams } from 'react-router-dom'; import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react'; import { toast } from 'react-toastify'; -import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Button, Input, Card, ReCaptcha } from '../../components/common'; import { useAdminAuth } from '../../contexts'; import { authService } from '../../services/auth.service'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; import { api } from '../../config/api'; export const AdminLoginPage: React.FC = () => { @@ -25,15 +25,7 @@ export const AdminLoginPage: React.FC = () => { const [loginSuccess, setLoginSuccess] = useState(false); const [recaptchaToken, setRecaptchaToken] = useState(null); - // Fetch branding settings (unauthenticated) - const { data: settingsData } = useQuery({ - queryKey: ['admin-login-settings'], - queryFn: async () => { - const response = await api.get('/public/settings'); - return response.data; - }, - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - }); + const { data: settingsData } = usePublicSettings(); const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak'; const logoUrl = settingsData?.branding_logo_url?.trim(); diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index 3aa64bbd..56862b57 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -22,7 +22,7 @@ import { eventsService } from '../../services/events.service'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { categoriesService } from '../../services/categories.service'; import { settingsService } from '../../services/settings.service'; -import { publicSettingsService } from '../../services/publicSettings.service'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; import { cssTemplatesService } from '../../services/cssTemplates.service'; import { eventTypesService } from '../../services/eventTypes.service'; import { useTranslation } from 'react-i18next'; @@ -170,11 +170,7 @@ export const CreateEventPage: React.FC = () => { queryFn: () => settingsService.getAllSettings() }); - // Fetch public settings for field requirements - const { data: publicSettings } = useQuery({ - queryKey: ['public-settings'], - queryFn: () => publicSettingsService.getPublicSettings() - }); + const { data: publicSettings } = usePublicSettings(); // Get field requirements (default to true if not set) const requireCustomerName = publicSettings?.event_require_customer_name !== false; diff --git a/frontend/src/pages/public/LegalPage.tsx b/frontend/src/pages/public/LegalPage.tsx index 327db3ef..688cf028 100644 --- a/frontend/src/pages/public/LegalPage.tsx +++ b/frontend/src/pages/public/LegalPage.tsx @@ -6,7 +6,7 @@ import { ArrowLeft, Home } from 'lucide-react'; import DOMPurify from 'dompurify'; import { Loading, Card } from '../../components/common'; import { cmsService } from '../../services/cms.service'; -import { api } from '../../config/api'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; import '../../styles/prose-overrides.css'; export const LegalPage: React.FC = () => { @@ -18,15 +18,7 @@ export const LegalPage: React.FC = () => { const pathname = window.location.pathname; const pageSlug = slug || pathname.split('/').pop() || ''; - // Fetch settings to get default language - const { data: settingsData } = useQuery({ - queryKey: ['public-settings'], - queryFn: async () => { - const response = await api.get('/public/settings'); - return response.data; - }, - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - }); + const { data: settingsData } = usePublicSettings(); // Use admin settings language const lang = settingsData?.default_language || 'en'; diff --git a/frontend/src/services/publicSettings.service.ts b/frontend/src/services/publicSettings.service.ts index 41c32464..e124087e 100644 --- a/frontend/src/services/publicSettings.service.ts +++ b/frontend/src/services/publicSettings.service.ts @@ -12,6 +12,13 @@ export interface PublicSettings { branding_watermark_size: number; branding_favicon_url: string; branding_logo_url: string; + branding_logo_size?: string; + branding_logo_max_height?: number; + branding_logo_position?: 'left' | 'center' | 'right'; + branding_logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text'; + branding_logo_display_header?: boolean; + branding_logo_display_hero?: boolean; + branding_hide_powered_by?: boolean; theme_config: any; default_language: string; enable_analytics: boolean; @@ -34,6 +41,10 @@ export interface PublicSettings { event_default_require_password?: boolean; gallery_show_filter_bar?: boolean; event_phone_field_enabled?: boolean; + // SEO meta tags (consumed by RobotsMetaTags) + seo_meta_noindex?: boolean; + seo_meta_nofollow?: boolean; + seo_meta_noai?: boolean; } export const publicSettingsService = { diff --git a/tests/e2e/public-settings-dedup.spec.ts b/tests/e2e/public-settings-dedup.spec.ts new file mode 100644 index 00000000..59097747 --- /dev/null +++ b/tests/e2e/public-settings-dedup.spec.ts @@ -0,0 +1,102 @@ +import { test, expect, Page } from '@playwright/test'; + +/** + * Verifies the dedup work for issue #325 — every consumer of /public/settings + * should share a single React Query cache rather than triggering its own fetch + * per component mount. + * + * Pre-dedup baseline (captured 2026-04-27 with the live admin dashboard): + * 7 calls to /api/public/settings on a single /admin/login → /admin/dashboard + * navigation (4 from non-React-Query call sites + 3 from inconsistent + * queryKeys in React Query consumers). + * + * After landing usePublicSettings the count drops to 1. + */ + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; +const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!'; + +function attachSettingsCounter(page: Page) { + const calls: string[] = []; + page.on('request', (req) => { + const url = req.url(); + if (url.includes('/api/public/settings')) { + calls.push(`${req.method()} ${url}`); + } + }); + return calls; +} + +test.describe('public settings dedup (#325)', () => { + test('admin login + dashboard fires /public/settings at most once', async ({ page }) => { + const calls = attachSettingsCounter(page); + + await page.goto('/admin/login'); + // Wait until the form is interactive — branding/theme/maintenance contexts + // have all had a chance to mount by this point. + await page.waitForSelector('input[type="email"]', { state: 'visible' }); + await page.waitForLoadState('networkidle'); + + expect(calls, calls.join('\n')).toHaveLength(1); + }); + + test('no spurious refetch within the 60s staleTime window', async ({ page }) => { + const calls = attachSettingsCounter(page); + + await page.goto('/admin/login'); + await page.waitForSelector('input[type="email"]', { state: 'visible' }); + await page.waitForLoadState('networkidle'); + + // Sit on the page for ~5s to confirm no decorative consumer (favicon, + // robots tags, recaptcha probe, etc.) triggers a second fetch within + // the hook's staleTime window. Pre-dedup, several call sites used a + // 5-minute staleTime but inconsistent queryKeys, so multiple fetches + // would land within the first second and could re-fire on remount. + await page.waitForTimeout(5000); + + expect(calls, calls.join('\n')).toHaveLength(1); + }); + + test('public gallery login page fires /public/settings at most once', async ({ page, request }) => { + // Set up an event so the gallery page doesn't bail out with a 404. + const adminLogin = await request.post('/api/auth/admin/login', { + data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + if (!adminLogin.ok()) { + test.skip(true, 'Admin login unavailable — skipping gallery dedup check'); + return; + } + const { token } = await adminLogin.json(); + + const eventResponse = await request.post('/api/admin/events', { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { + event_type: 'wedding', + event_name: `Dedup test ${Date.now()}`, + event_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), + customer_name: 'Dedup Host', + customer_email: 'host@example.com', + host_name: 'Dedup Host', + host_email: 'host@example.com', + admin_email: ADMIN_EMAIL, + password: GALLERY_PASSWORD, + expiration_days: 30, + }, + }); + if (!eventResponse.ok()) { + test.skip(true, `Event creation failed (${eventResponse.status()}) — skipping`); + return; + } + const event = await eventResponse.json(); + const slug: string = event?.event?.slug ?? event?.slug; + expect(slug).toBeTruthy(); + + const calls = attachSettingsCounter(page); + + await page.goto(`/gallery/${slug}`); + await page.waitForLoadState('networkidle'); + + expect(calls, calls.join('\n')).toHaveLength(1); + }); +});