feat(setup): event-types step in first-run wizard + un-hardcode event type deps (#800)

Fresh installs can now shape the event-type catalog during the setup
wizard — rename, delete or replace the seeded defaults while nothing
references them. On existing installs system types stay protected.

- New wizard step between features and config: edit name/URL prefix,
  remove, or add types; defaults shown as recommendations
- setup_wizard_completed app setting (migration 161): seeded true when
  an admin already exists, false on fresh installs; POST /api/setup/
  complete (adminAuth) flips it when the wizard finishes
- deleteEventType: system types deletable only while the flag is unset;
  in-use check extended to quotes; per-type reminder template
  (event_reminder_<slug>) is deleted with the type
- reminder-template self-heal no longer resurrects templates for slugs
  removed from the catalog
- v1 API event creation validates event_type against the live catalog
  instead of a hardcoded whitelist (custom types were rejected; the
  never-seeded 'family' slug is no longer silently accepted)
- contract→event conversion resolves the event type via
  crm_default_event_type / resolveDefaultEventType instead of
  hardcoding 'wedding' (resolveDefaultEventType moved from quoteService
  to eventTypeService for reuse)
This commit is contained in:
Paul Nothaft
2026-07-15 21:30:23 +02:00
parent aab9e1a937
commit 7eb6357b4a
14 changed files with 573 additions and 47 deletions
@@ -0,0 +1,193 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Plus, X } from 'lucide-react';
import { Button, Input, Loading } from '../common';
import { eventTypesService, EventType } from '../../services/eventTypes.service';
interface Props {
onDone: () => void;
}
// One editable row of the wizard's event-type list. Existing rows carry the
// catalog id; rows added in the wizard have no id until Continue POSTs them.
interface RowState {
id?: number;
name: string;
slug_prefix: string;
emoji: string;
}
const normalizeSlug = (value: string) => value.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// First-run event-types step (#800). Shown once, during the setup wizard —
// the only window in which the seeded SYSTEM types may be deleted (nothing
// references them yet; the backend re-locks them when the wizard finishes).
// Deliberately lean: name + URL prefix only. Icons, themes and ordering are
// tunable later in Settings → Event Types.
export const SetupEventTypesStep: React.FC<Props> = ({ onDone }) => {
const { t } = useTranslation();
const [rows, setRows] = useState<RowState[] | null>(null);
const [original, setOriginal] = useState<Map<number, EventType>>(new Map());
const [deletedIds, setDeletedIds] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const { isLoading, isError } = useQuery({
queryKey: ['setup-event-types'],
queryFn: async () => {
const types = await eventTypesService.getEventTypes();
// Initialize once — a re-run (remount) must not clobber in-progress edits.
setOriginal((prev) => (prev.size > 0 ? prev : new Map(types.map((et) => [et.id, et]))));
setRows((prev) => prev ?? types.map((et) => ({ id: et.id, name: et.name, slug_prefix: et.slug_prefix, emoji: et.emoji })));
return types;
},
staleTime: Infinity,
});
const setRow = (index: number, patch: Partial<RowState>) => {
setRows((prev) => (prev ? prev.map((r, i) => (i === index ? { ...r, ...patch } : r)) : prev));
};
const removeRow = (index: number) => {
// No side effects inside the setRows updater — StrictMode double-invokes
// updaters, which would enqueue the same id twice (one DELETE 404s and
// shows a false "could not save" warning).
const row = rows?.[index];
if (!row) return;
if (row.id !== undefined) {
setDeletedIds((ids) => (ids.includes(row.id!) ? ids : [...ids, row.id!]));
}
setRows((prev) => (prev ? prev.filter((_, i) => i !== index) : prev));
};
const addRow = () => {
setRows((prev) => (prev ? [...prev, { name: '', slug_prefix: '', emoji: '📷' }] : prev));
};
// Apply the diff (delete → update → create), then advance. Best-effort like
// the other wizard steps — a partial failure warns but never traps the user;
// everything here is editable later in Settings → Event Types.
const handleContinue = async () => {
if (!rows) return;
const kept = rows.filter((r) => r.name.trim() && r.slug_prefix.trim());
if (kept.length === 0) {
toast.error(t('setup.eventTypes.atLeastOne'));
return;
}
setSaving(true);
let failures = 0;
for (const id of deletedIds) {
try {
await eventTypesService.deleteEventType(id);
} catch {
failures += 1;
}
}
for (const row of kept) {
try {
if (row.id !== undefined) {
const before = original.get(row.id);
const updates: { name?: string; slug_prefix?: string } = {};
if (before && row.name.trim() !== before.name) updates.name = row.name.trim();
if (before && row.slug_prefix !== before.slug_prefix) updates.slug_prefix = row.slug_prefix;
if (Object.keys(updates).length > 0) {
await eventTypesService.updateEventType(row.id, updates);
}
} else {
await eventTypesService.createEventType({
name: row.name.trim(),
slug_prefix: row.slug_prefix,
emoji: row.emoji,
});
}
} catch {
failures += 1;
}
}
setSaving(false);
if (failures > 0) toast.warn(t('setup.eventTypes.saveFailed'));
onDone();
};
if (isLoading || rows === null) {
return isError ? (
// Catalog unreadable — don't trap the user; the defaults stay seeded and
// remain editable later in Settings → Event Types.
<div className="space-y-6">
<p className="text-sm text-neutral-600">{t('setup.eventTypes.loadFailed')}</p>
<Button type="button" variant="primary" size="lg" className="w-full" onClick={onDone}>
{t('setup.continue')}
</Button>
</div>
) : (
<Loading />
);
}
return (
<div className="space-y-6">
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
{t('setup.eventTypes.intro')}
</p>
<div className="space-y-2">
{rows.map((row, index) => (
<div key={row.id ?? `new-${index}`} className="flex items-center gap-2">
<span className="w-8 text-center text-xl flex-shrink-0" aria-hidden="true">{row.emoji}</span>
<div className="flex-1 min-w-0">
<Input
value={row.name}
onChange={(e) => setRow(index, { name: e.target.value })}
placeholder={t('setup.eventTypes.namePlaceholder')}
aria-label={t('setup.eventTypes.nameLabel')}
/>
</div>
<div className="w-32 flex-shrink-0">
<Input
value={row.slug_prefix}
onChange={(e) => setRow(index, { slug_prefix: normalizeSlug(e.target.value) })}
placeholder={t('setup.eventTypes.slugPlaceholder')}
aria-label={t('setup.eventTypes.slugLabel')}
/>
</div>
<button
type="button"
onClick={() => removeRow(index)}
className="flex-shrink-0 p-2 rounded-lg text-neutral-400 hover:text-red-600 hover:bg-red-50 transition-colors"
aria-label={t('common.delete', 'Delete')}
title={t('common.delete', 'Delete')}
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
<button
type="button"
onClick={addRow}
className="w-full rounded-lg border border-dashed border-neutral-300 p-3 text-left hover:bg-neutral-50 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-800">{t('setup.eventTypes.add')}</span>
</button>
<p className="text-xs text-neutral-500">{t('setup.eventTypes.hint')}</p>
<Button
type="button"
variant="primary"
size="lg"
isLoading={saving}
className="w-full"
onClick={handleContinue}
>
{t('setup.continue')}
</Button>
</div>
);
};
SetupEventTypesStep.displayName = 'SetupEventTypesStep';
+13
View File
@@ -3611,6 +3611,19 @@
"finish": "Einrichtung abschließen",
"saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen."
},
"eventTypes": {
"subtitle": "Welche Veranstaltungen fotografieren Sie?",
"intro": "Veranstaltungsarten ordnen Ihre Galerien — jede Art erhält ein eigenes URL-Präfix und Standard-Theme. Die Vorschläge unten sind nur ein Startpunkt: Benennen Sie sie um, entfernen Sie Unnötiges oder fügen Sie eigene hinzu. Nur jetzt können die mitgelieferten Arten gelöscht werden; später lassen sie sich nur umbenennen oder deaktivieren.",
"nameLabel": "Anzeigename",
"namePlaceholder": "z.B. Familienshooting",
"slugLabel": "URL-Präfix",
"slugPlaceholder": "z.B. familie",
"add": "Veranstaltungsart hinzufügen",
"hint": "Das URL-Präfix erscheint in Galerie-Links (z.B. familie-mueller-2025-06-01). Symbole, Themes und Reihenfolge können Sie später unter Einstellungen → Veranstaltungsarten anpassen.",
"atLeastOne": "Behalten Sie mindestens eine Veranstaltungsart — jede Galerie braucht eine.",
"loadFailed": "Veranstaltungsarten konnten nicht geladen werden — Sie können sie später unter Einstellungen → Veranstaltungsarten anpassen.",
"saveFailed": "Einige Änderungen konnten nicht gespeichert werden — Sie können sie unter Einstellungen → Veranstaltungsarten abschließen."
},
"community": {
"subtitle": "Alles bereit",
"mission": "PicPeak gibt es, damit Fotografinnen und Fotografen ihre Galerien und Kundendaten selbst besitzen — auf dem eigenen Server, ohne monatliche SaaS-Gebühren. Danke, dass du es ausprobierst.",
+13
View File
@@ -3500,6 +3500,19 @@
"finish": "Finish setup",
"saveFailed": "Some settings could not be saved — you can finish them in Settings."
},
"eventTypes": {
"subtitle": "Which events do you photograph?",
"intro": "Event types organize your galleries — each one gets its own URL prefix and default theme. The suggestions below are just a starting point: rename them, remove what you don't need, or add your own. This is the only time the built-in types can be deleted; later they can only be renamed or deactivated.",
"nameLabel": "Display name",
"namePlaceholder": "e.g. Family Shoot",
"slugLabel": "URL prefix",
"slugPlaceholder": "e.g. family",
"add": "Add event type",
"hint": "The URL prefix appears in gallery links (e.g. family-smith-2025-06-01). Icons, themes and order can be tuned later in Settings → Event Types.",
"atLeastOne": "Keep at least one event type — every gallery needs one.",
"loadFailed": "Could not load the event types — you can adjust them later in Settings → Event Types.",
"saveFailed": "Some event type changes could not be saved — you can finish them in Settings → Event Types."
},
"community": {
"subtitle": "You're all set",
"mission": "PicPeak exists so photographers can own their galleries and client data — on their own server, without monthly SaaS fees. Thanks for giving it a try.",
+37 -19
View File
@@ -11,6 +11,7 @@ import { setupService } from '../services/setup.service';
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep';
import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
import type { AdminUser } from '../types';
@@ -67,7 +68,7 @@ export const SetupPage: React.FC = () => {
staleTime: Infinity,
});
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore' | 'config' | 'community'>('token');
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'eventTypes' | 'restore' | 'config' | 'community'>('token');
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -241,19 +242,25 @@ export const SetupPage: React.FC = () => {
toast.warn(t('setup.featuresSaveFailed'));
} finally {
setIsSavingFeatures(false);
// If the chosen features need config the wizard can collect (invoicing,
// email), go to the config step; otherwise enter the app.
const needsConfig =
selectedFeatures.has('bills') ||
selectedFeatures.has('reminderEmails') ||
selectedFeatures.has('incomingMail') ||
selectedFeatures.has('whatsapp');
// Both the config branch and the no-config path end on the final
// community/thank-you step (#732), whose Finish button enters the app.
setStep(needsConfig ? 'config' : 'community');
// Event types come next (#800) — the wizard is the one window in which
// the seeded defaults can be freely renamed or deleted, because nothing
// (events, quotes, reminder mails) references them yet.
setStep('eventTypes');
}
};
// After the event-types step: if the chosen features need config the wizard
// can collect (invoicing, email), go to the config step; otherwise skip to
// the final community/thank-you step (#732), whose Finish enters the app.
const continueAfterEventTypes = () => {
const needsConfig =
selectedFeatures.has('bills') ||
selectedFeatures.has('reminderEmails') ||
selectedFeatures.has('incomingMail') ||
selectedFeatures.has('whatsapp');
setStep(needsConfig ? 'config' : 'community');
};
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
return (
@@ -278,13 +285,15 @@ export const SetupPage: React.FC = () => {
? t('setup.tokenStepSubtitle')
: step === 'account'
? t('setup.accountStepSubtitle')
: step === 'restore'
? t('setup.restoreStepSubtitle')
: step === 'config'
? t('setup.config.subtitle')
: step === 'community'
? t('setup.community.subtitle')
: t('setup.usageSubtitle')}
: step === 'eventTypes'
? t('setup.eventTypes.subtitle')
: step === 'restore'
? t('setup.restoreStepSubtitle')
: step === 'config'
? t('setup.config.subtitle')
: step === 'community'
? t('setup.community.subtitle')
: t('setup.usageSubtitle')}
</p>
{(step === 'token' || step === 'account' || step === 'usage') && (
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}>
@@ -509,6 +518,8 @@ export const SetupPage: React.FC = () => {
{t('setup.back')}
</Button>
</div>
) : step === 'eventTypes' ? (
<SetupEventTypesStep onDone={continueAfterEventTypes} />
) : step === 'config' ? (
<SetupConfigStep
selectedFeatures={selectedFeatures}
@@ -546,7 +557,14 @@ export const SetupPage: React.FC = () => {
variant="primary"
size="lg"
className="w-full"
onClick={() => navigate('/admin/dashboard', { replace: true })}
onClick={async () => {
// One-way marker: re-locks the seeded system event types
// (#800). Best-effort — a failure must not trap the user on
// the thank-you screen, and the flag re-arms nothing risky
// (the delete window also requires zero usage server-side).
try { await setupService.completeSetup(); } catch { /* best-effort */ }
navigate('/admin/dashboard', { replace: true });
}}
rightIcon={<ArrowRight className="w-4 h-4" />}
>
{t('setup.community.finish')}
+7
View File
@@ -38,4 +38,11 @@ export const setupService = {
const response = await api.post<{ user: SetupAdminUser }>('/setup/admin', input);
return response.data;
},
// One-way wizard-finish marker (authenticated — runs after the admin
// exists). While unset, the wizard's event-types step may delete the
// seeded system types; afterwards they are permanently protected.
async completeSetup(): Promise<void> {
await api.post('/setup/complete');
},
};