From 0bc7e2af171d1a4c6e91ba541d293b90c17c111b Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 13 May 2026 13:47:02 +0200 Subject: [PATCH] feat(og): per-event opt-in to use hero photo as social-share preview (#474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../core/102_add_og_image_share_enabled.js | 43 ++++ backend/server.js | 10 +- .../galleryOgService.shareImage.test.js | 239 ++++++++++++++++++ backend/src/routes/adminEvents.js | 20 +- backend/src/services/galleryOgService.js | 99 +++++++- frontend/src/i18n/locales/de.json | 5 + frontend/src/i18n/locales/en.json | 5 + frontend/src/i18n/locales/fr.json | 5 + frontend/src/i18n/locales/nl.json | 5 + frontend/src/i18n/locales/pt.json | 5 + frontend/src/i18n/locales/ru.json | 5 + frontend/src/pages/admin/EventDetailsPage.tsx | 43 ++++ frontend/src/services/events.service.ts | 2 + frontend/src/types/index.ts | 6 + 14 files changed, 488 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/core/102_add_og_image_share_enabled.js create mode 100644 backend/src/__tests__/galleryOgService.shareImage.test.js diff --git a/backend/migrations/core/102_add_og_image_share_enabled.js b/backend/migrations/core/102_add_og_image_share_enabled.js new file mode 100644 index 00000000..48bf2a71 --- /dev/null +++ b/backend/migrations/core/102_add_og_image_share_enabled.js @@ -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/ 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'); + }); +}; diff --git a/backend/server.js b/backend/server.js index fbcdb2f1..7746487b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 // never reach them. nginx routes UA-detected crawlers from /gallery/:slug to // 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); +// 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) const { generateRobotsTxt } = require('./src/services/robotsTxtService'); diff --git a/backend/src/__tests__/galleryOgService.shareImage.test.js b/backend/src/__tests__/galleryOgService.shareImage.test.js new file mode 100644 index 00000000..7e9e623a --- /dev/null +++ b/backend/src/__tests__/galleryOgService.shareImage.test.js @@ -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//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(); + }); +}); diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index ae1443dc..ed04d418 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -400,6 +400,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [ // off → suppress entirely for this event body('promo_mode').optional().isIn(['inherit', 'custom', 'off']), 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.id — many-to-many via event_customer_assignments. body('customer_account_ids').optional().isArray(), @@ -663,7 +667,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [ ...(client_access_enabled && client_password ? { client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()), 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'); // 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 body('promo_mode').optional().isIn(['inherit', 'custom', 'off']), 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.id — many-to-many via event_customer_assignments. 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); } + // 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 // NULL when mode is anything other than 'custom' so we don't carry // stale text after the admin switches modes. Empty markdown also diff --git a/backend/src/services/galleryOgService.js b/backend/src/services/galleryOgService.js index 135363eb..038a279c 100644 --- a/backend/src/services/galleryOgService.js +++ b/backend/src/services/galleryOgService.js @@ -1,5 +1,7 @@ const { db } = require('../database/db'); const logger = require('../utils/logger'); +const { ensureThumbnail } = require('./imageProcessor'); +const { getStorage } = require('./storage'); const SOCIAL_CRAWLER_PATTERNS = [ /facebookexternalhit/i, @@ -149,10 +151,27 @@ async function buildOgMetadata(slug, requestPath) { 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 { title, description, - image: logoUrl, + image, url: `${base}/gallery/${event.slug}`, siteName, 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 = { isSocialCrawler, buildOgMetadata, renderOgHtml, - handleGalleryOgRequest + handleGalleryOgRequest, + handleGalleryOgCover }; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index be0d9a66..e76ca0e9 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1011,6 +1011,11 @@ "mode_off": "Aus (für dieses Event ausblenden)", "placeholder": "Markdown-Inhalt (z. B. **Aktion:** [jetzt Termin buchen](https://example.com))", "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": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 24d31111..78740416 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -650,6 +650,11 @@ "mode_off": "Off (hide for this event)", "placeholder": "Markdown content (e.g. **Special offer:** [book your next session](https://example.com))", "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": { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 3eac8951..b7c88417 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -664,6 +664,11 @@ "mode_off": "Désactivé (masquer pour cet événement)", "placeholder": "Contenu Markdown (ex. **Offre :** [réservez votre prochaine séance](https://example.com))", "preview": "Aperçu" + }, + "ogShare": { + "title": "Utiliser la photo principale comme aperçu de partage social", + "help": "Quand l’URL de cette galerie est partagée sur WhatsApp, Facebook, Slack, etc., l’aperçu du lien affichera la photo principale ci-dessus. La vignette est récupérée sans authentification par les robots d’aperçu — toute personne partageant l’URL rend cette image effectivement publique. Désactivé par défaut ; choisissez une photo principale que vous êtes prêt à exposer publiquement avant d’activer.", + "heroRequired": "Choisissez d’abord une photo principale ci-dessus — cette option l’utilise comme image d’aperçu sur WhatsApp / Facebook / Slack." } }, "settings": { diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index bd44f02b..a86866b3 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -650,6 +650,11 @@ "mode_off": "Uit (verbergen voor dit evenement)", "placeholder": "Markdown-inhoud (bijv. **Aanbieding:** [boek je volgende sessie](https://example.com))", "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": { diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index c47e4126..b872e41f 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -666,6 +666,11 @@ "mode_off": "Desligado (ocultar neste evento)", "placeholder": "Conteúdo em Markdown (ex.: **Oferta especial:** [agende sua próxima sessão](https://example.com))", "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": { diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index e4ccd633..5f12e38b 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -682,6 +682,11 @@ "mode_off": "Выключено (скрыть для этого события)", "placeholder": "Содержимое в Markdown (например, **Спецпредложение:** [записаться](https://example.com))", "preview": "Предпросмотр" + }, + "ogShare": { + "title": "Использовать главное фото как превью при шаринге", + "help": "Когда ссылку на эту галерею делят в WhatsApp, Facebook, Slack и т.п., в превью будет показано выбранное выше главное фото. Миниатюру забирают краулеры превью без авторизации — каждый, кто делится ссылкой, фактически делает это изображение публичным. По умолчанию выключено; выберите главное фото, которое вы готовы показать публично, прежде чем включать.", + "heroRequired": "Сначала выберите главное фото выше — эта опция использует его как превью в WhatsApp / Facebook / Slack." } }, "settings": { diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index f27a5019..d49992ae 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -295,6 +295,8 @@ export const EventDetailsPage: React.FC = () => { // the GET /admin/events/:id response and sent back as a flat id // array on save. 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); @@ -337,6 +339,10 @@ export const EventDetailsPage: React.FC = () => { promo_markdown: '', // Customer accounts (#354) — hydrated from event response. 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({ feedback_enabled: false, @@ -600,6 +606,10 @@ export const EventDetailsPage: React.FC = () => { // the picker's shape. 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 })), + // 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); @@ -764,6 +774,10 @@ export const EventDetailsPage: React.FC = () => { if (editForm.hero_photo_id !== undefined) { 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.external_path = editForm.source_mode === 'reference' ? externalPathToSave @@ -1173,6 +1187,35 @@ export const EventDetailsPage: React.FC = () => { 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. */} +
+ +
+ {/* Hero Image Focal Point Picker (#162) */} {editForm.hero_photo_id && (() => { const heroPhoto = (photos || []).find((p) => p.id === editForm.hero_photo_id); diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index 61f134dd..3f21fcbe 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -66,6 +66,8 @@ interface UpdateEventData { external_path?: string | null; photo_cap?: number | null; 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; // omit the field to leave assignments untouched, send [] to clear. customer_account_ids?: number[]; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 19b6f6a3..8d8eeef0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -47,6 +47,12 @@ export interface Event { hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge'; hero_logo_position?: 'top' | 'center' | 'bottom'; 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?: 'hero' | 'standard' | 'minimal' | 'none'; hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none';