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:
@@ -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;
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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.' }));
|
||||
|
||||
Reference in New Issue
Block a user