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:
@@ -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';
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user