feat(frontend): dedupe /public/settings via shared usePublicSettings hook (#325)
Pre-dedup: 7 calls to /api/public/settings on a single /admin/login page load — 4 from raw-fetch consumers + 3 from React Query consumers using inconsistent queryKeys. Captured live in Chrome DevTools. Post-dedup: 1 call. Verified by tests/e2e/public-settings-dedup.spec.ts. Adds: - frontend/src/hooks/usePublicSettings.ts — single React Query hook, 60s staleTime, queryKey ['public-settings']. Vitest with mocked api proves multi-mount dedup. - Extended PublicSettings interface with seo_meta_* fields used by RobotsMetaTags and branding_logo_* fields used by GalleryView/AdminHeader. Migrates 19 call sites across 4 risk-ordered rounds: - Round A (high fan-in): GlobalThemeProvider, MaintenanceContext (with refetchInterval to preserve maintenance polling), MaintenanceWrapper (drops the now-redundant per-route ping; axios interceptor already handles 503), AdminHeader. - Round B (gallery/login): GalleryView, GalleryPage, ClientAccessPage, AdminLoginPage, MaintenanceMode. - Round C (decorative): RobotsMetaTags, DynamicFavicon, CMSContentBlock, ReCaptcha, useWatermarkSettings (rips out raw fetch + local state), LegalPage. - Round D (queryKey alignment): useLocalizedDate, UserPhotoUpload, CreateEventPage. EventDetailsPage Round D ships in the follow-up commit that adds presigned-download UI on the same page. App.tsx (AnalyticsBootstrap) and EventDetailsPage are deferred to later commits — both files mix #325 changes with backend feature work.
This commit is contained in:
@@ -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,16 +9,7 @@ interface GlobalThemeProviderProps {
|
||||
export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ 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(() => {
|
||||
|
||||
@@ -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<BrandingSettings>({
|
||||
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(() => {
|
||||
|
||||
@@ -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<MaintenanceWrapperProps> = ({ 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<MaintenanceWrapperProps> = ({ 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 <MaintenanceMode />;
|
||||
}
|
||||
|
||||
@@ -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<AdminHeaderProps> = ({ 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();
|
||||
|
||||
@@ -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<CMSContentBlockProps> = ({ 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';
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
@@ -15,25 +14,10 @@ export const ReCaptcha: React.FC<ReCaptchaProps> = ({
|
||||
size = 'normal'
|
||||
}) => {
|
||||
const recaptchaRef = React.useRef<ReCAPTCHA>(null);
|
||||
const [siteKey, setSiteKey] = useState<string>('');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<GalleryViewProps> = ({ 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({
|
||||
|
||||
@@ -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<UserPhotoUploadProps> = ({
|
||||
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),
|
||||
|
||||
@@ -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<MaintenanceProviderProps> = ({ 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);
|
||||
|
||||
@@ -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 }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
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<ReturnType<typeof publicSettingsService.getPublicSettings>>);
|
||||
|
||||
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<ReturnType<typeof publicSettingsService.getPublicSettings>>);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -3,3 +3,4 @@ export * from './useOnClickOutside';
|
||||
export * from './useLocalizedDate';
|
||||
export * from './useLocalizedTimeAgo';
|
||||
export * from './usePermission';
|
||||
export * from './usePublicSettings';
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PublicSettings, Error>,
|
||||
'queryKey' | 'queryFn'
|
||||
>;
|
||||
|
||||
export function usePublicSettings(options?: PublicSettingsQueryOptions) {
|
||||
return useQuery<PublicSettings, Error>({
|
||||
queryKey: PUBLIC_SETTINGS_QUERY_KEY,
|
||||
queryFn: () => publicSettingsService.getPublicSettings(),
|
||||
staleTime: 60_000,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
return {
|
||||
watermarkEnabled: Boolean(settings?.branding_watermark_enabled),
|
||||
loading: isLoading,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<string | null>(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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 || '[email protected]';
|
||||
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: '[email protected]',
|
||||
host_name: 'Dedup Host',
|
||||
host_email: '[email protected]',
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user