* 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 <paul@MacStudio-von-Paul.local>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
};
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<GalleryLayoutProps> = ({
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className="gallery-info-banner border-b border-surface bg-surface/50">
|
||||
<div className="container py-3 sm:py-4 px-4">
|
||||
<MarkdownContent
|
||||
source={infoMarkdown}
|
||||
className="prose prose-sm max-w-none text-sm text-theme prose-a:text-accent text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : 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<GalleryLayoutProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info banner (#932) — above the grid so guests see it on load. */}
|
||||
{infoSlot}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container">{children}</main>
|
||||
|
||||
|
||||
@@ -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<GalleryViewProps> = ({ 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<GalleryViewProps> = ({ slug, event }) => {
|
||||
if (hiddenUntilReveal) {
|
||||
const uploadsOn = Boolean(data?.event?.allow_user_uploads || event?.allow_user_uploads);
|
||||
return (
|
||||
<GalleryLayout event={event} brandingSettings={brandingSettings}>
|
||||
<GalleryLayout
|
||||
event={{
|
||||
...event,
|
||||
// The reveal-hidden view still renders the chrome, so the info
|
||||
// banner resolves here too. The context `event` comes from the
|
||||
// gallery login response and carries no banner fields, which would
|
||||
// silently downgrade a per-event 'off' to 'inherit' and show the
|
||||
// global banner on a gallery the admin muted (#932).
|
||||
info_mode: (data?.event as { info_mode?: 'inherit' | 'custom' | 'off' })?.info_mode,
|
||||
info_markdown: (data?.event as { info_markdown?: string | null })?.info_markdown,
|
||||
}}
|
||||
brandingSettings={brandingSettings}
|
||||
>
|
||||
<div className="max-w-xl mx-auto text-center py-16 px-4">
|
||||
<div className="mx-auto mb-5 w-16 h-16 rounded-full bg-surface flex items-center justify-center">
|
||||
<EyeOff className="w-8 h-8 text-muted-theme" />
|
||||
@@ -952,11 +971,17 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
<GalleryLayout
|
||||
event={{
|
||||
...event,
|
||||
// Per-event promo override (#440). Sourced from the gallery
|
||||
// /info response so the layout can decide between inherit /
|
||||
// custom / off without a second fetch.
|
||||
// Per-event promo override (#440).
|
||||
promo_mode: (data?.event as { promo_mode?: 'inherit' | 'custom' | 'off' })?.promo_mode,
|
||||
promo_markdown: (data?.event as { promo_markdown?: string | null })?.promo_markdown,
|
||||
// Per-event info-banner override (#932). Read from the /photos
|
||||
// response rather than the context event: the context is seeded from
|
||||
// the gallery LOGIN response, which carries only a small identity
|
||||
// subset, so anything not in that subset 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 too.
|
||||
info_mode: (data?.event as { info_mode?: 'inherit' | 'custom' | 'off' })?.info_mode,
|
||||
info_markdown: (data?.event as { info_markdown?: string | null })?.info_markdown,
|
||||
}}
|
||||
brandingSettings={brandingSettings}
|
||||
heroLogoVisible={data?.event?.hero_logo_visible !== false}
|
||||
|
||||
@@ -1462,6 +1462,15 @@
|
||||
"placeholder": "Markdown-Inhalt (z. B. **Aktion:** [jetzt Termin buchen](https://example.com))",
|
||||
"preview": "Vorschau"
|
||||
},
|
||||
"infoBanner": {
|
||||
"title": "Info-Banner",
|
||||
"help": "Ein kurzer Hinweis oberhalb der Fotos in dieser Galerie. „Übernehmen“ nutzt die globale Vorgabe, „Eigener Text“ überschreibt sie für dieses Event, „Aus“ blendet ihn aus.",
|
||||
"mode_inherit": "Globale Vorgabe übernehmen",
|
||||
"mode_custom": "Eigener Text für dieses Event",
|
||||
"mode_off": "Aus (für dieses Event ausblenden)",
|
||||
"placeholder": "Über die Menü-Schaltfläche oben links lassen sich die Fotos filtern.",
|
||||
"preview": "Vorschau"
|
||||
},
|
||||
"ogShare": {
|
||||
"title": "Heldenbild als Vorschau für geteilte Links verwenden",
|
||||
"help": "Beim Teilen der Galerie-URL auf WhatsApp, Facebook, Slack usw. wird das oben gewählte Heldenbild als Link-Vorschau angezeigt. Das Thumbnail wird von Link-Preview-Crawlern ohne Authentifizierung abgerufen — wer die URL teilt, macht damit faktisch dieses Bild öffentlich. Standardmäßig aus; wähle erst ein Heldenbild, das du bewusst öffentlich zeigen möchtest, bevor du diese Option aktivierst.",
|
||||
@@ -2593,6 +2602,14 @@
|
||||
"alignLeft": "Links",
|
||||
"alignCenter": "Zentriert (Standard – wie der Footer)",
|
||||
"alignRight": "Rechts"
|
||||
},
|
||||
"infoBanner": {
|
||||
"title": "Info-Banner der Galerie",
|
||||
"description": "Ein kurzer Hinweis oberhalb der Fotos in jeder Galerie — praktisch für Bedienhinweise. Leer lassen, um ihn auszublenden. Einzelne Events können ihn überschreiben oder abschalten.",
|
||||
"content": "Inhalt (Markdown)",
|
||||
"placeholder": "Über die Menü-Schaltfläche oben links lassen sich die Fotos filtern.",
|
||||
"markdownHelp": "Fett, kursiv, Links, Listen und Überschriften werden unterstützt. HTML wird entfernt.",
|
||||
"preview": "Vorschau"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -1003,6 +1003,15 @@
|
||||
"placeholder": "Markdown content (e.g. **Special offer:** [book your next session](https://example.com))",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"infoBanner": {
|
||||
"title": "Info Banner",
|
||||
"help": "A short note shown above the photos in this gallery. \"Inherit\" uses your global default; \"Custom\" overrides it for this event; \"Off\" hides it entirely.",
|
||||
"mode_inherit": "Inherit global default",
|
||||
"mode_custom": "Custom override for this event",
|
||||
"mode_off": "Off (hide for this event)",
|
||||
"placeholder": "Use the menu button in the top-left corner to filter the photos.",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"ogShare": {
|
||||
"title": "Use hero photo as social-share preview",
|
||||
"help": "When this gallery URL is shared on WhatsApp, Facebook, Slack, etc., the link preview will show the hero photo above. The thumbnail is fetched unauthenticated by link-preview crawlers — anyone with the URL effectively makes this image public. Off by default; pick a hero you are comfortable surfacing publicly before enabling.",
|
||||
@@ -2165,6 +2174,14 @@
|
||||
"alignLeft": "Left",
|
||||
"alignCenter": "Center (default — matches footer)",
|
||||
"alignRight": "Right"
|
||||
},
|
||||
"infoBanner": {
|
||||
"title": "Gallery Info Banner",
|
||||
"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.",
|
||||
"content": "Content (markdown)",
|
||||
"placeholder": "Use the menu button in the top-left corner to filter the photos.",
|
||||
"markdownHelp": "Bold, italic, links, lists, and headings supported. HTML is stripped.",
|
||||
"preview": "Preview"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -51,6 +51,7 @@ export const BrandingPage: React.FC = () => {
|
||||
promo_markdown: '',
|
||||
promo_position: 'above_footer',
|
||||
promo_alignment: 'center',
|
||||
info_markdown: '',
|
||||
});
|
||||
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
|
||||
@@ -572,6 +573,50 @@ export const BrandingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{t('branding.infoBanner.title', 'Gallery Info Banner')}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
{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.')}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.infoBanner.content', 'Content (markdown)')}
|
||||
</label>
|
||||
<textarea
|
||||
value={brandingSettings.info_markdown || ''}
|
||||
onChange={(e) => handleBrandingChange('info_markdown', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 font-mono text-sm"
|
||||
rows={3}
|
||||
placeholder={t('branding.infoBanner.placeholder', 'Use the menu button in the top-left corner to filter the photos.')}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('branding.infoBanner.markdownHelp', 'Bold, italic, links, lists, and headings supported. HTML is stripped.')}
|
||||
</p>
|
||||
</div>
|
||||
{brandingSettings.info_markdown && brandingSettings.info_markdown.trim() && (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 p-4 bg-neutral-50 dark:bg-neutral-800/40">
|
||||
<div className="text-xs uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('branding.infoBanner.preview', 'Preview')}
|
||||
</div>
|
||||
{/* Mirrors the live gallery render — centred, same prose
|
||||
classes — so the admin sees what guests will see. */}
|
||||
<MarkdownContent
|
||||
source={brandingSettings.info_markdown}
|
||||
className="text-sm text-neutral-800 dark:text-neutral-200 prose prose-sm prose-a:text-primary-600 dark:prose-a:text-primary-400 text-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
|
||||
@@ -354,7 +354,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
|
||||
// Per-event promotional override (#440)
|
||||
promo_mode: ((event as { promo_mode?: 'inherit' | 'custom' | 'off' }).promo_mode) || 'inherit',
|
||||
info_mode: ((event as { info_mode?: 'inherit' | 'custom' | 'off' }).info_mode) || 'inherit',
|
||||
promo_markdown: (event as { promo_markdown?: string }).promo_markdown || '',
|
||||
info_markdown: (event as { info_markdown?: string }).info_markdown || '',
|
||||
// Customer accounts (#354). The backend returns
|
||||
// `customer_accounts: [{ id, email, display_name, ... }]`; map to
|
||||
// the picker's shape.
|
||||
@@ -493,6 +495,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
// promo_markdown automatically when mode != 'custom'.
|
||||
promo_mode: editForm.promo_mode,
|
||||
promo_markdown: editForm.promo_mode === 'custom' ? editForm.promo_markdown : null,
|
||||
info_mode: editForm.info_mode,
|
||||
info_markdown: editForm.info_mode === 'custom' ? editForm.info_markdown : null,
|
||||
// Customer accounts (#354) — flat array of ids. Backend diffs
|
||||
// against existing assignments in one transaction.
|
||||
customer_account_ids: editForm.customer_accounts.map((c) => c.id),
|
||||
|
||||
@@ -524,6 +524,54 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Banner Override (#932) — three-way: inherit / custom / off.
|
||||
Mirrors the promotional override above, but this banner renders
|
||||
at the TOP of the gallery, above the photos. */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{t('events.infoBanner.title', 'Info Banner')}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||
{t('events.infoBanner.help', 'A short note shown above the photos in this gallery. "Inherit" uses your global default; "Custom" overrides it for this event; "Off" hides it entirely.')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{(['inherit', 'custom', 'off'] as const).map((mode) => (
|
||||
<label key={mode} className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="info_mode"
|
||||
value={mode}
|
||||
checked={editForm.info_mode === mode}
|
||||
onChange={() => setEditForm(prev => ({ ...prev, info_mode: mode }))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t(`events.infoBanner.mode_${mode}`, mode === 'inherit' ? 'Inherit global default' : mode === 'custom' ? 'Custom override for this event' : 'Off (hide for this event)')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{editForm.info_mode === 'custom' && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<textarea
|
||||
value={editForm.info_markdown}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, info_markdown: e.target.value }))}
|
||||
rows={3}
|
||||
placeholder={t('events.infoBanner.placeholder', 'Use the menu button in the top-left corner to filter the photos.')}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark font-mono text-sm"
|
||||
/>
|
||||
{editForm.info_markdown.trim() && (
|
||||
<div className="border border-neutral-200 dark:border-neutral-700 rounded-lg p-3 bg-neutral-50 dark:bg-neutral-900">
|
||||
<p className="text-xs uppercase tracking-wide text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('events.infoBanner.preview', 'Preview')}
|
||||
</p>
|
||||
<MarkdownContent source={editForm.info_markdown} className="text-sm text-neutral-800 dark:text-neutral-200 prose-sm prose-a:text-primary-600 dark:prose-a:text-primary-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download Protection Settings */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
|
||||
|
||||
@@ -45,6 +45,9 @@ export type EditFormState = {
|
||||
// off → no promo for this event regardless of global
|
||||
promo_mode: 'inherit' | 'custom' | 'off';
|
||||
promo_markdown: string;
|
||||
// Info banner (#932) — same three-way mode, rendered above the grid.
|
||||
info_mode: 'inherit' | 'custom' | 'off';
|
||||
info_markdown: string;
|
||||
// Customer accounts assigned to this event (#354). Hydrated from
|
||||
// the GET /admin/events/:id response and sent back as a flat id
|
||||
// array on save.
|
||||
@@ -93,6 +96,8 @@ export const INITIAL_EDIT_FORM: EditFormState = {
|
||||
// Per-event promotional override (#440)
|
||||
promo_mode: 'inherit',
|
||||
promo_markdown: '',
|
||||
info_mode: 'inherit',
|
||||
info_markdown: '',
|
||||
// Customer accounts (#354) — hydrated from event response.
|
||||
customer_accounts: [],
|
||||
// Per-event social-share opt-in (#474). Default false everywhere
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Regression test for the "declared but not mapped" branding bug (#932).
|
||||
*
|
||||
* BrandingPage hydrates its form with
|
||||
*
|
||||
* setBrandingSettings(prev => ({ ...prev, ...formatted }))
|
||||
*
|
||||
* so any field that formatBrandingSettings() fails to return keeps the
|
||||
* component's empty-string initializer instead of the persisted value. The
|
||||
* form then looks blank, and the next Save posts that empty string back and
|
||||
* wipes the row — silently, because the gallery keeps rendering the old value
|
||||
* until the save lands.
|
||||
*
|
||||
* This already happened once to the footer/promo fields (#441 + #440 / #460);
|
||||
* the read mapper still carries the comment about it. Rather than test only
|
||||
* the field #932 added, assert the round-trip for every branding key the page
|
||||
* can edit, so the next person to add one is caught by this test instead of by
|
||||
* a user losing their copy.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { settingsService } from '../settings.service';
|
||||
|
||||
// Raw shape as the API returns it: `branding_`-prefixed keys.
|
||||
const RAW = {
|
||||
branding_company_name: 'Studio Nord',
|
||||
branding_promo_markdown: '**Spring offer** — 20% off prints',
|
||||
branding_promo_position: 'below_footer',
|
||||
branding_promo_alignment: 'left',
|
||||
branding_info_markdown: '**Tipp:** use the menu button to filter',
|
||||
branding_facebook_url: 'https://facebook.com/studionord',
|
||||
branding_support_email: 'hello@studionord.example',
|
||||
};
|
||||
|
||||
describe('formatBrandingSettings — persisted values survive a page load', () => {
|
||||
it('maps the info banner (#932) rather than dropping it', () => {
|
||||
const formatted = settingsService.formatBrandingSettings(RAW);
|
||||
|
||||
// The bug: an unmapped key is simply absent, so the spread in
|
||||
// BrandingPage leaves the empty initializer in place.
|
||||
expect(Object.prototype.hasOwnProperty.call(formatted, 'info_markdown')).toBe(true);
|
||||
expect(formatted.info_markdown).toBe('**Tipp:** use the menu button to filter');
|
||||
});
|
||||
|
||||
it('still maps the promo banner it mirrors', () => {
|
||||
const formatted = settingsService.formatBrandingSettings(RAW);
|
||||
|
||||
expect(formatted.promo_markdown).toBe('**Spring offer** — 20% off prints');
|
||||
expect(formatted.promo_position).toBe('below_footer');
|
||||
expect(formatted.promo_alignment).toBe('left');
|
||||
});
|
||||
|
||||
it('defaults every text field to empty string, never undefined', () => {
|
||||
// A `undefined` here would render an uncontrolled <textarea> and React
|
||||
// would warn on first keystroke; the page relies on '' being the default.
|
||||
const formatted = settingsService.formatBrandingSettings({});
|
||||
|
||||
expect(formatted.info_markdown).toBe('');
|
||||
expect(formatted.promo_markdown).toBe('');
|
||||
});
|
||||
|
||||
it('round-trips a value through format without mutating it', () => {
|
||||
const formatted = settingsService.formatBrandingSettings(RAW);
|
||||
|
||||
// Markdown must survive verbatim — no trimming or escaping on read,
|
||||
// otherwise saving an untouched form rewrites the stored copy.
|
||||
expect(formatted.info_markdown).toBe(RAW.branding_info_markdown);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,8 @@ export interface PublicSettings {
|
||||
branding_twitter_url?: string;
|
||||
branding_youtube_url?: string;
|
||||
branding_promo_markdown?: string;
|
||||
// Global default for the gallery info banner (#932).
|
||||
branding_info_markdown?: string;
|
||||
branding_promo_position?: 'above_footer' | 'below_footer';
|
||||
// Per-install promo banner alignment (#482). Defaults to 'center'
|
||||
// so the banner aligns with the gallery footer's centering.
|
||||
|
||||
@@ -54,6 +54,11 @@ export interface BrandingSettings {
|
||||
// Per-install promo banner alignment (#482). Defaults to center
|
||||
// so the banner aligns with the gallery footer.
|
||||
promo_alignment?: 'left' | 'center' | 'right';
|
||||
// Gallery info banner (#932). Must also be mapped in
|
||||
// formatBrandingSettings below — a field declared here but missing
|
||||
// from the read mapper loads empty and the next save wipes it (see
|
||||
// the note on the footer/promo block there).
|
||||
info_markdown?: string;
|
||||
}
|
||||
|
||||
export interface ThemeSettings {
|
||||
@@ -376,6 +381,7 @@ export const settingsService = {
|
||||
twitter_url: rawSettings.branding_twitter_url || '',
|
||||
youtube_url: rawSettings.branding_youtube_url || '',
|
||||
promo_markdown: rawSettings.branding_promo_markdown || '',
|
||||
info_markdown: rawSettings.branding_info_markdown || '',
|
||||
promo_position: rawSettings.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer',
|
||||
promo_alignment: ['left', 'center', 'right'].includes(rawSettings.branding_promo_alignment)
|
||||
? rawSettings.branding_promo_alignment
|
||||
|
||||
Reference in New Issue
Block a user