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';