From b48fa62eea5117c3e09bb6c8a7d3dc02931ee8f9 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:33:13 +0200 Subject: [PATCH] feat(gallery): info banner above the photo grid (#932) (#1063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gallery): info banner above the photo grid (#932) A short informational note rendered at the TOP of a gallery, above the photos. Distinct from the promotional banner (#440), which stays by the footer for marketing copy — the reporter's case is an onboarding hint ("use the menu button to filter"), which is useless below a gallery the guest has to scroll past first. Mirrors the promo feature's shape rather than inventing a second one: a global default in Settings → Branding (branding_info_markdown) plus a per-event inherit/custom/off override. Markdown via the existing MarkdownContent sanitiser — no raw HTML, no CSS injection. Empty global default means nothing renders, so upgrading changes nothing visible. Deliberately NOT included: an alignment knob (this is short helper copy, not marketing layout) and guest dismissal — the issue lists dismissal as a nice-to-have, and it needs per-guest persistence that is its own decision. Migration 176 is idempotent (hasColumn / existing-key guarded). Note on the payload plumbing: the per-event fields travel in the /photos response, not just /info. GalleryAuthContext seeds its cached event from the gallery LOGIN response — a small identity subset — so anything absent there is undefined right after a guest signs in. /photos is the payload that refreshes on every gallery load, which is why the fields were added there and why GalleryView reads them from `data.event`. Verified in a browser across all three modes; reading them from the context event instead silently collapsed every override back to 'inherit'. * fix(branding): map branding_info_markdown on read so saving can't wipe it (#932) External review caught this. BrandingSettings declared no info_markdown and formatBrandingSettings never mapped branding_info_markdown, so BrandingPage's hydration — setBrandingSettings(prev => ({ ...prev, ...formatted })) — kept the empty-string initializer instead of the persisted value. The form loaded blank and the next Save posted '' back, wiping a configured banner. Silently: the gallery keeps rendering the old copy until that save lands. This is the same bug the footer/promo fields hit in #441 + #440 / #460, which the read mapper still carries a comment about. Add the field to the interface and the mapper, and pin the round-trip for the whole editable branding set so the next field added is caught by a test rather than by a user losing copy. Verified: the new test fails 3/4 with the mapper line removed. * fix(gallery): honour the info-banner override in the reveal-hidden view (#932) External review, round 2. The hidden-until-reveal branch renders GalleryLayout with the context `event`, which is seeded from the gallery login response and carries no banner fields — so while a gallery was hidden, a per-event 'off' silently resolved to 'inherit' and the global banner appeared on a gallery the admin had muted. Resolve the fields there the same way the main render path does. The two full-page layouts (gallery-premium, gallery-story) are deliberately left alone: they return before GalleryLayout and render no header, footer or promo banner either — injecting a wrapper into layouts documented as having 'their own integrated UI' would be a design change, not a fix. --------- Co-authored-by: Paul Nothaft --- .../__tests__/utils/galleryInfoBanner.test.js | 177 ++++++++++++++++++ .../core/176_gallery_info_banner.js | 78 ++++++++ backend/src/routes/adminEvents/crud.js | 22 +++ backend/src/routes/adminSettings.js | 5 +- backend/src/routes/gallery.js | 16 +- backend/src/routes/publicSettings.js | 4 + .../src/components/gallery/GalleryLayout.tsx | 35 ++++ .../src/components/gallery/GalleryView.tsx | 33 +++- frontend/src/i18n/locales/de.json | 17 ++ frontend/src/i18n/locales/en.json | 17 ++ frontend/src/pages/admin/BrandingPage.tsx | 45 +++++ frontend/src/pages/admin/EventDetailsPage.tsx | 4 + .../event-details/EventInformationCard.tsx | 48 +++++ .../src/pages/admin/event-details/types.ts | 5 + .../settings.brandingRoundTrip.test.ts | 68 +++++++ .../src/services/publicSettings.service.ts | 2 + frontend/src/services/settings.service.ts | 6 + 17 files changed, 575 insertions(+), 7 deletions(-) create mode 100644 backend/__tests__/utils/galleryInfoBanner.test.js create mode 100644 backend/migrations/core/176_gallery_info_banner.js create mode 100644 frontend/src/services/__tests__/settings.brandingRoundTrip.test.ts diff --git a/backend/__tests__/utils/galleryInfoBanner.test.js b/backend/__tests__/utils/galleryInfoBanner.test.js new file mode 100644 index 00000000..1fabc94a --- /dev/null +++ b/backend/__tests__/utils/galleryInfoBanner.test.js @@ -0,0 +1,177 @@ +/** + * Gallery info banner (#932). + * + * A short note rendered ABOVE the photo grid — the reporter's case is an + * onboarding hint ("use the menu button to filter"), which is useless in the + * promo slot down by the footer because the guest has to scroll the whole + * gallery to reach it. + * + * Covers what the migration actually produces on a real engine (the harness + * runs SQLite) and the inherit/custom/off resolution the gallery render + * depends on. The resolution is duplicated here rather than imported because + * it lives in the React layer; the point is to pin the CONTRACT — which + * source wins for each mode — so a change on either side has to update this + * file deliberately. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +process.env.NODE_ENV = 'test'; +process.env.TEST_DATABASE_PATH = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-info-banner-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'info-banner-test-secret'; + +const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb'); + +let db; +let cleanup; + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); +}); + +afterAll(async () => { + if (cleanup) await cleanup(); +}); + +describe('migration 176 — schema', () => { + it('adds events.info_mode defaulting to inherit', async () => { + expect(await db.schema.hasColumn('events', 'info_mode')).toBe(true); + + const [id] = await db('events').insert({ + slug: 'info-default-test', + event_type: 'wedding', + event_name: 'Info Default Test', + event_date: '2026-06-22', + host_email: 'host@example.com', + admin_email: 'admin@example.com', + password_hash: 'x', + share_link: '/gallery/info-default-test/share', + share_token: 'info-default-test-share', + expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(), + is_active: 1, + is_archived: 0, + is_draft: 0, + created_at: new Date().toISOString(), + }).returning('id'); + const eventId = id?.id ?? id; + + const row = await db('events').where({ id: eventId }).first(); + // A gallery created before anyone configures the feature must inherit, + // so switching the global default on lights up every existing gallery. + expect(row.info_mode).toBe('inherit'); + expect(row.info_markdown ?? null).toBeNull(); + }); + + it('adds events.info_markdown as nullable text', async () => { + expect(await db.schema.hasColumn('events', 'info_markdown')).toBe(true); + }); + + it('seeds branding_info_markdown empty, so upgrading shows no banner', async () => { + const row = await db('app_settings').where({ setting_key: 'branding_info_markdown' }).first(); + expect(row).toBeTruthy(); + expect(JSON.parse(row.setting_value)).toBe(''); + expect(row.setting_type).toBe('branding'); + }); +}); + +// Mirrors GalleryLayout's resolution. +function resolveInfoBanner(event, brandingDefault) { + const mode = event.info_mode || 'inherit'; + if (mode === 'off') return ''; + if (mode === 'custom') { + const own = (event.info_markdown || '').trim(); + return (own || brandingDefault || '').trim(); + } + return (brandingDefault || '').trim(); +} + +describe('inherit / custom / off resolution', () => { + const GLOBAL = 'Use the menu button to filter.'; + + it('inherit renders the global default', () => { + expect(resolveInfoBanner({ info_mode: 'inherit' }, GLOBAL)).toBe(GLOBAL); + }); + + it('a missing mode is treated as inherit (rows predating the migration)', () => { + expect(resolveInfoBanner({}, GLOBAL)).toBe(GLOBAL); + }); + + it('custom renders the event copy instead of the global', () => { + const own = 'Proofs are watermarked until final delivery.'; + expect(resolveInfoBanner({ info_mode: 'custom', info_markdown: own }, GLOBAL)).toBe(own); + }); + + it('custom with blank copy falls back to the global rather than showing nothing', () => { + expect(resolveInfoBanner({ info_mode: 'custom', info_markdown: ' ' }, GLOBAL)).toBe(GLOBAL); + }); + + it('off suppresses the banner even when a global default exists', () => { + expect(resolveInfoBanner({ info_mode: 'off' }, GLOBAL)).toBe(''); + }); + + it('off wins over the event own copy too', () => { + expect(resolveInfoBanner({ info_mode: 'off', info_markdown: 'ignored' }, GLOBAL)).toBe(''); + }); + + it('an empty global default means no banner anywhere — the upgrade state', () => { + expect(resolveInfoBanner({ info_mode: 'inherit' }, '')).toBe(''); + expect(resolveInfoBanner({}, undefined)).toBe(''); + }); +}); + +describe('per-event persistence', () => { + let eventId; + + beforeAll(async () => { + const [id] = await db('events').insert({ + slug: 'info-persist-test', + event_type: 'wedding', + event_name: 'Info Persist Test', + event_date: '2026-06-22', + host_email: 'host@example.com', + admin_email: 'admin@example.com', + password_hash: 'x', + share_link: '/gallery/info-persist-test/share', + share_token: 'info-persist-test-share', + expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(), + is_active: 1, + is_archived: 0, + is_draft: 0, + created_at: new Date().toISOString(), + }).returning('id'); + eventId = id?.id ?? id; + }); + + it('stores a custom override', async () => { + await db('events').where({ id: eventId }) + .update({ info_mode: 'custom', info_markdown: '**Heads up** — proofs only.' }); + + const row = await db('events').where({ id: eventId }).first(); + expect(row.info_mode).toBe('custom'); + expect(row.info_markdown).toBe('**Heads up** — proofs only.'); + }); + + it('switching away from custom clears the stored copy', async () => { + // Matches the route's normalisation: mode != custom nulls the text so a + // later switch back to custom can't resurrect stale copy. + await db('events').where({ id: eventId }) + .update({ info_mode: 'off', info_markdown: null }); + + const row = await db('events').where({ id: eventId }).first(); + expect(row.info_mode).toBe('off'); + expect(row.info_markdown).toBeNull(); + }); + + it('the promo banner is untouched by info-banner changes', async () => { + const row = await db('events').where({ id: eventId }).first(); + // Independent slots: the whole point of #932 is that an info hint at the + // top does not consume the marketing slot at the bottom. + expect(row.promo_mode).toBe('inherit'); + expect(row.promo_markdown ?? null).toBeNull(); + }); +}); diff --git a/backend/migrations/core/176_gallery_info_banner.js b/backend/migrations/core/176_gallery_info_banner.js new file mode 100644 index 00000000..ab5946d7 --- /dev/null +++ b/backend/migrations/core/176_gallery_info_banner.js @@ -0,0 +1,78 @@ +/** + * Migration: gallery info banner (#932) + * + * A short informational message rendered ABOVE the photo grid, so guests see + * it on load. Distinct from the promotional banner (#440), which sits by the + * footer and stays there for marketing/CTA copy — the reporter's case is an + * onboarding hint ("use the menu button to filter"), which is useless below a + * gallery the guest has to scroll past first. + * + * Deliberately mirrors the promo columns rather than inventing a second + * shape: a global default in branding settings plus a per-event + * inherit/custom/off override. Same semantics, same normalisation, so the + * two banners stay predictable next to each other. + * + * Idempotent (hasColumn / existing-key guarded) so a re-run or a + * partially-applied state is safe. + */ + +exports.up = async function (knex) { + // 1. events.info_mode — 'inherit' (use the global default) | 'custom' + // (this event's own copy) | 'off' (no banner for this gallery). + const hasInfoMode = await knex.schema.hasColumn('events', 'info_mode'); + if (!hasInfoMode) { + await knex.schema.alterTable('events', (table) => { + table.string('info_mode', 16).notNullable().defaultTo('inherit'); + }); + console.log(' added events.info_mode (default "inherit")'); + } else { + console.log(' events.info_mode already exists, skipping'); + } + + // 2. events.info_markdown — per-event copy, only read when mode = custom. + const hasInfoMarkdown = await knex.schema.hasColumn('events', 'info_markdown'); + if (!hasInfoMarkdown) { + await knex.schema.alterTable('events', (table) => { + table.text('info_markdown').nullable(); + }); + console.log(' added events.info_markdown (nullable text)'); + } else { + console.log(' events.info_markdown already exists, skipping'); + } + + // 3. The global default, following the existing `branding_` + // convention used by the promo rows this mirrors. Empty string = the + // banner is off everywhere until an admin fills it in, so upgrading + // changes nothing visible for existing installs. + const infoSetting = { + setting_key: 'branding_info_markdown', + setting_value: JSON.stringify(''), + setting_type: 'branding', + }; + const exists = await knex('app_settings').where('setting_key', infoSetting.setting_key).first(); + if (!exists) { + await knex('app_settings').insert({ ...infoSetting, updated_at: knex.fn.now() }); + console.log(' added branding_info_markdown (empty = banner off)'); + } else { + console.log(' branding_info_markdown already exists, skipping'); + } + + console.log('Migration 176_gallery_info_banner completed'); +}; + +exports.down = async function (knex) { + console.log('Rollback: 176_gallery_info_banner'); + + if (await knex.schema.hasTable('events')) { + if (await knex.schema.hasColumn('events', 'info_markdown')) { + await knex.schema.alterTable('events', (table) => table.dropColumn('info_markdown')); + } + if (await knex.schema.hasColumn('events', 'info_mode')) { + await knex.schema.alterTable('events', (table) => table.dropColumn('info_mode')); + } + } + + if (await knex.schema.hasTable('app_settings')) { + await knex('app_settings').where('setting_key', 'branding_info_markdown').del(); + } +}; diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index 9058fc9c..7bdca580 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -117,6 +117,10 @@ module.exports = (router) => { // off → suppress entirely for this event body('promo_mode').optional().isIn(['inherit', 'custom', 'off']), body('promo_markdown').optional({ nullable: true }).isString(), + // Per-event info-banner override (#932). Same three-way mode as promo, + // but resolved against branding_info_markdown and rendered above the grid. + body('info_mode').optional().isIn(['inherit', 'custom', 'off']), + body('info_markdown').optional({ nullable: true }).isString(), // Per-event opt-in for using hero photo as the social-share preview // image (#474). When false (default), galleryOgService falls back to // the brand logo for og:image / Twitter Card. @@ -1264,6 +1268,10 @@ module.exports = (router) => { // off → suppress entirely for this event body('promo_mode').optional().isIn(['inherit', 'custom', 'off']), body('promo_markdown').optional({ nullable: true }).isString(), + // Per-event info-banner override (#932). Same three-way mode as promo, + // but resolved against branding_info_markdown and rendered above the grid. + body('info_mode').optional().isIn(['inherit', 'custom', 'off']), + body('info_markdown').optional({ nullable: true }).isString(), // Per-event opt-in for using hero photo as the social-share preview // image (#474). When false (default), galleryOgService falls back to // the brand logo for og:image / Twitter Card. @@ -1540,6 +1548,20 @@ module.exports = (router) => { } } + // Per-event info-banner override (#932). Identical normalisation to + // promo above — mode != custom drops the stored text so switching + // modes can't leave stale copy behind. + if (Object.prototype.hasOwnProperty.call(updates, 'info_mode') + || Object.prototype.hasOwnProperty.call(updates, 'info_markdown')) { + const mode = updates.info_mode; + if (mode && mode !== 'custom') { + updates.info_markdown = null; + } else if (Object.prototype.hasOwnProperty.call(updates, 'info_markdown')) { + const md = typeof updates.info_markdown === 'string' ? updates.info_markdown.trim() : ''; + updates.info_markdown = md || null; + } + } + // Sync header_style / hero_divider_style from color_theme JSON when not // explicitly provided in the request body (#158). This ensures the // database columns stay in sync even if the frontend only sends the diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 6355078b..714f8a7b 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -927,7 +927,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re youtube_url, promo_markdown, promo_position, - promo_alignment + promo_alignment, + // Info banner (#932). Markdown only, same sanitiser path as promo. + info_markdown } = req.body; // Normalize force_color_mode: only 'dark' | 'light' | null are valid. @@ -995,6 +997,7 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re ...(twitter_url !== undefined && { twitter_url: String(twitter_url || '').trim() }), ...(youtube_url !== undefined && { youtube_url: String(youtube_url || '').trim() }), ...(promo_markdown !== undefined && { promo_markdown: typeof promo_markdown === 'string' ? promo_markdown : '' }), + ...(info_markdown !== undefined && { info_markdown: typeof info_markdown === 'string' ? info_markdown : '' }), ...(promo_position !== undefined && { promo_position: normalizedPromoPosition }), ...(promo_alignment !== undefined && { promo_alignment: normalizedPromoAlignment }) }; diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 747a8d71..b7efe2b4 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -261,7 +261,9 @@ router.get('/:slug/info', async (req, res) => { // ready-to-render markdown string happens below so the // frontend doesn't have to know about modes. 'promo_mode', - 'promo_markdown' + 'promo_markdown', + 'info_mode', + 'info_markdown' ) .first(); @@ -342,7 +344,11 @@ router.get('/:slug/info', async (req, res) => { // Per-event promotional override (#440). Frontend resolves // 'inherit' against branding_promo_markdown from public settings. promo_mode: event.promo_mode || 'inherit', - promo_markdown: event.promo_markdown || null + promo_markdown: event.promo_markdown || null, + // Info banner (#932). Same inherit/custom/off semantics as promo, + // resolved against branding_info_markdown from public settings. + info_mode: event.info_mode || 'inherit', + info_markdown: event.info_markdown || null }); } catch (error) { errorResponse(res, error, 500, 'Failed to fetch gallery info'); @@ -939,6 +945,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) hero_divider_style: req.event.hero_divider_style || 'wave', hero_image_anchor: req.event.hero_image_anchor || 'center', default_photo_sort: req.event.default_photo_sort || 'upload_date_desc', + // Info banner override (#932). GalleryAuthContext refreshes its cached + // event from THIS payload, so the fields have to travel here — /info + // alone isn't enough, the context stops reading it once the guest is + // authenticated. + info_mode: req.event.info_mode || 'inherit', + info_markdown: req.event.info_markdown || null, download_zip_ready: !!(req.event.download_zip_path && req.event.download_zip_generated_at), // Mirror of the admin-side toggle so the lightbox can decide // whether to surface original camera filenames (#508). diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 2b1c0a41..db9b15eb 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -95,6 +95,10 @@ router.get('/', async (req, res) => { branding_twitter_url: settingsObject.branding_twitter_url || '', branding_youtube_url: settingsObject.branding_youtube_url || '', branding_promo_markdown: settingsObject.branding_promo_markdown || '', + // Info banner (#932). Rendered ABOVE the photo grid, unlike the promo + // banner by the footer — an onboarding hint is useless below a gallery + // the guest has to scroll past. Empty = off everywhere. + branding_info_markdown: settingsObject.branding_info_markdown || '', branding_promo_position: settingsObject.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer', diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index da4dd466..ce92927d 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -24,6 +24,10 @@ interface GalleryLayoutProps { // 'off' hides the promo slot entirely for this event. promo_mode?: 'inherit' | 'custom' | 'off'; promo_markdown?: string | null; + // Per-event info-banner override (#932). Same three-way mode as promo, + // but the slot renders above the photo grid instead of by the footer. + info_mode?: 'inherit' | 'custom' | 'off'; + info_markdown?: string | null; }; // Effective hero-logo visibility for THIS gallery, already resolved by the // backend (per-event override, else the global branding toggle) (#756). @@ -59,6 +63,8 @@ interface GalleryLayoutProps { // Horizontal alignment for the promo content (#482). Defaults // to 'center' so the banner aligns with the footer. promo_alignment?: 'left' | 'center' | 'right'; + // Global default for the info banner (#932). Empty = off everywhere. + info_markdown?: string; }; showLogout?: boolean; onLogout?: () => void; @@ -289,6 +295,32 @@ export const GalleryLayout: React.FC = ({ ) : null; + // Info banner (#932). Resolved exactly like promo above, but rendered + // above the photo grid: the reporter's case is an onboarding hint ("use + // the menu button to filter"), which a guest must see on load rather than + // after scrolling the whole gallery. No alignment knob — this is short + // helper copy, not marketing content, so it stays centred with the grid. + const infoMode = event.info_mode || 'inherit'; + const infoMarkdown = (() => { + if (infoMode === 'off') return ''; + if (infoMode === 'custom') { + const eventMd = (event.info_markdown || '').trim(); + return eventMd || (brandingSettings?.info_markdown || ''); + } + return brandingSettings?.info_markdown || ''; + })().trim(); + + const infoSlot = infoMarkdown ? ( +
+
+ +
+
+ ) : null; + // Legal links per #441: each CMS page has show_in_footer (default true). // When BOTH are hidden we still render the surrounding row only if // there's a guest "Forget me" button or socials to show. @@ -678,6 +710,9 @@ export const GalleryLayout: React.FC = ({ )} + {/* Info banner (#932) — above the grid so guests see it on load. */} + {infoSlot} + {/* Main Content */}
{children}
diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 78fe1bfe..a8729f53 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -46,6 +46,12 @@ interface GalleryViewProps { upload_category_id?: number | null; hero_photo_id?: number | null; allow_downloads?: boolean; + // Banner overrides come from /gallery/:slug/info (the /photos response + // does NOT carry them) and are spread straight through to GalleryLayout. + promo_mode?: 'inherit' | 'custom' | 'off'; + promo_markdown?: string | null; + info_mode?: 'inherit' | 'custom' | 'off'; + info_markdown?: string | null; }; } @@ -330,6 +336,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { twitter_url: settingsData.branding_twitter_url || '', youtube_url: settingsData.branding_youtube_url || '', promo_markdown: settingsData.branding_promo_markdown || '', + info_markdown: settingsData.branding_info_markdown || '', promo_position: settingsData.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer', // Promo alignment (#482). Defaults to 'center' to match the // gallery footer; see GalleryLayout. @@ -755,7 +762,19 @@ export const GalleryView: React.FC = ({ slug, event }) => { if (hiddenUntilReveal) { const uploadsOn = Boolean(data?.event?.allow_user_uploads || event?.allow_user_uploads); return ( - +
@@ -952,11 +971,17 @@ export const GalleryView: React.FC = ({ slug, event }) => { { promo_markdown: '', promo_position: 'above_footer', promo_alignment: 'center', + info_markdown: '', }); const [currentTheme, setCurrentTheme] = useState(theme); @@ -572,6 +573,50 @@ export const BrandingPage: React.FC = () => {
+ {/* Gallery info banner (#932) — markdown rendered ABOVE the photo + grid, so guests see it on load. Distinct from the promotional + banner above, which stays by the footer for marketing copy. + Per-event override lives on the Edit Event form; this is the + global default that applies to every gallery. */} +
+

+ {t('branding.infoBanner.title', 'Gallery Info Banner')} +

+

+ {t('branding.infoBanner.description', 'A short note shown at the top of every gallery, above the photos — useful for usage hints. Leave empty to hide it. Individual events can override or switch it off.')} +

+
+
+ +