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 React, { useEffect, useRef } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { api } from '../config/api';
|
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||||
|
|
||||||
interface GlobalThemeProviderProps {
|
interface GlobalThemeProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -10,22 +9,13 @@ interface GlobalThemeProviderProps {
|
|||||||
export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ children }) => {
|
export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ children }) => {
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
const themeAppliedRef = useRef(false);
|
const themeAppliedRef = useRef(false);
|
||||||
|
const { data: settingsData } = usePublicSettings();
|
||||||
// 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
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply global theme when settings are loaded (but not on gallery pages)
|
// Apply global theme when settings are loaded (but not on gallery pages)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if we're on a gallery page - gallery pages handle their own themes
|
// Skip if we're on a gallery page - gallery pages handle their own themes
|
||||||
const isGalleryPage = window.location.pathname.includes('/gallery/');
|
const isGalleryPage = window.location.pathname.includes('/gallery/');
|
||||||
|
|
||||||
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
|
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
|
||||||
themeAppliedRef.current = true;
|
themeAppliedRef.current = true;
|
||||||
setTheme(settingsData.theme_config);
|
setTheme(settingsData.theme_config);
|
||||||
|
|||||||
@@ -1,38 +1,13 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { api } from '../config/api';
|
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||||
import { buildResourceUrl } from '../utils/url';
|
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 = () => {
|
export const MaintenanceMode: React.FC = () => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
// Fetch branding settings
|
const { data: settings } = usePublicSettings({ retry: false });
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set language based on system settings
|
// Set language based on system settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { MaintenanceMode } from './MaintenanceMode';
|
import { MaintenanceMode } from './MaintenanceMode';
|
||||||
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
|
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
|
||||||
import { setMaintenanceModeCallback, api } from '../config/api';
|
import { setMaintenanceModeCallback, api } from '../config/api';
|
||||||
@@ -9,12 +8,16 @@ interface MaintenanceWrapperProps {
|
|||||||
children: React.ReactNode;
|
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 }) => {
|
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
|
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
|
||||||
const [hasAdminSession, setHasAdminSession] = useState(false);
|
const [hasAdminSession, setHasAdminSession] = useState(false);
|
||||||
|
|
||||||
// Check if current route is admin route
|
|
||||||
const isAdminRoute = location.pathname.startsWith('/admin');
|
const isAdminRoute = location.pathname.startsWith('/admin');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,40 +48,12 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
|
|||||||
};
|
};
|
||||||
}, [isAdminRoute]);
|
}, [isAdminRoute]);
|
||||||
|
|
||||||
// Register the maintenance mode callback
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMaintenanceModeCallback((enabled: boolean) => {
|
setMaintenanceModeCallback((enabled: boolean) => {
|
||||||
setMaintenanceMode(enabled);
|
setMaintenanceMode(enabled);
|
||||||
});
|
});
|
||||||
}, [setMaintenanceMode]);
|
}, [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)) {
|
if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) {
|
||||||
return <MaintenanceMode />;
|
return <MaintenanceMode />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useAdminAuth } from '../../contexts';
|
import { useAdminAuth } from '../../contexts';
|
||||||
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
||||||
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
|
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
|
||||||
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import { PasswordChangeModal } from './PasswordChangeModal';
|
import { PasswordChangeModal } from './PasswordChangeModal';
|
||||||
import { LanguageSelector } from '../common';
|
import { LanguageSelector } from '../common';
|
||||||
import { notificationsService } from '../../services/notifications.service';
|
import { notificationsService } from '../../services/notifications.service';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { buildResourceUrl, getApiBaseUrl } from '../../utils/url';
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
|
|
||||||
interface AdminHeaderProps {
|
interface AdminHeaderProps {
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
@@ -31,16 +32,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Fetch branding settings
|
const { data: brandingSettings } = usePublicSettings();
|
||||||
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 companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
|
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
|
||||||
const logoUrl = brandingSettings?.branding_logo_url?.trim();
|
const logoUrl = brandingSettings?.branding_logo_url?.trim();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import DOMPurify from 'dompurify';
|
|||||||
import { Card } from './Card';
|
import { Card } from './Card';
|
||||||
import { Loading } from './Loading';
|
import { Loading } from './Loading';
|
||||||
import { cmsService } from '../../services/cms.service';
|
import { cmsService } from '../../services/cms.service';
|
||||||
import { api } from '../../config/api';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import { buildResourceUrl } from '../../utils/url';
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
import '../../styles/prose-overrides.css';
|
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 }) => {
|
export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback }) => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
const { data: settings } = useQuery({
|
const { data: settings } = usePublicSettings();
|
||||||
queryKey: ['public-settings'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await api.get('/public/settings');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const lang = settings?.default_language || i18n.language || 'en';
|
const lang = settings?.default_language || i18n.language || 'en';
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,11 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
|
|
||||||
const DEFAULT_TITLE = 'PicPeak - Photo Sharing Platform';
|
const DEFAULT_TITLE = 'PicPeak - Photo Sharing Platform';
|
||||||
|
|
||||||
export const DynamicFavicon: React.FC = () => {
|
export const DynamicFavicon: React.FC = () => {
|
||||||
const { data: settings } = useQuery({
|
const { data: settings } = usePublicSettings({ retry: false });
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update favicon when branding settings change
|
// Update favicon when branding settings change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -82,4 +68,4 @@ export const DynamicFavicon: React.FC = () => {
|
|||||||
}, [settings?.branding_company_name, settings?.branding_company_tagline]);
|
}, [settings?.branding_company_name, settings?.branding_company_tagline]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import ReCAPTCHA from 'react-google-recaptcha';
|
import ReCAPTCHA from 'react-google-recaptcha';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import { getApiBaseUrl } from '../../utils/url';
|
|
||||||
|
|
||||||
interface ReCaptchaProps {
|
interface ReCaptchaProps {
|
||||||
onChange: (token: string | null) => void;
|
onChange: (token: string | null) => void;
|
||||||
@@ -9,31 +8,16 @@ interface ReCaptchaProps {
|
|||||||
size?: 'normal' | 'compact';
|
size?: 'normal' | 'compact';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReCaptcha: React.FC<ReCaptchaProps> = ({
|
export const ReCaptcha: React.FC<ReCaptchaProps> = ({
|
||||||
onChange,
|
onChange,
|
||||||
onExpired,
|
onExpired,
|
||||||
size = 'normal'
|
size = 'normal'
|
||||||
}) => {
|
}) => {
|
||||||
const recaptchaRef = React.useRef<ReCAPTCHA>(null);
|
const recaptchaRef = React.useRef<ReCAPTCHA>(null);
|
||||||
const [siteKey, setSiteKey] = useState<string>('');
|
const { data: settings } = usePublicSettings();
|
||||||
|
|
||||||
// Fetch public settings to get reCAPTCHA site key
|
const siteKey = settings?.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
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
if (!settings?.enable_recaptcha || !siteKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -52,4 +36,4 @@ export const ReCaptcha: React.FC<ReCaptchaProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ReCaptcha;
|
export default ReCaptcha;
|
||||||
|
|||||||
@@ -1,23 +1,8 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import { getApiBaseUrl } from '../../utils/url';
|
|
||||||
|
|
||||||
export const RobotsMetaTags: React.FC = () => {
|
export const RobotsMetaTags: React.FC = () => {
|
||||||
const { data: settings } = useQuery({
|
const { data: settings } = usePublicSettings({ retry: false });
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Remove any existing robots meta tags we previously injected
|
// 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 { galleryService } from '../../services/gallery.service';
|
||||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||||
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
||||||
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import type { Photo } from '../../types';
|
import type { Photo } from '../../types';
|
||||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -199,15 +200,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch branding settings
|
const { data: settingsData } = usePublicSettings();
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch feedback settings
|
// Fetch feedback settings
|
||||||
const { data: feedbackSettings } = useQuery({
|
const { data: feedbackSettings } = useQuery({
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Upload, X, CheckCircle } from 'lucide-react';
|
import { Upload, X, CheckCircle } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
import { publicSettingsService } from '../../services/publicSettings.service';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
|
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
|
||||||
|
|
||||||
interface UserPhotoUploadProps {
|
interface UserPhotoUploadProps {
|
||||||
@@ -26,11 +25,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
|
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
|
||||||
|
|
||||||
const { data: publicSettings } = useQuery({
|
const { data: publicSettings } = usePublicSettings();
|
||||||
queryKey: ['public-settings'],
|
|
||||||
queryFn: () => publicSettingsService.getPublicSettings(),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const allowedMimeTypes = useMemo(
|
const allowedMimeTypes = useMemo(
|
||||||
() => extensionsToMimeTypes(publicSettings?.allowed_file_types),
|
() => extensionsToMimeTypes(publicSettings?.allowed_file_types),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { setMaintenanceModeCallback } from '../config/api';
|
import { setMaintenanceModeCallback } from '../config/api';
|
||||||
import { getApiBaseUrl } from '../utils/url';
|
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||||
|
|
||||||
interface MaintenanceContextType {
|
interface MaintenanceContextType {
|
||||||
isMaintenanceMode: boolean;
|
isMaintenanceMode: boolean;
|
||||||
@@ -25,34 +24,18 @@ interface MaintenanceProviderProps {
|
|||||||
export const MaintenanceProvider: React.FC<MaintenanceProviderProps> = ({ children }) => {
|
export const MaintenanceProvider: React.FC<MaintenanceProviderProps> = ({ children }) => {
|
||||||
const [isMaintenanceMode, setIsMaintenanceMode] = useState(false);
|
const [isMaintenanceMode, setIsMaintenanceMode] = useState(false);
|
||||||
|
|
||||||
// Check maintenance mode status on mount
|
// Polls /public/settings every 30s so a maintenance flag flipped server-side propagates
|
||||||
const { data: settings } = useQuery({
|
// without a refresh. 503 responses are caught by the axios interceptor in config/api.ts
|
||||||
queryKey: ['public-settings-maintenance'],
|
// (which calls setMaintenanceModeCallback below), so we only need to read the explicit
|
||||||
queryFn: async () => {
|
// maintenance_mode flag here.
|
||||||
try {
|
const { data: settings } = usePublicSettings({ refetchInterval: 30_000 });
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update maintenance mode based on settings
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings?.maintenance_mode !== undefined) {
|
if (settings?.maintenance_mode !== undefined) {
|
||||||
setIsMaintenanceMode(settings.maintenance_mode);
|
setIsMaintenanceMode(settings.maintenance_mode);
|
||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
// Set up the callback for API interceptor
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMaintenanceModeCallback((enabled: boolean) => {
|
setMaintenanceModeCallback((enabled: boolean) => {
|
||||||
setIsMaintenanceMode(enabled);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,4 +2,5 @@ export * from './useSessionTimeout';
|
|||||||
export * from './useOnClickOutside';
|
export * from './useOnClickOutside';
|
||||||
export * from './useLocalizedDate';
|
export * from './useLocalizedDate';
|
||||||
export * from './useLocalizedTimeAgo';
|
export * from './useLocalizedTimeAgo';
|
||||||
export * from './usePermission';
|
export * from './usePermission';
|
||||||
|
export * from './usePublicSettings';
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
||||||
import { de, enUS, ptBR } from 'date-fns/locale';
|
import { de, enUS, ptBR } from 'date-fns/locale';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { usePublicSettings } from './usePublicSettings';
|
||||||
import { publicSettingsService } from '../services/publicSettings.service';
|
|
||||||
|
|
||||||
// Convert old date format strings to new date-fns format
|
// Convert old date format strings to new date-fns format
|
||||||
const convertDateFormat = (format: string): string => {
|
const convertDateFormat = (format: string): string => {
|
||||||
@@ -15,13 +14,7 @@ const convertDateFormat = (format: string): string => {
|
|||||||
export const useLocalizedDate = () => {
|
export const useLocalizedDate = () => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
// Fetch public settings to get the date format
|
const { data: settings } = usePublicSettings();
|
||||||
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 getLocale = () => {
|
const getLocale = () => {
|
||||||
if (i18n.language === 'de') return de;
|
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 { usePublicSettings } from './usePublicSettings';
|
||||||
import { api } from '../config/api';
|
|
||||||
|
|
||||||
export function useWatermarkSettings() {
|
export function useWatermarkSettings() {
|
||||||
const [watermarkEnabled, setWatermarkEnabled] = useState(false);
|
const { data: settings, isLoading } = usePublicSettings();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return {
|
||||||
const fetchSettings = async () => {
|
watermarkEnabled: Boolean(settings?.branding_watermark_enabled),
|
||||||
try {
|
loading: isLoading,
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import React, { useState } from 'react';
|
|||||||
import { useParams, useSearchParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useSearchParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { AlertCircle, Lock } from 'lucide-react';
|
import { AlertCircle, Lock } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { Card, CardContent, Input, Button, Loading } from '../components/common';
|
import { Card, CardContent, Input, Button, Loading } from '../components/common';
|
||||||
import { useGalleryAuth } from '../contexts';
|
import { useGalleryAuth } from '../contexts';
|
||||||
import { useGalleryInfo } from '../hooks/useGallery';
|
import { useGalleryInfo } from '../hooks/useGallery';
|
||||||
import { api } from '../config/api';
|
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||||
import { buildResourceUrl } from '../utils/url';
|
import { buildResourceUrl } from '../utils/url';
|
||||||
|
|
||||||
export const ClientAccessPage: React.FC = () => {
|
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: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug);
|
||||||
|
|
||||||
const { data: settingsData } = useQuery({
|
const { data: settingsData } = usePublicSettings();
|
||||||
queryKey: ['gallery-settings'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await api.get('/public/settings');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If already authenticated as client, redirect to gallery
|
// If already authenticated as client, redirect to gallery
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AlertCircle, Clock } from 'lucide-react';
|
|||||||
import { differenceInDays, parseISO } from 'date-fns';
|
import { differenceInDays, parseISO } from 'date-fns';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalizedDate } from '../hooks/useLocalizedDate';
|
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 { Card, CardContent, Input, Button, ReCaptcha, CMSContentBlock } from '../components/common';
|
||||||
import { useGalleryAuth, useTheme } from '../contexts';
|
import { useGalleryAuth, useTheme } from '../contexts';
|
||||||
@@ -13,7 +13,6 @@ import { GalleryView } from '../components/gallery';
|
|||||||
import { GallerySkeleton } from '../components/gallery/GallerySkeleton';
|
import { GallerySkeleton } from '../components/gallery/GallerySkeleton';
|
||||||
import { analyticsService } from '../services/analytics.service';
|
import { analyticsService } from '../services/analytics.service';
|
||||||
import { galleryService } from '../services';
|
import { galleryService } from '../services';
|
||||||
import { api } from '../config/api';
|
|
||||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||||
import { buildResourceUrl } from '../utils/url';
|
import { buildResourceUrl } from '../utils/url';
|
||||||
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
||||||
@@ -106,15 +105,7 @@ export const GalleryPage: React.FC = () => {
|
|||||||
setAutoLoginAttempted(false);
|
setAutoLoginAttempted(false);
|
||||||
}, [resolvedSlug]);
|
}, [resolvedSlug]);
|
||||||
|
|
||||||
// Fetch branding settings
|
const { data: settingsData, isLoading: isLoadingSettings } = usePublicSettings();
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set language from admin settings when on login page
|
// Set language from admin settings when on login page
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button, Input, Card, ReCaptcha } from '../../components/common';
|
import { Button, Input, Card, ReCaptcha } from '../../components/common';
|
||||||
import { useAdminAuth } from '../../contexts';
|
import { useAdminAuth } from '../../contexts';
|
||||||
import { authService } from '../../services/auth.service';
|
import { authService } from '../../services/auth.service';
|
||||||
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
|
|
||||||
export const AdminLoginPage: React.FC = () => {
|
export const AdminLoginPage: React.FC = () => {
|
||||||
@@ -25,15 +25,7 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
const [loginSuccess, setLoginSuccess] = useState(false);
|
const [loginSuccess, setLoginSuccess] = useState(false);
|
||||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch branding settings (unauthenticated)
|
const { data: settingsData } = usePublicSettings();
|
||||||
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 companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
||||||
const logoUrl = settingsData?.branding_logo_url?.trim();
|
const logoUrl = settingsData?.branding_logo_url?.trim();
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { eventsService } from '../../services/events.service';
|
|||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
import { categoriesService } from '../../services/categories.service';
|
import { categoriesService } from '../../services/categories.service';
|
||||||
import { settingsService } from '../../services/settings.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 { cssTemplatesService } from '../../services/cssTemplates.service';
|
||||||
import { eventTypesService } from '../../services/eventTypes.service';
|
import { eventTypesService } from '../../services/eventTypes.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -170,11 +170,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
queryFn: () => settingsService.getAllSettings()
|
queryFn: () => settingsService.getAllSettings()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch public settings for field requirements
|
const { data: publicSettings } = usePublicSettings();
|
||||||
const { data: publicSettings } = useQuery({
|
|
||||||
queryKey: ['public-settings'],
|
|
||||||
queryFn: () => publicSettingsService.getPublicSettings()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get field requirements (default to true if not set)
|
// Get field requirements (default to true if not set)
|
||||||
const requireCustomerName = publicSettings?.event_require_customer_name !== false;
|
const requireCustomerName = publicSettings?.event_require_customer_name !== false;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ArrowLeft, Home } from 'lucide-react';
|
|||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { Loading, Card } from '../../components/common';
|
import { Loading, Card } from '../../components/common';
|
||||||
import { cmsService } from '../../services/cms.service';
|
import { cmsService } from '../../services/cms.service';
|
||||||
import { api } from '../../config/api';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import '../../styles/prose-overrides.css';
|
import '../../styles/prose-overrides.css';
|
||||||
|
|
||||||
export const LegalPage: React.FC = () => {
|
export const LegalPage: React.FC = () => {
|
||||||
@@ -18,15 +18,7 @@ export const LegalPage: React.FC = () => {
|
|||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
const pageSlug = slug || pathname.split('/').pop() || '';
|
const pageSlug = slug || pathname.split('/').pop() || '';
|
||||||
|
|
||||||
// Fetch settings to get default language
|
const { data: settingsData } = usePublicSettings();
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use admin settings language
|
// Use admin settings language
|
||||||
const lang = settingsData?.default_language || 'en';
|
const lang = settingsData?.default_language || 'en';
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export interface PublicSettings {
|
|||||||
branding_watermark_size: number;
|
branding_watermark_size: number;
|
||||||
branding_favicon_url: string;
|
branding_favicon_url: string;
|
||||||
branding_logo_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;
|
theme_config: any;
|
||||||
default_language: string;
|
default_language: string;
|
||||||
enable_analytics: boolean;
|
enable_analytics: boolean;
|
||||||
@@ -34,6 +41,10 @@ export interface PublicSettings {
|
|||||||
event_default_require_password?: boolean;
|
event_default_require_password?: boolean;
|
||||||
gallery_show_filter_bar?: boolean;
|
gallery_show_filter_bar?: boolean;
|
||||||
event_phone_field_enabled?: 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 = {
|
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