feat(gallery): info banner above the photo grid (#932) (#1063)

* 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 <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-16 21:33:13 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent ab6ca6f82f
commit b48fa62eea
17 changed files with 575 additions and 7 deletions
@@ -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: '[email protected]',
admin_email: '[email protected]',
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: '[email protected]',
admin_email: '[email protected]',
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();
});
});
@@ -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_<name>`
// 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();
}
};
+22
View File
@@ -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
+4 -1
View File
@@ -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 })
};
+14 -2
View File
@@ -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).
+4
View File
@@ -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',