completed the frontend dashboard component and bound it to the tenant admin pwa for the optimal onboarding experience.. Added a profile page.

This commit is contained in:
Codex Agent
2025-11-04 22:28:37 +01:00
parent fe380689fb
commit b32413b108
29 changed files with 1416 additions and 425 deletions

View File

@@ -122,18 +122,54 @@ export type PaginatedResult<T> = {
};
export type DashboardSummary = {
active_events: number;
new_photos: number;
task_progress: number;
credit_balance?: number | null;
upcoming_events?: number | null;
active_package?: {
name: string;
expires_at?: string | null;
remaining_events?: number | null;
} | null;
active_events: number;
new_photos: number;
task_progress: number;
credit_balance?: number | null;
upcoming_events?: number | null;
active_package?: {
name: string;
expires_at?: string | null;
remaining_events?: number | null;
} | null;
};
export type TenantOnboardingStatus = {
steps: {
admin_app_opened_at?: string | null;
primary_event_id?: number | string | null;
selected_packages?: unknown;
branding_completed?: boolean;
tasks_configured?: boolean;
event_created?: boolean;
invite_created?: boolean;
};
};
export async function trackOnboarding(step: string, meta?: Record<string, unknown>): Promise<void> {
try {
await authorizedFetch('/api/v1/tenant/onboarding', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ step, meta }),
});
} catch (error) {
emitApiErrorEvent(new ApiError('onboarding.track_failed', i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'), error));
}
}
export async function fetchOnboardingStatus(): Promise<TenantOnboardingStatus | null> {
try {
const response = await authorizedFetch('/api/v1/tenant/onboarding');
return (await response.json()) as TenantOnboardingStatus;
} catch (error) {
emitApiErrorEvent(new ApiError('onboarding.fetch_failed', i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'), error));
return null;
}
}
export type TenantPackageSummary = {
id: number;
package_id: number;

View File

@@ -78,6 +78,13 @@ export default function WelcomePackagesPage() {
isSubscription: Boolean(active.package_limits?.subscription),
}
: null,
serverStep: active ? "package_selected" : undefined,
meta: active
? {
packages: [active.package_id],
is_active: active.active,
}
: undefined,
});
}
}, [packagesState, markStep, currencyFormatter, t]);
@@ -96,6 +103,8 @@ export default function WelcomePackagesPage() {
priceText,
isSubscription: Boolean(pkg.features?.subscription),
},
serverStep: "package_selected",
meta: { packages: [pkg.id] },
});
navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } });

View File

@@ -1,10 +1,12 @@
import React from 'react';
import { fetchOnboardingStatus, trackOnboarding } from '../api';
export type OnboardingProgress = {
welcomeSeen: boolean;
packageSelected: boolean;
eventCreated: boolean;
lastStep?: string | null;
adminAppOpenedAt?: string | null;
selectedPackage?: {
id: number;
name: string;
@@ -13,10 +15,15 @@ export type OnboardingProgress = {
} | null;
};
type OnboardingUpdate = Partial<OnboardingProgress> & {
serverStep?: string;
meta?: Record<string, unknown>;
};
type OnboardingContextValue = {
progress: OnboardingProgress;
setProgress: (updater: (prev: OnboardingProgress) => OnboardingProgress) => void;
markStep: (step: Partial<OnboardingProgress>) => void;
markStep: (step: OnboardingUpdate) => void;
reset: () => void;
};
@@ -25,6 +32,7 @@ const DEFAULT_PROGRESS: OnboardingProgress = {
packageSelected: false,
eventCreated: false,
lastStep: null,
adminAppOpenedAt: null,
selectedPackage: null,
};
@@ -65,6 +73,45 @@ function writeStoredProgress(progress: OnboardingProgress) {
export function OnboardingProgressProvider({ children }: { children: React.ReactNode }) {
const [progress, setProgressState] = React.useState<OnboardingProgress>(() => readStoredProgress());
const [synced, setSynced] = React.useState(false);
React.useEffect(() => {
if (synced) {
return;
}
fetchOnboardingStatus().then((status) => {
if (!status) {
setSynced(true);
return;
}
setProgressState((prev) => {
const next: OnboardingProgress = {
...prev,
adminAppOpenedAt: status.steps.admin_app_opened_at ?? prev.adminAppOpenedAt ?? null,
eventCreated: Boolean(status.steps.event_created ?? prev.eventCreated),
packageSelected: Boolean(status.steps.selected_packages ?? prev.packageSelected),
};
writeStoredProgress(next);
return next;
});
if (!status.steps.admin_app_opened_at) {
const timestamp = new Date().toISOString();
trackOnboarding('admin_app_opened').catch(() => {});
setProgressState((prev) => {
const next = { ...prev, adminAppOpenedAt: timestamp };
writeStoredProgress(next);
return next;
});
}
setSynced(true);
});
}, [synced]);
const setProgress = React.useCallback((updater: (prev: OnboardingProgress) => OnboardingProgress) => {
setProgressState((prev) => {
@@ -74,12 +121,18 @@ export function OnboardingProgressProvider({ children }: { children: React.React
});
}, []);
const markStep = React.useCallback((step: Partial<OnboardingProgress>) => {
const markStep = React.useCallback((step: OnboardingUpdate) => {
const { serverStep, meta, ...rest } = step;
setProgress((prev) => ({
...prev,
...step,
lastStep: typeof step.lastStep === 'undefined' ? prev.lastStep : step.lastStep,
...rest,
lastStep: typeof rest.lastStep === 'undefined' ? prev.lastStep : rest.lastStep,
}));
if (serverStep) {
trackOnboarding(serverStep, meta).catch(() => {});
}
}, [setProgress]);
const reset = React.useCallback(() => {

View File

@@ -180,7 +180,12 @@ export default function DashboardPage() {
return;
}
if (events.length > 0 && !progress.eventCreated) {
markStep({ eventCreated: true });
const primary = events[0];
markStep({
eventCreated: true,
serverStep: 'event_created',
meta: primary ? { event_id: primary.id } : undefined,
});
}
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]);

View File

@@ -48,6 +48,7 @@ import {
triggerDownloadFromBlob,
triggerDownloadFromDataUrl,
} from './components/invite-layout/export-utils';
import { useOnboardingProgress } from '../onboarding';
interface PageState {
event: TenantEvent | null;
@@ -180,6 +181,7 @@ export default function EventInvitesPage(): React.ReactElement {
const [exportError, setExportError] = React.useState<string | null>(null);
const exportPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
const [exportScale, setExportScale] = React.useState(0.34);
const { markStep } = useOnboardingProgress();
const load = React.useCallback(async () => {
if (!slug) {
@@ -479,6 +481,11 @@ export default function EventInvitesPage(): React.ReactElement {
} catch {
// ignore clipboard failures
}
markStep({
lastStep: 'invite',
serverStep: 'invite_created',
meta: { invite_id: invite.id },
});
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erstellt werden.' }));
@@ -544,6 +551,14 @@ export default function EventInvitesPage(): React.ReactElement {
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerDraft(null);
markStep({
lastStep: 'branding',
serverStep: 'branding_configured',
meta: {
invite_id: selectedInvite.id,
has_custom_branding: true,
},
});
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' }));