diff --git a/frontend/src/components/admin/CssTemplateEditor.tsx b/frontend/src/components/admin/CssTemplateEditor.tsx index f2f23089..90e60da1 100644 --- a/frontend/src/components/admin/CssTemplateEditor.tsx +++ b/frontend/src/components/admin/CssTemplateEditor.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { Save, RotateCcw, Code, AlertTriangle, Check } from 'lucide-react'; +import { Save, RotateCcw, AlertTriangle, Check } from 'lucide-react'; import { Button, Card, Loading } from '../common'; import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; @@ -90,12 +90,10 @@ export const CssTemplateEditor: React.FC = () => { return (
-
-

- - {t('cssTemplates.title', 'Custom CSS Templates')} -

-
+ {/* No title here — this component IS the Settings → Custom CSS + tab, and the Settings shell already renders that section + heading (icon + label + divider). A second, near-identical H2 + stacked directly under it (QA warning). */} {/* Tab Navigation */}
diff --git a/frontend/src/components/admin/WordFilterManager.tsx b/frontend/src/components/admin/WordFilterManager.tsx index f6d6aadf..0ae59897 100644 --- a/frontend/src/components/admin/WordFilterManager.tsx +++ b/frontend/src/components/admin/WordFilterManager.tsx @@ -176,10 +176,11 @@ export const WordFilterManager: React.FC = () => { <>
+ {/* No title here — this component IS the Settings → Moderation + tab, and the Settings shell already renders that section + heading (icon + label + divider). A second H2 stacked + directly under it (QA warning). */}
-

- {t('settings.moderation.wordFilters', 'Word Filters')} -

{t('settings.moderation.description', 'Manage words that should be filtered or blocked in comments')}

diff --git a/frontend/src/features/settings/tabs/AccountingTab.tsx b/frontend/src/features/settings/tabs/AccountingTab.tsx index 9f60ae5a..399faed2 100644 --- a/frontend/src/features/settings/tabs/AccountingTab.tsx +++ b/frontend/src/features/settings/tabs/AccountingTab.tsx @@ -95,10 +95,12 @@ export const AccountingTab: React.FC = () => { return (
-
-

{t('settings.accounting.title', 'Accounting')}

-

{t('settings.accounting.subtitle', 'Default rates for internal expenses and the proof requirement.')}

-
+ {/* No tab title here — the Settings shell renders the section + heading (icon + label + divider) for every tab that isn't in + SettingsPage's TABS_WITH_OWN_HEADER, and it reads from the same + `settings.accounting.title` key, so repeating it stacked two + identical H2s on top of each other (QA warning). */} +

{t('settings.accounting.subtitle', 'Default rates for internal expenses and the proof requirement.')}

diff --git a/frontend/src/features/settings/tabs/ApiTokensTab.tsx b/frontend/src/features/settings/tabs/ApiTokensTab.tsx index 6ee44fcb..57fd4d9a 100644 --- a/frontend/src/features/settings/tabs/ApiTokensTab.tsx +++ b/frontend/src/features/settings/tabs/ApiTokensTab.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; -import { KeyRound, Trash2, Copy, AlertTriangle } from 'lucide-react'; +import { Trash2, Copy, AlertTriangle } from 'lucide-react'; import { Button, Card, Input, Loading } from '../../../components/common'; import { api } from '../../../config/api'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; @@ -83,10 +83,10 @@ export const ApiTokensTab: React.FC = () => { return (
-

- - {t('settings.apiTokens.title', 'API Tokens')} -

+ {/* No tab title here — the Settings shell renders the section + heading (icon + label + divider) for every tab that isn't in + SettingsPage's TABS_WITH_OWN_HEADER, and repeating it stacked + two identical H2s on top of each other (QA warning). */}

{t('settings.apiTokens.subtitle', 'Long-lived bearer tokens for the public /api/v1 surface — n8n integrations, custom apps, scripts. Tokens act as the admin user that minted them, intersected with the chosen scopes.')}

diff --git a/frontend/src/features/settings/tabs/DownloadsTab.tsx b/frontend/src/features/settings/tabs/DownloadsTab.tsx index 2ea6135d..e056765d 100644 --- a/frontend/src/features/settings/tabs/DownloadsTab.tsx +++ b/frontend/src/features/settings/tabs/DownloadsTab.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Save, Download, Plus, Trash2 } from 'lucide-react'; +import { Save, Plus, Trash2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; @@ -107,12 +107,10 @@ export const DownloadsTab: React.FC = () => { return (
-
- -

- {t('settings.downloads.title', 'Download resolutions')} -

-
+ {/* No tab title here — the Settings shell renders the section + heading (icon + label + divider) for every tab that isn't in + SettingsPage's TABS_WITH_OWN_HEADER, and repeating it stacked + two identical H2s on top of each other (QA warning). */}

{t('settings.downloads.intro', 'The standard size is what every gallery hands out by default. Individual galleries can override this.')} diff --git a/frontend/src/features/settings/tabs/SsoTab.tsx b/frontend/src/features/settings/tabs/SsoTab.tsx index 3aa8a535..5d9ce66b 100644 --- a/frontend/src/features/settings/tabs/SsoTab.tsx +++ b/frontend/src/features/settings/tabs/SsoTab.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { Save, KeyRound, PlugZap, Copy, Check, UserCog, ShieldAlert, Plus, Trash2 } from 'lucide-react'; +import { Save, PlugZap, Copy, Check, UserCog, ShieldAlert, Plus, Trash2 } from 'lucide-react'; import type { AxiosError } from 'axios'; import { Button, Card, Input, Loading } from '../../../components/common'; @@ -127,12 +127,11 @@ export const SsoTab: React.FC = () => {

-
- -

- {t('settings.sso.title', 'Single Sign-On (OIDC)')} -

-
+ {/* No tab title here — the Settings shell renders the section + heading (icon + label + divider) for every tab that isn't in + SettingsPage's TABS_WITH_OWN_HEADER, and it reads from the + same `settings.sso.title` key, so repeating it stacked two + identical H2s on top of each other (QA warning). */}

{t('settings.sso.intro', 'Let admins sign in through your identity provider (Keycloak, Authentik, Pocket ID, or any OIDC-compliant IdP). Local email/password login stays available as a fallback.')}

diff --git a/frontend/src/features/settings/tabs/WebhooksTab.tsx b/frontend/src/features/settings/tabs/WebhooksTab.tsx index a31454c8..04da590f 100644 --- a/frontend/src/features/settings/tabs/WebhooksTab.tsx +++ b/frontend/src/features/settings/tabs/WebhooksTab.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; -import { Webhook as WebhookIcon, Trash2, Copy, AlertTriangle, Activity, CheckCircle2, XCircle } from 'lucide-react'; +import { Trash2, Copy, AlertTriangle, Activity, CheckCircle2, XCircle } from 'lucide-react'; import { Button, Card, Input, Loading } from '../../../components/common'; import { api } from '../../../config/api'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; @@ -124,10 +124,10 @@ export const WebhooksTab: React.FC = () => { return (
-

- - {t('settings.webhooks.title', 'Webhooks')} -

+ {/* No tab title here — the Settings shell renders the section + heading (icon + label + divider) for every tab that isn't in + SettingsPage's TABS_WITH_OWN_HEADER, and repeating it stacked + two identical H2s on top of each other (QA warning). */}

{t('settings.webhooks.subtitle', 'POST event notifications to your URL the moment something happens — gallery published, photo uploaded, event archived, etc. Signed with HMAC-SHA256 in the X-PicPeak-Signature header.')}

diff --git a/frontend/src/features/settings/tabs/WhatsAppTab.tsx b/frontend/src/features/settings/tabs/WhatsAppTab.tsx index dbb9ae98..f0627bf0 100644 --- a/frontend/src/features/settings/tabs/WhatsAppTab.tsx +++ b/frontend/src/features/settings/tabs/WhatsAppTab.tsx @@ -119,17 +119,17 @@ export const WhatsAppTab: React.FC = () => { return (
-
-

- {t('settings.whatsapp.title', 'WhatsApp')} -

-

- {t( - 'settings.whatsapp.subtitle', - 'Configure Meta Business credentials to deliver the gallery-ready notification via WhatsApp alongside email.', - )} -

-
+ {/* No tab title here — the Settings shell renders the section + heading (icon + label + divider) for every tab that isn't in + SettingsPage's TABS_WITH_OWN_HEADER, and it reads from the same + `settings.whatsapp.title` key, so repeating it stacked two + identical H2s on top of each other (QA warning). */} +

+ {t( + 'settings.whatsapp.subtitle', + 'Configure Meta Business credentials to deliver the gallery-ready notification via WhatsApp alongside email.', + )} +

diff --git a/frontend/src/pages/admin/SlideshowSettingsPage.tsx b/frontend/src/pages/admin/SlideshowSettingsPage.tsx index 50862a40..a52727fd 100644 --- a/frontend/src/pages/admin/SlideshowSettingsPage.tsx +++ b/frontend/src/pages/admin/SlideshowSettingsPage.tsx @@ -12,14 +12,14 @@ export const SlideshowSettingsPage: React.FC = () => { const { t } = useTranslation(); return (
-
-

- {t('settings.slideshow.title', 'Slideshow')} -

-

- {t('settings.slideshow.subtitle', 'Global defaults for the Live Slideshow. Events and event types can override these.')} -

-
+ {/* No tab title here — the Settings shell renders the section + heading (icon + label + divider) for every tab that isn't in + SettingsPage's TABS_WITH_OWN_HEADER, and it reads from the same + `settings.slideshow.title` key, so repeating it stacked two + identical H2s on top of each other (QA warning). */} +

+ {t('settings.slideshow.subtitle', 'Global defaults for the Live Slideshow. Events and event types can override these.')} +

); diff --git a/frontend/src/pages/admin/__tests__/settingsTabDuplicateHeading.test.ts b/frontend/src/pages/admin/__tests__/settingsTabDuplicateHeading.test.ts new file mode 100644 index 00000000..17bc7dca --- /dev/null +++ b/frontend/src/pages/admin/__tests__/settingsTabDuplicateHeading.test.ts @@ -0,0 +1,104 @@ +/** + * SettingsPage renders a section heading (icon + nav label + divider) for + * every tab that isn't listed in its own TABS_WITH_OWN_HEADER escape hatch. + * Several tab components ALSO rendered a page-level title of their own, so + * the admin saw the same heading twice, stacked (QA warning). + * + * The duplicate was removed on the component side — the shell heading is the + * one that is consistent across all ~20 tabs and always matches the nav item + * the admin just clicked. Source-inspection guard so it doesn't come back. + */ +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; + +import en from '../../../i18n/locales/en.json'; + +const SRC = path.resolve(__dirname, '../../..'); + +const read = (rel: string) => fs.readFileSync(path.join(SRC, rel), 'utf8'); + +const translate = (key: string): string | undefined => { + let node: unknown = en; + for (const part of key.split('.')) { + if (typeof node !== 'object' || node === null || !(part in node)) return undefined; + node = (node as Record)[part]; + } + return typeof node === 'string' ? node : undefined; +}; + +const normalize = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, ''); + +/** Tabs that lean on the shell heading, and the component each one mounts. */ +const SHELL_HEADING_TABS = [ + { tab: 'downloads', labelKey: 'settings.downloads.title', file: 'features/settings/tabs/DownloadsTab.tsx' }, + { tab: 'sso', labelKey: 'settings.sso.title', file: 'features/settings/tabs/SsoTab.tsx' }, + { tab: 'apiTokens', labelKey: 'settings.apiTokens.title', file: 'features/settings/tabs/ApiTokensTab.tsx' }, + { tab: 'webhooks', labelKey: 'settings.webhooks.title', file: 'features/settings/tabs/WebhooksTab.tsx' }, + { tab: 'accounting', labelKey: 'settings.accounting.title', file: 'features/settings/tabs/AccountingTab.tsx' }, + { tab: 'whatsapp', labelKey: 'settings.whatsapp.title', file: 'features/settings/tabs/WhatsAppTab.tsx' }, + { tab: 'moderation', labelKey: 'settings.moderation.title', file: 'components/admin/WordFilterManager.tsx' }, + { tab: 'styling', labelKey: 'settings.styling.title', file: 'components/admin/CssTemplateEditor.tsx' }, + { tab: 'slideshow', labelKey: 'settings.slideshow.title', file: 'pages/admin/SlideshowSettingsPage.tsx' }, + { tab: 'businessProfile', labelKey: 'settings.businessProfile.title', file: 'pages/admin/settings/SettingsBusinessProfilePage.tsx' }, + { tab: 'crm', labelKey: 'settings.crm.title', file: 'pages/admin/settings/CrmSettingsPage.tsx' }, +]; + +/** + * The three tabs whose own title merely paraphrased the nav label ("Word + * Filters" under "Moderation") rather than repeating it verbatim, so the + * label comparison above can't catch a regression on them. + */ +const PARAPHRASED_TITLE_KEYS = [ + { file: 'components/admin/WordFilterManager.tsx', key: 'settings.moderation.wordFilters' }, + { file: 'components/admin/CssTemplateEditor.tsx', key: 'cssTemplates.title' }, + { file: 'pages/admin/settings/CrmSettingsPage.tsx', key: 'crmSettings.title' }, +]; + +const HEADING = /<(h[12])\b[^>]*>([\s\S]*?)<\/\1>/g; +// Captures the key and, when present, the inline English default. Several of +// these keys live only as defaults (`businessProfile.title` is not in en.json +// at all), so resolving against the locale file alone would miss them. +const T_CALL = /\bt\(\s*'([^']+)'(?:\s*,\s*'((?:[^'\\]|\\.)*)')?/g; + +const resolve = (key: string, fallback?: string) => translate(key) ?? fallback; + +describe('settings tabs render exactly one section heading', () => { + it.each(SHELL_HEADING_TABS)( + '$tab does not repeat its nav label inside the tab body', + ({ labelKey, file }) => { + const label = translate(labelKey); + expect(label, `${labelKey} missing from en.json`).toBeTruthy(); + + const offenders: string[] = []; + for (const heading of read(file).matchAll(HEADING)) { + for (const call of heading[2].matchAll(T_CALL)) { + const resolved = resolve(call[1], call[2]); + if (resolved && normalize(resolved) === normalize(label as string)) { + offenders.push(call[1]); + } + } + } + + expect(offenders).toEqual([]); + }, + ); + + it.each(PARAPHRASED_TITLE_KEYS)('$file no longer renders $key as a title', ({ file, key }) => { + expect(read(file)).not.toContain(`t('${key}'`); + }); + + it('keeps these tabs out of SettingsPage.TABS_WITH_OWN_HEADER', () => { + // The fix must stay on the component side: adding the tabs to the skip + // list would drop the shell heading and lose the icon + divider that + // every other tab shows. + const declaration = /TABS_WITH_OWN_HEADER: TabType\[\] = \[([^\]]*)\]/ + .exec(read('pages/admin/SettingsPage.tsx'))?.[1]; + expect(declaration).toBeTruthy(); + + const skipped = Array.from((declaration as string).matchAll(/'([^']+)'/g), (m) => m[1]); + for (const { tab } of SHELL_HEADING_TABS) { + expect(skipped).not.toContain(tab); + } + }); +}); diff --git a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx index c455d8d0..52554d9e 100644 --- a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx +++ b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx @@ -171,13 +171,15 @@ export const CrmSettingsPage: React.FC = () => { return (
-
-
-

{t('crmSettings.title', 'CRM settings')}

-

- {t('crmSettings.subtitle', 'Fine-tune quote and invoice behaviour.')} -

-
+ {/* No tab title here — the Settings shell renders the section + heading (icon + label + divider) for every tab that isn't in + SettingsPage's TABS_WITH_OWN_HEADER, so a page title repeated + the nav label the admin just clicked (QA warning). The subtitle + stays. */} +
+

+ {t('crmSettings.subtitle', 'Fine-tune quote and invoice behaviour.')} +

diff --git a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx index 3edf3734..fee1effb 100644 --- a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx +++ b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx @@ -63,13 +63,15 @@ export const SettingsBusinessProfilePage: React.FC = () => { return (
-
-
-

{t('businessProfile.title', 'Business profile')}

-

- {t('businessProfile.subtitle', 'Issuer block shown on every quote and invoice PDF.')} -

-
+ {/* No tab title here — the Settings shell renders the section + heading (icon + label + divider) for every tab that isn't in + SettingsPage's TABS_WITH_OWN_HEADER, and its label resolves to + the same string, so repeating it stacked two identical H2s on + top of each other (QA warning). The subtitle stays. */} +
+

+ {t('businessProfile.subtitle', 'Issuer block shown on every quote and invoice PDF.')} +