feat(slideshow): gate behind a feature flag + move globals to a Settings tab

- New `slideshow` feature flag (backend KNOWN_FLAGS/DEFAULT_FLAGS, frontend
  FeatureKey + context default, a toggle card under Settings -> Features -> Core).
  Default off; strictly opt-in.
- Move the global watermark defaults off the Event Types page into a dedicated
  Settings -> Slideshow tab (new SlideshowSettingsPage), shown only when the flag
  is on.
- Gate the per-event Live Slideshow card and the per-event-type preset section
  behind the flag too (and stop writing a type preset when it's off).
- en/de strings for the feature card + settings tab.
This commit is contained in:
Luca
2026-06-20 03:08:04 +02:00
parent db8388c79e
commit 69367b45be
10 changed files with 94 additions and 12 deletions
+5
View File
@@ -84,6 +84,10 @@ const KNOWN_FLAGS = [
// opt-in — operators must register a Meta-approved template before turning // opt-in — operators must register a Meta-approved template before turning
// it on. Independent of email; both can fire on the same event. // it on. Independent of email; both can fire on the same event.
'whatsapp', 'whatsapp',
// Live Slideshow ("Diashow") — the per-event fullscreen kiosk link + its
// per-event-type presets and global watermark defaults tab. Strictly opt-in;
// gates all slideshow admin UI (per-event card, type preset, settings tab).
'slideshow',
]; ];
// Spec defaults for any flag missing from the DB (e.g. a row added by a // Spec defaults for any flag missing from the DB (e.g. a row added by a
@@ -112,6 +116,7 @@ const DEFAULT_FLAGS = {
expenses: false, expenses: false,
projects: false, projects: false,
whatsapp: false, whatsapp: false,
slideshow: false,
}; };
async function readAllFlags() { async function readAllFlags() {
@@ -62,6 +62,8 @@ export const DEFAULT_FLAGS: FeatureFlags = {
projects: false, projects: false,
// WhatsApp Business API delivery channel (migration 136, #640D). // WhatsApp Business API delivery channel (migration 136, #640D).
whatsapp: false, whatsapp: false,
// Live Slideshow ("Diashow") — opt-in; gates all slideshow admin UI.
slideshow: false,
}; };
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const; export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
@@ -22,6 +22,7 @@ import {
ScanLine, ScanLine,
Wallet, Wallet,
FolderKanban, FolderKanban,
MonitorPlay,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../../components/common'; import { Button, Card } from '../../../components/common';
@@ -116,6 +117,21 @@ export const FeaturesTab: React.FC = () => {
"Galleries are the foundation of PicPeak and can't be turned off.", "Galleries are the foundation of PicPeak and can't be turned off.",
)} )}
/> />
<FeatureCard
icon={MonitorPlay}
title={t('settings.features.slideshow.title', 'Live Slideshow')}
description={t(
'settings.features.slideshow.description',
'A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads, with per-event-type presets and global watermark defaults under Settings → Slideshow.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarHidden
sidebarHiddenLabel={sidebarHiddenLabel}
enabled={staged.slideshow}
onToggle={(next) => setFlag('slideshow', next)}
/>
</Section> </Section>
{/* Clients (#354 follow-up). Visual grouping for the CRM-area {/* Clients (#354 follow-up). Visual grouping for the CRM-area
+8
View File
@@ -1768,6 +1768,10 @@
"whatsapp": { "whatsapp": {
"title": "WhatsApp", "title": "WhatsApp",
"description": "Liefert die Gallerie-Bereit-Benachrichtigung zusätzlich zur E-Mail über die WhatsApp Business API. Voraussetzung: Meta-Business-Konto, genehmigte Nachrichtenvorlage und eine Kunden-Telefonnummer am Event. Zugangsdaten unter Einstellungen → WhatsApp konfigurieren." "description": "Liefert die Gallerie-Bereit-Benachrichtigung zusätzlich zur E-Mail über die WhatsApp Business API. Voraussetzung: Meta-Business-Konto, genehmigte Nachrichtenvorlage und eine Kunden-Telefonnummer am Event. Zugangsdaten unter Einstellungen → WhatsApp konfigurieren."
},
"slideshow": {
"title": "Live-Diashow",
"description": "Ein separater Vollbild-„Diashow“-Link pro Event für Beamer bei Live-Events übernimmt neue Uploads automatisch, mit Voreinstellungen je Event-Typ und globalen Wasserzeichen-Vorgaben unter Einstellungen → Diashow."
} }
}, },
"customerSurface": { "customerSurface": {
@@ -1815,6 +1819,10 @@
"hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen." "hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen."
} }
}, },
"slideshow": {
"title": "Diashow",
"subtitle": "Globale Vorgaben für die Live-Diashow. Events und Event-Typen können diese überschreiben."
},
"whatsapp": { "whatsapp": {
"title": "WhatsApp", "title": "WhatsApp",
"subtitle": "Meta-Business-Zugangsdaten konfigurieren, um die Gallerie-Bereit-Benachrichtigung zusätzlich zur E-Mail per WhatsApp zu senden.", "subtitle": "Meta-Business-Zugangsdaten konfigurieren, um die Gallerie-Bereit-Benachrichtigung zusätzlich zur E-Mail per WhatsApp zu senden.",
+8
View File
@@ -1324,6 +1324,10 @@
"whatsapp": { "whatsapp": {
"title": "WhatsApp", "title": "WhatsApp",
"description": "Deliver the gallery-ready notification via WhatsApp Business API in addition to email. Requires a Meta Business Account, an approved message template, and a customer phone number on the event. Configure credentials under Settings → WhatsApp." "description": "Deliver the gallery-ready notification via WhatsApp Business API in addition to email. Requires a Meta Business Account, an approved message template, and a customer phone number on the event. Configure credentials under Settings → WhatsApp."
},
"slideshow": {
"title": "Live Slideshow",
"description": "A separate fullscreen \"Diashow\" link per event for projectors at live events — auto-picks-up new uploads, with per-event-type presets and global watermark defaults under Settings → Slideshow."
} }
}, },
"customerSurface": { "customerSurface": {
@@ -1371,6 +1375,10 @@
"hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate." "hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate."
} }
}, },
"slideshow": {
"title": "Slideshow",
"subtitle": "Global defaults for the Live Slideshow. Events and event types can override these."
},
"whatsapp": { "whatsapp": {
"title": "WhatsApp", "title": "WhatsApp",
"subtitle": "Configure Meta Business credentials to deliver the gallery-ready notification via WhatsApp alongside email.", "subtitle": "Configure Meta Business credentials to deliver the gallery-ready notification via WhatsApp alongside email.",
@@ -2159,7 +2159,9 @@ export const EventDetailsPage: React.FC = () => {
</div> </div>
</Card> </Card>
{/* Live Slideshow ("Diashow") link + live display settings (migration 137) */} {/* Live Slideshow ("Diashow") link + live display settings (migrations 137/138).
Gated behind the `slideshow` feature flag. */}
{flags.slideshow && (
<SlideshowSettingsCard <SlideshowSettingsCard
eventId={event.id} eventId={event.id}
slug={event.slug} slug={event.slug}
@@ -2179,6 +2181,7 @@ export const EventDetailsPage: React.FC = () => {
}} }}
onChanged={() => refetchEvent()} onChanged={() => refetchEvent()}
/> />
)}
{/* Pre-event reminder override (migration 143). Hidden when {/* Pre-event reminder override (migration 143). Hidden when
the reminderEmails master flag is off — the override here the reminderEmails master flag is off — the override here
+8 -7
View File
@@ -19,8 +19,8 @@ import { Button, Input, Card, Loading } from '../../components/common';
import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service'; import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types'; import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { SlideshowStyleFields } from '../../components/admin/SlideshowStyleFields'; import { SlideshowStyleFields } from '../../components/admin/SlideshowStyleFields';
import { SlideshowGlobalDefaultsCard } from '../../components/admin/SlideshowGlobalDefaultsCard';
import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service'; import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service';
import { useFeatureEnabled } from '../../contexts/FeatureFlagsContext';
// Parse a type's stored slideshow_preset (JSON string | object | null) into a // Parse a type's stored slideshow_preset (JSON string | object | null) into a
// full SlideshowStyle, falling back to defaults for any missing keys. // full SlideshowStyle, falling back to defaults for any missing keys.
@@ -152,9 +152,6 @@ export const EventTypesPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Global Live Slideshow watermark default (per-event/type can override) */}
<SlideshowGlobalDefaultsCard />
{/* Filters */} {/* Filters */}
<Card className="mb-6"> <Card className="mb-6">
<div className="p-4 flex flex-col sm:flex-row gap-4"> <div className="p-4 flex flex-col sm:flex-row gap-4">
@@ -339,6 +336,7 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isEditing = !!eventType; const isEditing = !!eventType;
const slideshowEnabled = useFeatureEnabled('slideshow');
const [form, setForm] = useState<CreateEventTypeData>({ const [form, setForm] = useState<CreateEventTypeData>({
name: eventType?.name || '', name: eventType?.name || '',
@@ -382,10 +380,10 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
if (form.emoji !== eventType?.emoji) updates.emoji = form.emoji; if (form.emoji !== eventType?.emoji) updates.emoji = form.emoji;
if (form.theme_preset !== eventType?.theme_preset) updates.theme_preset = form.theme_preset; if (form.theme_preset !== eventType?.theme_preset) updates.theme_preset = form.theme_preset;
const originalStyle = JSON.stringify(parseSlideshowPreset(eventType?.slideshow_preset)); const originalStyle = JSON.stringify(parseSlideshowPreset(eventType?.slideshow_preset));
if (JSON.stringify(slideshowStyle) !== originalStyle) updates.slideshow_preset = slideshowStyle; if (slideshowEnabled && JSON.stringify(slideshowStyle) !== originalStyle) updates.slideshow_preset = slideshowStyle;
onSubmit(updates); onSubmit(updates);
} else { } else {
onSubmit({ ...form, slideshow_preset: slideshowStyle }); onSubmit({ ...form, ...(slideshowEnabled ? { slideshow_preset: slideshowStyle } : {}) });
} }
}; };
@@ -488,7 +486,9 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
{/* Live Slideshow preset (migration 138). New events of this type {/* Live Slideshow preset (migration 138). New events of this type
inherit these slideshow defaults; admins can still override inherit these slideshow defaults; admins can still override
per event on the event detail page. */} per event on the event detail page. Gated behind the
`slideshow` feature flag. */}
{slideshowEnabled && (
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700"> <div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('eventTypes.form.slideshowPreset', 'Slideshow preset')} {t('eventTypes.form.slideshowPreset', 'Slideshow preset')}
@@ -498,6 +498,7 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
</p> </p>
<SlideshowStyleFields value={slideshowStyle} onChange={setSlideshowStyle} /> <SlideshowStyleFields value={slideshowStyle} onChange={setSlideshowStyle} />
</div> </div>
)}
{/* Active toggle for editing */} {/* Active toggle for editing */}
{isEditing && ( {isEditing && (
+11 -3
View File
@@ -46,6 +46,7 @@ import {
import { EmailConfigPage } from './EmailConfigPage'; import { EmailConfigPage } from './EmailConfigPage';
import { BrandingPage } from './BrandingPage'; import { BrandingPage } from './BrandingPage';
import { EventTypesPage } from './EventTypesPage'; import { EventTypesPage } from './EventTypesPage';
import { SlideshowSettingsPage } from './SlideshowSettingsPage';
import { BackupManagement } from './BackupManagement'; import { BackupManagement } from './BackupManagement';
import { CMSPage } from './CMSPage'; import { CMSPage } from './CMSPage';
// CRM (#TBD) // CRM (#TBD)
@@ -54,7 +55,7 @@ import { CrmSettingsPage } from './settings/CrmSettingsPage';
import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage'; import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage';
import { BlockLibraryPage } from './contracts/BlockLibraryPage'; import { BlockLibraryPage } from './contracts/BlockLibraryPage';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { Briefcase, Receipt, ScrollText, Mail, Landmark, Smartphone } from 'lucide-react'; import { Briefcase, Receipt, ScrollText, Mail, Landmark, Smartphone, MonitorPlay } from 'lucide-react';
// Tab keys driving the inner-nav. Must include every key used in // Tab keys driving the inner-nav. Must include every key used in
// `navGroups` below and in the switch at the bottom of the component. // `navGroups` below and in the switch at the bottom of the component.
@@ -85,7 +86,8 @@ type TabType =
| 'contracts' | 'contracts'
| 'reminderTemplates' | 'reminderTemplates'
| 'accounting' | 'accounting'
| 'whatsapp'; | 'whatsapp'
| 'slideshow';
interface NavItem { interface NavItem {
key: TabType; key: TabType;
@@ -106,6 +108,7 @@ const ALL_TAB_KEYS: TabType[] = [
'apiTokens', 'webhooks', 'apiTokens', 'webhooks',
'status', 'analytics', 'backup', 'status', 'analytics', 'backup',
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting', 'whatsapp', 'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting', 'whatsapp',
'slideshow',
]; ];
function isValidTab(value: string | null): value is TabType { function isValidTab(value: string | null): value is TabType {
@@ -203,12 +206,13 @@ export const SettingsPage: React.FC = () => {
reminderTemplates: !flags.reminderEmails, reminderTemplates: !flags.reminderEmails,
accounting: !flags.accounting, accounting: !flags.accounting,
whatsapp: !flags.whatsapp, whatsapp: !flags.whatsapp,
slideshow: !flags.slideshow,
}; };
if (gatedOff[activeTab]) { if (gatedOff[activeTab]) {
setActiveTab('features'); setActiveTab('features');
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [flagsLoading, flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, flags.whatsapp, activeTab]); }, [flagsLoading, flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, flags.whatsapp, flags.slideshow, activeTab]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -240,6 +244,9 @@ export const SettingsPage: React.FC = () => {
{ key: 'thumbnails', label: t('settings.thumbnails.title', 'Thumbnails'), icon: ImageIcon }, { key: 'thumbnails', label: t('settings.thumbnails.title', 'Thumbnails'), icon: ImageIcon },
{ key: 'styling', label: t('settings.styling.title', 'Custom CSS'), icon: Code }, { key: 'styling', label: t('settings.styling.title', 'Custom CSS'), icon: Code },
{ key: 'cms', label: t('settings.cms.title', 'CMS Pages'), icon: FileText }, { key: 'cms', label: t('settings.cms.title', 'CMS Pages'), icon: FileText },
...(flags.slideshow
? [{ key: 'slideshow' as const, label: t('settings.slideshow.title', 'Slideshow'), icon: MonitorPlay }]
: []),
], ],
}, },
{ {
@@ -433,6 +440,7 @@ export const SettingsPage: React.FC = () => {
)} )}
{activeTab === 'eventTypes' && <EventTypesPage />} {activeTab === 'eventTypes' && <EventTypesPage />}
{activeTab === 'slideshow' && <SlideshowSettingsPage />}
{activeTab === 'branding' && <BrandingPage />} {activeTab === 'branding' && <BrandingPage />}
{activeTab === 'cms' && <CMSPage />} {activeTab === 'cms' && <CMSPage />}
{activeTab === 'email' && <EmailConfigPage />} {activeTab === 'email' && <EmailConfigPage />}
@@ -0,0 +1,28 @@
/**
* Settings Slideshow tab. Top-level (global) Live Slideshow settings.
* Currently the global watermark default (source/position/opacity/style) that
* every event inherits unless it overrides. Gated behind the `slideshow`
* feature flag (see SettingsPage nav).
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SlideshowGlobalDefaultsCard } from '../../components/admin/SlideshowGlobalDefaultsCard';
export const SlideshowSettingsPage: React.FC = () => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('settings.slideshow.title', 'Slideshow')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{t('settings.slideshow.subtitle', 'Global defaults for the Live Slideshow. Events and event types can override these.')}
</p>
</div>
<SlideshowGlobalDefaultsCard />
</div>
);
};
export default SlideshowSettingsPage;
@@ -63,7 +63,10 @@ export type FeatureKey =
// Strictly opt-in — requires a Meta Business Account, an approved // Strictly opt-in — requires a Meta Business Account, an approved
// message template, and a Meta access token. Independent of email; both // message template, and a Meta access token. Independent of email; both
// can fire on the same event. // can fire on the same event.
| 'whatsapp'; | 'whatsapp'
// Live Slideshow ("Diashow") — per-event fullscreen kiosk link + presets +
// global watermark settings tab. Strictly opt-in; gates all slideshow UI.
| 'slideshow';
export type FeatureFlags = Record<FeatureKey, boolean>; export type FeatureFlags = Record<FeatureKey, boolean>;