diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index b9bc94ed..b5549205 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -60,6 +60,8 @@ export const DEFAULT_FLAGS: FeatureFlags = { // the Project Overview cockpit. Off by default — admin opts in under // Settings → Features once they want the CRM → Overview area. projects: false, + // WhatsApp Business API delivery channel (migration 136, #640D). + whatsapp: false, }; export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const; diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index 89febb13..66bf688a 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -115,7 +115,7 @@ function isValidTab(value: string | null): value is TabType { export const SettingsPage: React.FC = () => { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); - const { flags } = useFeatureFlags(); + const { flags, isLoading: flagsLoading } = useFeatureFlags(); // Read ?tab=… on mount; default to Features per the redesign. const initialTab: TabType = isValidTab(searchParams.get('tab')) @@ -183,6 +183,33 @@ export const SettingsPage: React.FC = () => { saveSeoMutation, } = useSettingsState(); + // If the active tab refers to an item that's now hidden (e.g. admin + // landed on ?tab=reminderTemplates after disabling reminderEmails), + // snap to the first key that the dependency-rule flags allow. Effect + // re-fires when flags toggle live. MUST stay above the isLoading early + // return so React's rules-of-hooks count stays consistent across renders + // (was previously after the early return — that's a hooks violation that + // surfaced as React error #310 once settled long enough for `isLoading` + // to transition true→false in the same mount, #640D pre-existing-bug fix). + useEffect(() => { + // Wait for the server's actual flag values before deciding whether the + // current tab is allowed — during initial load `flags` is the defaults + // placeholder which would falsely snap-back away from a tab the server + // has actually enabled. + if (flagsLoading) return; + const gatedOff: Record = { + crm: !(flags.quotes || flags.bills || flags.contracts), + contracts: !flags.contracts, + reminderTemplates: !flags.reminderEmails, + accounting: !flags.accounting, + whatsapp: !flags.whatsapp, + }; + if (gatedOff[activeTab]) { + setActiveTab('features'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [flagsLoading, flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, flags.whatsapp, activeTab]); + if (isLoading) { return (
@@ -275,18 +302,8 @@ export const SettingsPage: React.FC = () => { const allItems = navGroups.flatMap((g) => g.items); const activeItem = allItems.find((i) => i.key === activeTab) ?? allItems[0]; - - // If the active tab refers to an item that's now hidden (e.g. admin - // landed on ?tab=reminderTemplates after disabling reminderEmails), - // snap to the first visible item so the content area doesn't render - // a hidden tab's UI. Effect re-fires when flags toggle live. - useEffect(() => { - const visibleKeys = allItems.map((i) => i.key); - if (!visibleKeys.includes(activeTab) && visibleKeys.length > 0) { - setActiveTab(visibleKeys[0]); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, activeTab]); + // (Visibility snap-back is handled in the useEffect above, which sits + // before the isLoading early return to keep hook ordering stable.) // For tabs that mount existing top-level pages OR bring their own // header (FeaturesTab has its own icon+title+description block), skip