feat(og): per-event opt-in to use hero photo as social-share preview (#474)

Background: galleryOgService already serves OG/Twitter Card meta tags
to social-crawler User-Agents (WhatsApp, Facebook, Slack, Telegram,
Discord, ~21 in total) for /gallery/:slug URLs. Today the og:image
is always the brand logo with the inline rationale "no protected
photo content".

#474 asked for a hero/cover photo preview. The trade-off is that any
URL embedded in og:image is fetched unauthenticated by every
link-preview crawler — so an opted-in image is effectively public
to anyone the gallery URL is shared to. Ship as a per-event boolean,
default FALSE, so existing galleries never start surfacing photos
without explicit admin intent.

Schema (migration 102):
  - events.og_image_share_enabled BOOLEAN NOT NULL DEFAULT FALSE.

Backend:
  - galleryOgService.buildOgMetadata: when opt-in is on AND a
    hero_photo_id is set AND the photo has a generated thumbnail,
    emit og:image as /og/gallery/:slug/cover. Falls back to the
    brand logo on any miss (deleted hero, missing thumbnail, no
    opt-in) so a half-configured gallery still gets a polished
    preview rather than a broken-image src.
  - galleryOgService.handleGalleryOgCover: new public endpoint that
    streams the hero thumbnail. Validates slug shape, checks the
    opt-in flag + hero presence + thumbnail existence; returns 404
    on any failure. ETag = thumbnail mtime + photo id so a
    regenerated thumb busts crawler caches. Cache-Control:
    public, max-age=300 (short — admins shouldn't wait an hour for
    a cover swap to land in chat previews).
  - server.js: mount the new GET /og/gallery/:slug/cover route. The
    existing nginx ^~ /og/gallery/ proxy block already covers it.
  - adminEvents.js: validator + persistence on POST + PUT.
    formatBoolean coercion so SQLite (0/1) and Postgres (boolean)
    both behave correctly.

Frontend:
  - Event type + UpdateEventData carry og_image_share_enabled.
  - EventDetailsPage adds a checkbox under the HeroPhotoSelector,
    disabled when no hero photo is picked. Help text deliberately
    spells out the public-by-design consequence — admins shouldn't
    flip this on for a sensitive gallery without realising what
    they're sharing with link-preview crawlers.

Tests: 8 new in galleryOgService.shareImage.test.js — pin the
cover-vs-logo decision contract (3 cases) plus the defensive
fallbacks (deleted hero, missing thumbnail) and the 404 contract
on the cover endpoint (4 cases). The 404 tests assert that
ensureThumbnail() is NOT called when opt-in is off, so a future
refactor can't accidentally widen the unauthenticated cover
endpoint to expose a hero the admin hasn't shared.

i18n: en + de hand-translated; nl + pt + ru + fr machine-translated
and flagged for native review per project convention.
This commit is contained in:
Paul Nothaft
2026-05-13 13:47:02 +02:00
parent 16e4d191c2
commit 0bc7e2af17
14 changed files with 488 additions and 4 deletions
@@ -0,0 +1,43 @@
/**
* Migration: Per-event opt-in for using the gallery hero photo as the
* Open Graph share image (#474).
*
* Background: galleryOgService already serves OG/Twitter Card meta
* tags to social-crawler User-Agents (WhatsApp, Facebook, Slack,
* Telegram, Discord, etc.) for /gallery/<slug> URLs — see
* frontend/nginx.conf and backend/src/services/galleryOgService.js.
* Today the og:image is always the brand logo, with the inline
* rationale "no protected photo content."
*
* #474 asks for a hero/cover photo preview. The trade-off is that
* the og:image is fetched unauthenticated by every link-preview
* crawler, so any opted-in image is effectively public. We ship
* this as a per-event boolean, default FALSE, so existing galleries
* never start surfacing photos until the admin consciously flips it
* on per gallery.
*
* When set to TRUE and the event has a hero_photo_id with a
* generated thumbnail, galleryOgService points og:image at the new
* /og/gallery/:slug/cover endpoint. With it set to FALSE (or no
* hero photo selected) the brand logo is used as before.
*
* Idempotent: re-runs are no-ops.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('events'))) return;
if (await knex.schema.hasColumn('events', 'og_image_share_enabled')) return;
await knex.schema.alterTable('events', (table) => {
// Default false everywhere so an upgrade never starts leaking the
// hero photo of a password-protected gallery without admin intent.
table.boolean('og_image_share_enabled').notNullable().defaultTo(false);
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('events'))) return;
if (!(await knex.schema.hasColumn('events', 'og_image_share_enabled'))) return;
await knex.schema.alterTable('events', (table) => {
table.dropColumn('og_image_share_enabled');
});
};
+9 -1
View File
@@ -503,8 +503,16 @@ if (process.env.NODE_ENV === 'development') {
// Slack, Facebook, etc.) don't execute JS, so the SPA's client-side meta tags // Slack, Facebook, etc.) don't execute JS, so the SPA's client-side meta tags
// never reach them. nginx routes UA-detected crawlers from /gallery/:slug to // never reach them. nginx routes UA-detected crawlers from /gallery/:slug to
// here; humans still get the SPA via try_files. // here; humans still get the SPA via try_files.
const { isSocialCrawler, handleGalleryOgRequest } = require('./src/services/galleryOgService'); const {
isSocialCrawler,
handleGalleryOgRequest,
handleGalleryOgCover,
} = require('./src/services/galleryOgService');
app.get('/og/gallery/:slug', handleGalleryOgRequest); app.get('/og/gallery/:slug', handleGalleryOgRequest);
// Public hero-photo cover served as og:image when the admin has flipped
// events.og_image_share_enabled (#474). Unauthenticated by design;
// returns 404 unless the opt-in is on AND a hero_photo_id is set.
app.get('/og/gallery/:slug/cover', handleGalleryOgCover);
// robots.txt endpoint (dynamic, served from DB settings) // robots.txt endpoint (dynamic, served from DB settings)
const { generateRobotsTxt } = require('./src/services/robotsTxtService'); const { generateRobotsTxt } = require('./src/services/robotsTxtService');
@@ -0,0 +1,239 @@
/**
* Unit tests for the per-event social-share preview opt-in (#474).
*
* Pins three contracts on `buildOgMetadata`:
* - opt-in OFF (or missing) → og:image is the brand logo
* - opt-in ON without a hero photo → og:image is the brand logo
* - opt-in ON + hero + thumbnail → og:image is the public
* /og/gallery/<slug>/cover URL
*
* Plus the `handleGalleryOgCover` 404 path so we can't accidentally
* widen the unauthenticated cover endpoint to expose a hero photo
* the admin hasn't opted into sharing.
*/
jest.mock('../database/db', () => {
const mockDb = jest.fn();
return { db: mockDb };
});
jest.mock('../utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
jest.mock('../services/imageProcessor', () => ({
ensureThumbnail: jest.fn(),
}));
jest.mock('../services/storage', () => ({
getStorage: jest.fn(),
}));
const { db } = require('../database/db');
const { ensureThumbnail } = require('../services/imageProcessor');
const { getStorage } = require('../services/storage');
const {
buildOgMetadata,
handleGalleryOgCover,
} = require('../services/galleryOgService');
// The service hits two tables in sequence:
// 1. events (slug lookup → may then hit event_slug_redirects)
// 2. app_settings (branding lookup)
// then optionally a third query when og_image_share_enabled is true:
// 3. photos (validate hero exists + has thumbnail)
//
// Each test queues responses on the shared mock in the order the
// service calls them.
function chain(result) {
const q = {};
['where', 'whereIn', 'andWhere', 'select', 'orderBy', 'limit', 'first']
.forEach((m) => { q[m] = jest.fn().mockReturnValue(q); });
q.first = jest.fn().mockResolvedValue(result?.first);
q.then = (resolve) => Promise.resolve(result?.rows ?? []).then(resolve);
q.catch = () => q;
return q;
}
function mockResolveSlug(event) {
// events table query → return event row (or null + no redirects).
db.mockImplementationOnce(() => chain({ first: event || null }));
if (!event) {
// event_slug_redirects fallback — unused here, return null.
db.schema = db.schema || {};
db.schema.hasTable = jest.fn().mockResolvedValue(false);
}
}
function mockBranding() {
// app_settings → fetchBranding rows. Empty = pure defaults.
db.mockImplementationOnce(() => chain({ rows: [] }));
}
function mockHeroPhoto(photo) {
db.mockImplementationOnce(() => chain({ first: photo }));
}
beforeEach(() => {
db.mockReset();
ensureThumbnail.mockReset();
getStorage.mockReset();
process.env.FRONTEND_URL = 'https://gallery.example.com';
});
// ---- buildOgMetadata: cover-vs-logo decision ---------------------------
describe('buildOgMetadata — share-image opt-in', () => {
it('uses the brand logo when og_image_share_enabled is false (default)', async () => {
mockResolveSlug({
id: 1,
slug: 'wedding-2026',
event_name: 'Wedding 2026',
event_date: '2026-06-12',
welcome_message: null,
hero_photo_id: 99, // hero IS picked
og_image_share_enabled: false, // ...but opt-in is off
});
mockBranding();
const meta = await buildOgMetadata('wedding-2026', '/gallery/wedding-2026');
// Falls back to the default logo URL — the picpeak-logo asset
// since branding has no logo configured.
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
// Confirm the photos table was NOT queried — opt-in off means no
// hero lookup at all.
expect(db).toHaveBeenCalledTimes(2); // events + app_settings only
});
it('uses the brand logo when opt-in is on but no hero photo is picked', async () => {
mockResolveSlug({
id: 2,
slug: 'engagement',
event_name: 'Engagement',
event_date: null,
welcome_message: null,
hero_photo_id: null, // no hero
og_image_share_enabled: true, // opt-in IS on
});
mockBranding();
const meta = await buildOgMetadata('engagement', '/gallery/engagement');
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
// photos table NOT queried — service short-circuits when hero_photo_id
// is falsy, even with opt-in on.
expect(db).toHaveBeenCalledTimes(2);
});
it('uses the cover URL when opt-in is on AND hero exists with a thumbnail', async () => {
mockResolveSlug({
id: 3,
slug: 'birthday-2026',
event_name: 'Birthday 2026',
event_date: '2026-04-15',
welcome_message: null,
hero_photo_id: 42,
og_image_share_enabled: true,
});
mockBranding();
mockHeroPhoto({
id: 42,
thumbnail_path: 'thumbnails/thumb_birthday_42.jpg',
});
const meta = await buildOgMetadata('birthday-2026', '/gallery/birthday-2026');
expect(meta.image).toBe('https://gallery.example.com/og/gallery/birthday-2026/cover');
});
it('falls back to the brand logo if the hero photo row is missing', async () => {
// Defensive: hero_photo_id points to a photo that no longer
// exists (e.g. deleted after admin enabled the toggle). The OG
// page must still render with the logo, never a broken image
// src in WhatsApp previews.
mockResolveSlug({
id: 4,
slug: 'orphan',
event_name: 'Orphan',
hero_photo_id: 999,
og_image_share_enabled: true,
});
mockBranding();
mockHeroPhoto(null); // photo deleted
const meta = await buildOgMetadata('orphan', '/gallery/orphan');
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
});
it('falls back to the brand logo if the hero photo has no thumbnail yet', async () => {
// The hero exists but the background processor hasn't generated
// its thumbnail yet (or the regenerate failed). Same fallback.
mockResolveSlug({
id: 5,
slug: 'just-uploaded',
event_name: 'Just Uploaded',
hero_photo_id: 7,
og_image_share_enabled: true,
});
mockBranding();
mockHeroPhoto({ id: 7, thumbnail_path: null });
const meta = await buildOgMetadata('just-uploaded', '/gallery/just-uploaded');
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
});
});
// ---- handleGalleryOgCover: unauthenticated 404 contract ----------------
function makeRes() {
const res = { headers: {} };
res.status = jest.fn().mockReturnValue(res);
res.type = jest.fn().mockReturnValue(res);
res.send = jest.fn().mockReturnValue(res);
res.set = jest.fn((kv) => { Object.assign(res.headers, kv); return res; });
res.setHeader = jest.fn((k, v) => { res.headers[k] = v; });
res.end = jest.fn().mockReturnValue(res);
return res;
}
describe('handleGalleryOgCover — 404 unless explicitly opted in', () => {
it('returns 400 on an invalid slug shape', async () => {
const req = { params: { slug: '../../etc/passwd' }, headers: {} };
const res = makeRes();
await handleGalleryOgCover(req, res);
expect(res.status).toHaveBeenCalledWith(400);
});
it('returns 404 when the event has og_image_share_enabled = false', async () => {
mockResolveSlug({
id: 1,
slug: 'wedding-2026',
hero_photo_id: 99,
og_image_share_enabled: false,
});
const req = { params: { slug: 'wedding-2026' }, headers: {} };
const res = makeRes();
await handleGalleryOgCover(req, res);
expect(res.status).toHaveBeenCalledWith(404);
// Crucial: ensureThumbnail must NOT be called — we never want to
// touch the storage backend for a non-opted-in gallery.
expect(ensureThumbnail).not.toHaveBeenCalled();
});
it('returns 404 when the event opts in but has no hero_photo_id', async () => {
mockResolveSlug({
id: 2,
slug: 'engagement',
hero_photo_id: null,
og_image_share_enabled: true,
});
const req = { params: { slug: 'engagement' }, headers: {} };
const res = makeRes();
await handleGalleryOgCover(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(ensureThumbnail).not.toHaveBeenCalled();
});
});
+19 -1
View File
@@ -400,6 +400,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
// off → suppress entirely for this event // off → suppress entirely for this event
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']), body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
body('promo_markdown').optional({ nullable: true }).isString(), body('promo_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.
body('og_image_share_enabled').optional().isBoolean(),
// Customer accounts assigned to this event (#354). Optional array of // Customer accounts assigned to this event (#354). Optional array of
// customer_accounts.id — many-to-many via event_customer_assignments. // customer_accounts.id — many-to-many via event_customer_assignments.
body('customer_account_ids').optional().isArray(), body('customer_account_ids').optional().isArray(),
@@ -663,7 +667,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
...(client_access_enabled && client_password ? { ...(client_access_enabled && client_password ? {
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()), client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
client_share_token: crypto.randomBytes(32).toString('hex') client_share_token: crypto.randomBytes(32).toString('hex')
} : {}) } : {}),
// Per-event opt-in for hero-photo OG share image (#474). Defaults
// false on create — admin opts in from the event detail page once
// they've picked a hero they're comfortable surfacing publicly.
og_image_share_enabled: formatBoolean(req.body.og_image_share_enabled === true),
}).returning('id'); }).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -1156,6 +1164,10 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
// off → suppress entirely for this event // off → suppress entirely for this event
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']), body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
body('promo_markdown').optional({ nullable: true }).isString(), body('promo_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.
body('og_image_share_enabled').optional().isBoolean(),
// Customer accounts assigned to this event (#354). Optional array of // Customer accounts assigned to this event (#354). Optional array of
// customer_accounts.id — many-to-many via event_customer_assignments. // customer_accounts.id — many-to-many via event_customer_assignments.
body('customer_account_ids').optional().isArray(), body('customer_account_ids').optional().isArray(),
@@ -1317,6 +1329,12 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible); updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
} }
// Per-event opt-in for hero-photo OG share image (#474). Coerce so
// SQLite stores 0/1 and Postgres stores boolean true/false.
if (Object.prototype.hasOwnProperty.call(updates, 'og_image_share_enabled')) {
updates.og_image_share_enabled = formatBoolean(updates.og_image_share_enabled === true);
}
// Per-event promotional override (#440). Normalize promo_markdown to // Per-event promotional override (#440). Normalize promo_markdown to
// NULL when mode is anything other than 'custom' so we don't carry // NULL when mode is anything other than 'custom' so we don't carry
// stale text after the admin switches modes. Empty markdown also // stale text after the admin switches modes. Empty markdown also
+97 -2
View File
@@ -1,5 +1,7 @@
const { db } = require('../database/db'); const { db } = require('../database/db');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { ensureThumbnail } = require('./imageProcessor');
const { getStorage } = require('./storage');
const SOCIAL_CRAWLER_PATTERNS = [ const SOCIAL_CRAWLER_PATTERNS = [
/facebookexternalhit/i, /facebookexternalhit/i,
@@ -149,10 +151,27 @@ async function buildOgMetadata(slug, requestPath) {
description = `Photo gallery from ${eventName}.`; description = `Photo gallery from ${eventName}.`;
} }
// Per-event hero-photo opt-in (#474). When the admin has flipped
// events.og_image_share_enabled AND a hero_photo_id is set AND that
// photo has a generated thumbnail, point og:image at the public
// cover endpoint instead of the brand logo. Falls back silently to
// the logo on any of those misses so a half-configured event still
// gets a polished link preview rather than a broken image.
let image = logoUrl;
if (event.og_image_share_enabled && event.hero_photo_id) {
const heroPhoto = await db('photos')
.where({ id: event.hero_photo_id, event_id: event.id })
.select('id', 'thumbnail_path')
.first();
if (heroPhoto && heroPhoto.thumbnail_path) {
image = `${base}/og/gallery/${event.slug}/cover`;
}
}
return { return {
title, title,
description, description,
image: logoUrl, image,
url: `${base}/gallery/${event.slug}`, url: `${base}/gallery/${event.slug}`,
siteName, siteName,
eventName, eventName,
@@ -210,9 +229,85 @@ async function handleGalleryOgRequest(req, res) {
} }
} }
/**
* Public cover-image endpoint for OG/Twitter Card previews (#474).
*
* Streams the gallery's hero-photo thumbnail unauthenticated — but
* ONLY when the admin has flipped events.og_image_share_enabled on
* that event. Any miss (slug not found, opt-in not set, no hero, no
* thumbnail) returns 404; buildOgMetadata above falls back to the
* brand logo for the og:image when this would 404, so callers never
* see a broken-image preview.
*
* Why a dedicated endpoint instead of reusing /api/gallery/:slug/
* thumbnail/:photoId — the latter is gated by verifyGalleryAccess
* (gallery JWT or per-event password). Social crawlers don't carry
* either, so we need a separate, explicitly-public path that the
* admin opted into.
*/
async function handleGalleryOgCover(req, res) {
try {
const { slug } = req.params;
if (!slug || !/^[a-zA-Z0-9_-]{1,255}$/.test(slug)) {
res.status(400).type('text/plain').send('Invalid gallery slug');
return;
}
const event = await resolveSlug(slug);
if (!event || !event.og_image_share_enabled || !event.hero_photo_id) {
res.status(404).type('text/plain').send('Cover not available');
return;
}
const photo = await db('photos')
.where({ id: event.hero_photo_id, event_id: event.id })
.first();
if (!photo) {
res.status(404).type('text/plain').send('Cover not available');
return;
}
const thumbnailPath = await ensureThumbnail(photo);
if (!thumbnailPath) {
res.status(404).type('text/plain').send('Cover not available');
return;
}
const storage = getStorage();
const stat = await storage.stat(thumbnailPath);
if (!stat) {
res.status(404).type('text/plain').send('Cover not available');
return;
}
// ETag = thumbnail mtime + photo id so a regenerated thumb (e.g.
// after the admin changes thumbnail fit mode) busts crawler
// caches. Keep the cache window short on the response itself —
// crawlers like WhatsApp re-fetch eagerly; admins shouldn't have
// to wait an hour for a swap to land in chat previews.
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const etag = `"og-cover-${photo.id}-${mtimeMs}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=300',
'X-Content-Type-Options': 'nosniff',
'ETag': etag,
});
if (stat.size) res.setHeader('Content-Length', stat.size);
const stream = await storage.get(thumbnailPath);
stream.pipe(res);
} catch (error) {
logger.error('Failed to stream gallery OG cover', { error: error.message });
res.status(500).type('text/plain').send('Internal server error');
}
}
module.exports = { module.exports = {
isSocialCrawler, isSocialCrawler,
buildOgMetadata, buildOgMetadata,
renderOgHtml, renderOgHtml,
handleGalleryOgRequest handleGalleryOgRequest,
handleGalleryOgCover
}; };
+5
View File
@@ -1011,6 +1011,11 @@
"mode_off": "Aus (für dieses Event ausblenden)", "mode_off": "Aus (für dieses Event ausblenden)",
"placeholder": "Markdown-Inhalt (z. B. **Aktion:** [jetzt Termin buchen](https://example.com))", "placeholder": "Markdown-Inhalt (z. B. **Aktion:** [jetzt Termin buchen](https://example.com))",
"preview": "Vorschau" "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.",
"heroRequired": "Wähle zuerst oben ein Heldenbild — diese Option verwendet es als WhatsApp- / Facebook- / Slack-Vorschaubild."
} }
}, },
"settings": { "settings": {
+5
View File
@@ -650,6 +650,11 @@
"mode_off": "Off (hide for this event)", "mode_off": "Off (hide for this event)",
"placeholder": "Markdown content (e.g. **Special offer:** [book your next session](https://example.com))", "placeholder": "Markdown content (e.g. **Special offer:** [book your next session](https://example.com))",
"preview": "Preview" "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.",
"heroRequired": "Pick a hero photo above first — this option uses it as the WhatsApp / Facebook / Slack preview image."
} }
}, },
"settings": { "settings": {
+5
View File
@@ -664,6 +664,11 @@
"mode_off": "Désactivé (masquer pour cet événement)", "mode_off": "Désactivé (masquer pour cet événement)",
"placeholder": "Contenu Markdown (ex. **Offre :** [réservez votre prochaine séance](https://example.com))", "placeholder": "Contenu Markdown (ex. **Offre :** [réservez votre prochaine séance](https://example.com))",
"preview": "Aperçu" "preview": "Aperçu"
},
"ogShare": {
"title": "Utiliser la photo principale comme aperçu de partage social",
"help": "Quand lURL de cette galerie est partagée sur WhatsApp, Facebook, Slack, etc., laperçu du lien affichera la photo principale ci-dessus. La vignette est récupérée sans authentification par les robots daperçu — toute personne partageant lURL rend cette image effectivement publique. Désactivé par défaut ; choisissez une photo principale que vous êtes prêt à exposer publiquement avant dactiver.",
"heroRequired": "Choisissez dabord une photo principale ci-dessus — cette option lutilise comme image daperçu sur WhatsApp / Facebook / Slack."
} }
}, },
"settings": { "settings": {
+5
View File
@@ -650,6 +650,11 @@
"mode_off": "Uit (verbergen voor dit evenement)", "mode_off": "Uit (verbergen voor dit evenement)",
"placeholder": "Markdown-inhoud (bijv. **Aanbieding:** [boek je volgende sessie](https://example.com))", "placeholder": "Markdown-inhoud (bijv. **Aanbieding:** [boek je volgende sessie](https://example.com))",
"preview": "Voorbeeld" "preview": "Voorbeeld"
},
"ogShare": {
"title": "Hero-foto gebruiken als social-share-voorbeeld",
"help": "Wanneer deze galerij-URL wordt gedeeld op WhatsApp, Facebook, Slack enz., toont de link-preview de bovenstaande hero-foto. De thumbnail wordt niet-geauthenticeerd opgehaald door link-preview-crawlers — wie de URL deelt maakt deze afbeelding daarmee in feite openbaar. Standaard uit; kies eerst een hero die je bewust openbaar wilt tonen voordat je dit inschakelt.",
"heroRequired": "Kies eerst hierboven een hero-foto — deze optie gebruikt die als WhatsApp- / Facebook- / Slack-voorbeeldafbeelding."
} }
}, },
"settings": { "settings": {
+5
View File
@@ -666,6 +666,11 @@
"mode_off": "Desligado (ocultar neste evento)", "mode_off": "Desligado (ocultar neste evento)",
"placeholder": "Conteúdo em Markdown (ex.: **Oferta especial:** [agende sua próxima sessão](https://example.com))", "placeholder": "Conteúdo em Markdown (ex.: **Oferta especial:** [agende sua próxima sessão](https://example.com))",
"preview": "Pré-visualização" "preview": "Pré-visualização"
},
"ogShare": {
"title": "Usar a foto principal como visualização ao compartilhar",
"help": "Quando o URL desta galeria é compartilhado no WhatsApp, Facebook, Slack etc., a pré-visualização do link mostrará a foto principal acima. A miniatura é obtida sem autenticação pelos rastreadores de pré-visualização — quem compartilha o URL torna esta imagem efetivamente pública. Desativado por padrão; escolha uma foto principal que você esteja confortável em expor publicamente antes de ativar.",
"heroRequired": "Escolha primeiro uma foto principal acima — esta opção a usa como imagem de pré-visualização no WhatsApp / Facebook / Slack."
} }
}, },
"settings": { "settings": {
+5
View File
@@ -682,6 +682,11 @@
"mode_off": "Выключено (скрыть для этого события)", "mode_off": "Выключено (скрыть для этого события)",
"placeholder": "Содержимое в Markdown (например, **Спецпредложение:** [записаться](https://example.com))", "placeholder": "Содержимое в Markdown (например, **Спецпредложение:** [записаться](https://example.com))",
"preview": "Предпросмотр" "preview": "Предпросмотр"
},
"ogShare": {
"title": "Использовать главное фото как превью при шаринге",
"help": "Когда ссылку на эту галерею делят в WhatsApp, Facebook, Slack и т.п., в превью будет показано выбранное выше главное фото. Миниатюру забирают краулеры превью без авторизации — каждый, кто делится ссылкой, фактически делает это изображение публичным. По умолчанию выключено; выберите главное фото, которое вы готовы показать публично, прежде чем включать.",
"heroRequired": "Сначала выберите главное фото выше — эта опция использует его как превью в WhatsApp / Facebook / Slack."
} }
}, },
"settings": { "settings": {
@@ -295,6 +295,8 @@ export const EventDetailsPage: React.FC = () => {
// the GET /admin/events/:id response and sent back as a flat id // the GET /admin/events/:id response and sent back as a flat id
// array on save. // array on save.
customer_accounts: Array<{ id: number; email: string; displayName: string | null }>; customer_accounts: Array<{ id: number; email: string; displayName: string | null }>;
// Per-event opt-in for hero photo as social-share preview (#474).
og_image_share_enabled: boolean;
}; };
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -337,6 +339,10 @@ export const EventDetailsPage: React.FC = () => {
promo_markdown: '', promo_markdown: '',
// Customer accounts (#354) — hydrated from event response. // Customer accounts (#354) — hydrated from event response.
customer_accounts: [], customer_accounts: [],
// Per-event social-share opt-in (#474). Default false everywhere
// so a freshly opened editor never displays "on" against the saved
// (off) state.
og_image_share_enabled: false,
}); });
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({ const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false, feedback_enabled: false,
@@ -600,6 +606,10 @@ export const EventDetailsPage: React.FC = () => {
// the picker's shape. // the picker's shape.
customer_accounts: ((event as { customer_accounts?: Array<{ id: number; email: string; display_name?: string | null }> }).customer_accounts || []) customer_accounts: ((event as { customer_accounts?: Array<{ id: number; email: string; display_name?: string | null }> }).customer_accounts || [])
.map((c) => ({ id: c.id, email: c.email, displayName: c.display_name ?? null })), .map((c) => ({ id: c.id, email: c.email, displayName: c.display_name ?? null })),
// Per-event social-share opt-in (#474). Coerce explicitly so
// SQLite's 0/1 and Postgres's true/false both render the switch
// in the right state on first paint.
og_image_share_enabled: event.og_image_share_enabled === true,
}); });
setShowNewPassword(false); setShowNewPassword(false);
@@ -764,6 +774,10 @@ export const EventDetailsPage: React.FC = () => {
if (editForm.hero_photo_id !== undefined) { if (editForm.hero_photo_id !== undefined) {
updateData.hero_photo_id = editForm.hero_photo_id; updateData.hero_photo_id = editForm.hero_photo_id;
} }
// Per-event hero-photo OG share opt-in (#474). Always send the
// current state — the backend writes through formatBoolean either
// way, so an explicit save can flip the value back to false.
updateData.og_image_share_enabled = editForm.og_image_share_enabled;
updateData.source_mode = editForm.source_mode; updateData.source_mode = editForm.source_mode;
updateData.external_path = editForm.source_mode === 'reference' updateData.external_path = editForm.source_mode === 'reference'
? externalPathToSave ? externalPathToSave
@@ -1173,6 +1187,35 @@ export const EventDetailsPage: React.FC = () => {
isEditing={isEditing} isEditing={isEditing}
/> />
{/* Per-event social-share opt-in (#474). Toggle is
disabled when no hero photo is picked there's
nothing to surface as the cover. The help text
deliberately spells out the public-by-design
consequence so an admin doesn't flip this on for
a sensitive gallery without realising what they're
sharing with link-preview crawlers. */}
<div className="ml-6 mt-3">
<label className={`flex items-start gap-2 cursor-pointer ${editForm.hero_photo_id ? '' : 'opacity-60 cursor-not-allowed'}`}>
<input
type="checkbox"
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
checked={editForm.og_image_share_enabled === true}
disabled={!editForm.hero_photo_id}
onChange={(e) => setEditForm(prev => ({ ...prev, og_image_share_enabled: e.target.checked }))}
/>
<span className="text-sm">
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{t('events.ogShare.title', 'Use hero photo as social-share preview')}
</span>
<span className="block text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
{editForm.hero_photo_id
? t('events.ogShare.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.')
: t('events.ogShare.heroRequired', 'Pick a hero photo above first — this option uses it as the WhatsApp / Facebook / Slack preview image.')}
</span>
</span>
</label>
</div>
{/* Hero Image Focal Point Picker (#162) */} {/* Hero Image Focal Point Picker (#162) */}
{editForm.hero_photo_id && (() => { {editForm.hero_photo_id && (() => {
const heroPhoto = (photos || []).find((p) => p.id === editForm.hero_photo_id); const heroPhoto = (photos || []).find((p) => p.id === editForm.hero_photo_id);
+2
View File
@@ -66,6 +66,8 @@ interface UpdateEventData {
external_path?: string | null; external_path?: string | null;
photo_cap?: number | null; photo_cap?: number | null;
default_photo_sort?: string; default_photo_sort?: string;
// Per-event opt-in for hero photo as social-share preview (#474).
og_image_share_enabled?: boolean;
// Customer accounts (#354). Same semantics as on CreateEventData; // Customer accounts (#354). Same semantics as on CreateEventData;
// omit the field to leave assignments untouched, send [] to clear. // omit the field to leave assignments untouched, send [] to clear.
customer_account_ids?: number[]; customer_account_ids?: number[];
+6
View File
@@ -47,6 +47,12 @@ export interface Event {
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge'; hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
hero_logo_position?: 'top' | 'center' | 'bottom'; hero_logo_position?: 'top' | 'center' | 'bottom';
hero_logo_url?: string | null; hero_logo_url?: string | null;
// Per-event opt-in for using the hero photo as the social-share
// preview image (#474). When false, og:image falls back to the
// brand logo. Defaults false on existing rows so no admin's hero
// photo gets surfaced via WhatsApp share until they consciously
// flip it on.
og_image_share_enabled?: boolean;
// Header style settings (decoupled from layout) // Header style settings (decoupled from layout)
header_style?: 'hero' | 'standard' | 'minimal' | 'none'; header_style?: 'hero' | 'standard' | 'minimal' | 'none';
hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none'; hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none';