* 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:
co-authored by
Paul Nothaft
parent
ab6ca6f82f
commit
b48fa62eea
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user