feat: add optional event date and expiration settings

Add global settings to make event_date and expiration optional when
creating galleries. This supports non-event use cases like portraits,
corporate shoots, etc.

New features:
- Settings toggles in Settings → Event Creation tab
- "Require event date" checkbox with warning about random URL identifiers
- "Require expiration date" checkbox with warning about manual archiving
- Galleries without date use random hex suffix in slug (e.g. portrait-smith-a1b2c3)
- Galleries without expiration never expire (stay active until archived)

Backend changes:
- New migration for settings and nullable columns
- Conditional validation based on settings
- Updated slug generation with random suffix fallback
- Updated expiration checker to skip null expires_at
- Updated gallery access control for null expiration

Frontend changes:
- New checkboxes in EventsTab with warnings
- Conditional event date field (shows optional label)
- No Expiration message when expiration disabled
- Updated types for nullable event_date and expires_at

Closes #118
This commit is contained in:
Paul Nothaft
2026-01-16 09:39:32 +01:00
parent 3e69579f5a
commit 2151147f2d
13 changed files with 226 additions and 61 deletions
@@ -49,6 +49,8 @@ export interface EventSettings {
event_require_customer_name: boolean;
event_require_customer_email: boolean;
event_require_admin_email: boolean;
event_require_event_date: boolean;
event_require_expiration: boolean;
}
export function useSettingsState() {
@@ -109,7 +111,9 @@ export function useSettingsState() {
const [eventSettings, setEventSettings] = useState<EventSettings>({
event_require_customer_name: true,
event_require_customer_email: true,
event_require_admin_email: true
event_require_admin_email: true,
event_require_event_date: true,
event_require_expiration: true
});
// Account form state
@@ -178,7 +182,9 @@ export function useSettingsState() {
setEventSettings({
event_require_customer_name: toBoolean(settings.event_require_customer_name, true),
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
event_require_admin_email: toBoolean(settings.event_require_admin_email, true)
event_require_admin_email: toBoolean(settings.event_require_admin_email, true),
event_require_event_date: toBoolean(settings.event_require_event_date, true),
event_require_expiration: toBoolean(settings.event_require_expiration, true)
});
}
}, [settings, i18n]);
@@ -99,6 +99,56 @@ export const EventsTab: React.FC<EventsTabProps> = ({
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_event_date}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_event_date: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireEventDate', 'Require event date')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireEventDateHelp', 'Event date must be provided when creating events')}
</p>
{!eventSettings.event_require_event_date && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.eventDateWarning', 'Gallery URLs will use random identifiers instead of dates')}
</p>
)}
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_expiration}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_expiration: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireExpiration', 'Require expiration date')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireExpirationHelp', 'Galleries must have an expiration date')}
</p>
{!eventSettings.event_require_expiration && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.expirationWarning', 'Galleries without expiration will remain active until manually archived')}
</p>
)}
</div>
</label>
</div>
</div>
<div className="mt-6">
+8
View File
@@ -759,6 +759,8 @@
"themeAndStyle": "Design & Stil",
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
"noExpiration": "Kein Ablaufdatum",
"noExpirationHelp": "Diese Galerie bleibt aktiv, bis sie manuell archiviert wird.",
"userUploads": "Benutzer-Upload-Einstellungen",
"allowUserUploads": "Gästen erlauben, Fotos hochzuladen",
"allowUserUploadsHelp": "Ermöglichen Sie Gästen, ihre eigenen Fotos in diese Galerie hochzuladen",
@@ -1048,6 +1050,12 @@
"requireAdminEmail": "Admin-E-Mail erforderlich",
"requireAdminEmailHelp": "Admin-E-Mail muss für neue Veranstaltungen angegeben werden",
"adminEmailWarning": "Erforderlich für den Erhalt von Veranstaltungsbenachrichtigungen",
"requireEventDate": "Veranstaltungsdatum erforderlich",
"requireEventDateHelp": "Veranstaltungsdatum muss beim Erstellen angegeben werden",
"eventDateWarning": "Galerie-URLs verwenden zufällige Kennungen anstelle von Daten",
"requireExpiration": "Ablaufdatum erforderlich",
"requireExpirationHelp": "Galerien müssen ein Ablaufdatum haben",
"expirationWarning": "Galerien ohne Ablaufdatum bleiben aktiv, bis sie manuell archiviert werden",
"saveSettings": "Veranstaltungseinstellungen speichern",
"noteTitle": "Hinweis",
"noteText": "Diese Einstellungen betreffen nur die Erstellung neuer Veranstaltungen. Bestehende Veranstaltungen sind nicht betroffen. Standardmäßig sind alle Felder erforderlich."
+8
View File
@@ -433,6 +433,8 @@
"expiresOn": "Expires on",
"galleryWillExpireOn": "Gallery will expire on {{date}}",
"expirationWarning": "Guests will receive a warning email 7 days before expiration.",
"noExpiration": "No Expiration",
"noExpirationHelp": "This gallery will remain active until manually archived.",
"userUploads": "User Upload Settings",
"allowUserUploads": "Allow guests to upload photos",
"allowUserUploadsHelp": "Enable guests to upload their own photos to this gallery",
@@ -753,6 +755,12 @@
"requireAdminEmail": "Require admin email",
"requireAdminEmailHelp": "Admin email must be provided for new events",
"adminEmailWarning": "Required for receiving event notifications",
"requireEventDate": "Require event date",
"requireEventDateHelp": "Event date must be provided when creating events",
"eventDateWarning": "Gallery URLs will use random identifiers instead of dates",
"requireExpiration": "Require expiration date",
"requireExpirationHelp": "Galleries must have an expiration date",
"expirationWarning": "Galleries without expiration will remain active until manually archived",
"saveSettings": "Save Event Settings",
"noteTitle": "Note",
"noteText": "These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields."
+40 -26
View File
@@ -148,6 +148,8 @@ export const CreateEventPage: React.FC = () => {
const requireCustomerName = publicSettings?.event_require_customer_name !== false;
const requireCustomerEmail = publicSettings?.event_require_customer_email !== false;
const requireAdminEmail = publicSettings?.event_require_admin_email !== false;
const requireEventDate = publicSettings?.event_require_event_date !== false;
const requireExpiration = publicSettings?.event_require_expiration !== false;
// Update default expiration days when settings are loaded
useEffect(() => {
@@ -201,7 +203,7 @@ export const CreateEventPage: React.FC = () => {
newErrors.event_name = t('validation.eventNameRequired');
}
if (!formData.event_date) {
if (requireEventDate && !formData.event_date) {
newErrors.event_date = t('validation.eventDateRequired');
}
@@ -247,7 +249,7 @@ export const CreateEventPage: React.FC = () => {
}
}
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
if (requireExpiration && (formData.expires_in_days < 1 || formData.expires_in_days > 365)) {
newErrors.expires_in_days = t('validation.expirationRange');
}
@@ -267,7 +269,7 @@ export const CreateEventPage: React.FC = () => {
const payload = {
event_type: formData.event_type,
event_name: formData.event_name,
event_date: formData.event_date,
event_date: formData.event_date || undefined,
customer_name: formData.customer_name,
customer_email: formData.customer_email,
admin_email: formData.admin_email,
@@ -275,7 +277,7 @@ export const CreateEventPage: React.FC = () => {
password: formData.require_password ? formData.password : undefined,
welcome_message: formData.welcome_message || '',
color_theme: JSON.stringify(formData.theme_config),
expiration_days: formData.expires_in_days,
expiration_days: requireExpiration ? formData.expires_in_days : undefined,
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
css_template_id: formData.css_template_id,
@@ -394,7 +396,7 @@ export const CreateEventPage: React.FC = () => {
<Input
type="date"
label={t('events.eventDate')}
label={requireEventDate ? t('events.eventDate') : `${t('events.eventDate')} (${t('common.optional')})`}
value={formData.event_date}
onChange={handleInputChange('event_date')}
error={errors.event_date}
@@ -666,30 +668,42 @@ export const CreateEventPage: React.FC = () => {
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.galleryExpiration')}
</label>
<div className="flex items-center gap-2">
<div className="w-32">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
/>
{requireExpiration ? (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.galleryExpiration')}
</label>
<div className="flex items-center gap-2">
<div className="w-32">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
/>
</div>
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
</div>
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
{formData.event_date && (
<p className="mt-2 text-sm text-neutral-500">
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
</p>
)}
</div>
{formData.event_date && (
<p className="mt-2 text-sm text-neutral-500">
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
) : (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">
<div className="flex items-center gap-2 text-blue-800">
<Clock className="w-4 h-4" />
<span className="text-sm font-medium">{t('events.noExpiration', 'No Expiration')}</span>
</div>
<p className="mt-1 text-xs text-blue-700">
{t('events.noExpirationHelp', 'This gallery will remain active until manually archived.')}
</p>
)}
</div>
</div>
)}
{/* User Upload Settings */}
<div className="pt-4 border-t border-neutral-200">
+4 -4
View File
@@ -20,15 +20,15 @@ const normalizeEvent = (event: Event): Event => {
interface CreateEventData {
event_type: string;
event_name: string;
event_date: string;
event_date?: string;
customer_name?: string;
customer_email: string;
admin_email: string;
customer_email?: string;
admin_email?: string;
require_password?: boolean;
password?: string;
welcome_message?: string;
color_theme?: string;
expiration_days: number;
expiration_days?: number;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
feedback_enabled?: boolean;
+6 -6
View File
@@ -4,7 +4,7 @@ export interface Event {
slug: string;
event_type: string;
event_name: string;
event_date: string;
event_date: string | null;
customer_name?: string;
customer_email: string;
admin_email: string;
@@ -12,7 +12,7 @@ export interface Event {
color_theme?: string;
share_link: string;
created_at: string;
expires_at: string;
expires_at: string | null;
is_active: boolean;
is_archived: boolean;
archive_path?: string;
@@ -46,8 +46,8 @@ export interface Event {
export interface GalleryInfo {
event_name: string;
event_type: string;
event_date: string;
expires_at: string;
event_date: string | null;
expires_at: string | null;
is_active: boolean;
is_expired: boolean;
requires_password?: boolean;
@@ -97,10 +97,10 @@ export interface GalleryData {
id: number;
event_name: string;
event_type: string;
event_date: string;
event_date: string | null;
welcome_message?: string;
color_theme?: string;
expires_at: string;
expires_at: string | null;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
hero_photo_id?: number | null;