Compare commits

...

3 Commits

Author SHA1 Message Date
Codex Agent
cc89cc667a Add package summary banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 12:01:12 +01:00
Codex Agent
a796973861 Admin package summary sheet 2026-01-06 11:57:30 +01:00
Codex Agent
eba212a056 Login Page redesign 2026-01-06 11:56:54 +01:00
17 changed files with 520 additions and 94 deletions

View File

@@ -34,6 +34,8 @@ class OnboardingController extends Controller
'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'), 'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'),
'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'), 'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'),
'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'), 'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'),
'summary_seen_package_id' => Arr::get($settings, 'onboarding.summary_seen_package_id'),
'summary_seen_at' => Arr::get($settings, 'onboarding.summary_seen_at'),
'dismissed_at' => Arr::get($settings, 'onboarding.dismissed_at'), 'dismissed_at' => Arr::get($settings, 'onboarding.dismissed_at'),
'completed_at' => Arr::get($settings, 'onboarding.completed_at'), 'completed_at' => Arr::get($settings, 'onboarding.completed_at'),
'branding_completed' => (bool) ($status['palette'] ?? false), 'branding_completed' => (bool) ($status['palette'] ?? false),
@@ -86,6 +88,11 @@ class OnboardingController extends Controller
Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String()); Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String());
break; break;
case 'summary_seen':
Arr::set($settings, 'onboarding.summary_seen_package_id', Arr::get($meta, 'package_id'));
Arr::set($settings, 'onboarding.summary_seen_at', Carbon::now()->toIso8601String());
break;
case 'dismissed': case 'dismissed':
Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String()); Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String());
break; break;

View File

@@ -330,6 +330,8 @@ export type TenantOnboardingStatus = {
admin_app_opened_at?: string | null; admin_app_opened_at?: string | null;
primary_event_id?: number | string | null; primary_event_id?: number | string | null;
selected_packages?: unknown; selected_packages?: unknown;
summary_seen_package_id?: number | null;
summary_seen_at?: string | null;
dismissed_at?: string | null; dismissed_at?: string | null;
completed_at?: string | null; completed_at?: string | null;
branding_completed?: boolean; branding_completed?: boolean;

View File

@@ -2061,6 +2061,39 @@
"storageUnprotected": "Nicht geschützt", "storageUnprotected": "Nicht geschützt",
"storageAction": "Jetzt schützen" "storageAction": "Jetzt schützen"
}, },
"packageSummary": {
"title": "Deine Paketübersicht",
"fallbackTitle": "Paketübersicht",
"subtitle": "Hier ist dein aktuelles Paket auf einen Blick.",
"limitPhotos": "Fotos",
"limitGuests": "Gäste",
"limitDays": "Galerietage",
"remaining": "Verbleibende Events",
"purchased": "Gekauft",
"expires": "Läuft ab",
"unlimited": "Unbegrenzt",
"unknown": "Unbekannt",
"featuresTitle": "Enthaltene Features",
"feature": {
"priority_support": "Priority Support",
"custom_domain": "Eigene Domain",
"analytics": "Analytics",
"team_management": "Team-Management",
"moderation_tools": "Moderations-Tools",
"prints": "Print-Uploads",
"photo_likes_enabled": "Foto-Likes",
"event_checklist": "Event-Checkliste",
"advanced_analytics": "Erweiterte Analytics",
"branding_allowed": "Branding",
"watermark_allowed": "Wasserzeichen"
},
"hint": "Im Billing kannst du dein Paket jederzeit prüfen oder upgraden.",
"continue": "Weiter zum Event-Setup",
"dismiss": "Schließen",
"bannerTitle": "Deine Paketübersicht",
"bannerSubtitle": "{{name}} ist aktiv. Prüfe Limits & Features.",
"bannerCta": "Ansehen"
},
"pickEvent": "Event auswählen", "pickEvent": "Event auswählen",
"status": { "status": {
"published": "Live", "published": "Live",

View File

@@ -15,7 +15,8 @@
"description": "Mit nur wenigen Schritten führst du deine Gäste durch ein magisches Fotoabenteuer inklusive Storytelling, Aufgaben und moderierter Galerie.", "description": "Mit nur wenigen Schritten führst du deine Gäste durch ein magisches Fotoabenteuer inklusive Storytelling, Aufgaben und moderierter Galerie.",
"primary": { "primary": {
"label": "Pakete entdecken", "label": "Pakete entdecken",
"button": "Pakete entdecken" "button": "Pakete entdecken",
"billing": "Zum Billing"
}, },
"secondary": { "secondary": {
"label": "Events anzeigen", "label": "Events anzeigen",
@@ -246,7 +247,8 @@
"cta": { "cta": {
"heading": "Bereit für dein erstes Event?", "heading": "Bereit für dein erstes Event?",
"description": "Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die Gästegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zurückkehren.", "description": "Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die Gästegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zurückkehren.",
"button": "Event erstellen" "button": "Event erstellen",
"billing": "Zum Billing"
}, },
"actions": { "actions": {
"back": { "back": {

View File

@@ -2065,6 +2065,39 @@
"storageUnprotected": "Not protected", "storageUnprotected": "Not protected",
"storageAction": "Protect now" "storageAction": "Protect now"
}, },
"packageSummary": {
"title": "Your package summary",
"fallbackTitle": "Package summary",
"subtitle": "Here is a quick overview of your active package.",
"limitPhotos": "Photos",
"limitGuests": "Guests",
"limitDays": "Gallery days",
"remaining": "Remaining events",
"purchased": "Purchased",
"expires": "Expires",
"unlimited": "Unlimited",
"unknown": "Unknown",
"featuresTitle": "Included features",
"feature": {
"priority_support": "Priority support",
"custom_domain": "Custom domain",
"analytics": "Analytics",
"team_management": "Team management",
"moderation_tools": "Moderation tools",
"prints": "Print uploads",
"photo_likes_enabled": "Photo likes",
"event_checklist": "Event checklist",
"advanced_analytics": "Advanced analytics",
"branding_allowed": "Branding",
"watermark_allowed": "Watermarks"
},
"hint": "You can revisit billing any time to review or upgrade your package.",
"continue": "Continue to event setup",
"dismiss": "Close",
"bannerTitle": "Your package summary",
"bannerSubtitle": "{{name}} is active. Review limits & features.",
"bannerCta": "View"
},
"pickEvent": "Select an event", "pickEvent": "Select an event",
"status": { "status": {
"published": "Live", "published": "Live",

View File

@@ -15,7 +15,8 @@
"description": "In just a few steps you guide guests through a magical photo journey complete with storytelling, tasks, and a moderated gallery.", "description": "In just a few steps you guide guests through a magical photo journey complete with storytelling, tasks, and a moderated gallery.",
"primary": { "primary": {
"label": "Explore packages", "label": "Explore packages",
"button": "Explore packages" "button": "Explore packages",
"billing": "Open billing"
}, },
"secondary": { "secondary": {
"label": "View events", "label": "View events",
@@ -246,7 +247,8 @@
"cta": { "cta": {
"heading": "Ready for your first event?", "heading": "Ready for your first event?",
"description": "You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.", "description": "You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.",
"button": "Create event" "button": "Create event",
"billing": "Open billing"
}, },
"actions": { "actions": {
"back": { "back": {

View File

@@ -11,12 +11,13 @@ import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } f
import { MobileSheet } from './components/Sheet'; import { MobileSheet } from './components/Sheet';
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants'; import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent, getEvents } from '../api'; import { fetchOnboardingStatus, getEventStats, EventStats, TenantEvent, getEvents, getTenantPackagesOverview } from '../api';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription'; import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions'; import { useDevicePermissions } from './hooks/useDevicePermissions';
import { useInstallPrompt } from './hooks/useInstallPrompt'; import { useInstallPrompt } from './hooks/useInstallPrompt';
import { getTourSeen, resolveTourStepKeys, setTourSeen, type TourStepKey } from './lib/mobileTour'; import { getTourSeen, resolveTourStepKeys, setTourSeen, type TourStepKey } from './lib/mobileTour';
import { formatPackageLimit, getPackageFeatureLabel } from './lib/packageSummary';
import { trackOnboarding } from '../api'; import { trackOnboarding } from '../api';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme'; import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
@@ -39,6 +40,8 @@ export default function MobileDashboardPage() {
const [fallbackAttempted, setFallbackAttempted] = React.useState(false); const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const [tourOpen, setTourOpen] = React.useState(false); const [tourOpen, setTourOpen] = React.useState(false);
const [tourStep, setTourStep] = React.useState(0); const [tourStep, setTourStep] = React.useState(0);
const [summaryOpen, setSummaryOpen] = React.useState(false);
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
const onboardingTrackedRef = React.useRef(false); const onboardingTrackedRef = React.useRef(false);
const installPrompt = useInstallPrompt(); const installPrompt = useInstallPrompt();
const pushState = useAdminPushSubscription(); const pushState = useAdminPushSubscription();
@@ -64,6 +67,16 @@ export default function MobileDashboardPage() {
queryFn: () => getEvents({ force: true }), queryFn: () => getEvents({ force: true }),
staleTime: 60_000, staleTime: 60_000,
}); });
const { data: onboardingStatus, isLoading: onboardingLoading } = useQuery({
queryKey: ['mobile', 'onboarding', 'status'],
queryFn: fetchOnboardingStatus,
staleTime: 60_000,
});
const { data: packagesOverview, isLoading: packagesLoading, isError: packagesError } = useQuery({
queryKey: ['mobile', 'onboarding', 'packages-overview'],
queryFn: () => getTenantPackagesOverview({ force: true }),
staleTime: 60_000,
});
const effectiveEvents = events.length ? events : dashboardEvents?.length ? dashboardEvents : fallbackEvents; const effectiveEvents = events.length ? events : dashboardEvents?.length ? dashboardEvents : fallbackEvents;
const effectiveHasEvents = hasEvents || Boolean(dashboardEvents?.length) || fallbackEvents.length > 0; const effectiveHasEvents = hasEvents || Boolean(dashboardEvents?.length) || fallbackEvents.length > 0;
const effectiveMultiple = const effectiveMultiple =
@@ -115,6 +128,41 @@ export default function MobileDashboardPage() {
setTourOpen(true); setTourOpen(true);
}, [forceTour, location.pathname, navigate]); }, [forceTour, location.pathname, navigate]);
const activePackage =
packagesOverview?.activePackage ?? packagesOverview?.packages?.find((pkg) => pkg.active) ?? null;
const remainingEvents = activePackage?.remaining_events ?? null;
const summarySeenPackageId =
summarySeenOverride ?? onboardingStatus?.steps?.summary_seen_package_id ?? null;
const hasSummaryPackage =
Boolean(activePackage?.id && activePackage.id !== summarySeenPackageId);
const shouldRedirectToBilling =
!packagesLoading &&
!packagesError &&
!effectiveHasEvents &&
(activePackage === null || (remainingEvents !== null && remainingEvents <= 0));
React.useEffect(() => {
if (packagesLoading || !shouldRedirectToBilling) {
return;
}
navigate(adminPath('/mobile/billing#packages'), { replace: true });
}, [navigate, packagesLoading, shouldRedirectToBilling]);
React.useEffect(() => {
if (packagesLoading || packagesError || onboardingLoading) {
return;
}
if (shouldRedirectToBilling) {
return;
}
if (hasSummaryPackage) {
setSummaryOpen(true);
}
}, [hasSummaryPackage, onboardingLoading, packagesLoading, shouldRedirectToBilling]);
const markTourSeen = React.useCallback(() => { const markTourSeen = React.useCallback(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
@@ -278,6 +326,37 @@ export default function MobileDashboardPage() {
</MobileSheet> </MobileSheet>
) : null; ) : null;
const handleSummaryClose = React.useCallback(() => {
setSummaryOpen(false);
if (activePackage?.id) {
setSummarySeenOverride(activePackage.id);
void trackOnboarding('summary_seen', { package_id: activePackage.id });
}
}, [activePackage?.id]);
const packageSummarySheet = activePackage ? (
<PackageSummarySheet
open={summaryOpen}
onClose={handleSummaryClose}
onContinue={() => {
handleSummaryClose();
navigate(adminPath('/mobile/events/new'));
}}
packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')}
remainingEvents={remainingEvents}
purchasedAt={activePackage.purchased_at}
expiresAt={activePackage.expires_at}
limits={activePackage.package_limits ?? null}
features={activePackage.features ?? null}
locale={locale}
/>
) : null;
const showPackageSummaryBanner =
Boolean(activePackage && summarySeenPackageId === activePackage.id) &&
!summaryOpen &&
!packagesLoading &&
!packagesError;
React.useEffect(() => { React.useEffect(() => {
if (events.length || isLoading || fallbackLoading || fallbackAttempted) { if (events.length || isLoading || fallbackLoading || fallbackAttempted) {
return; return;
@@ -306,6 +385,7 @@ export default function MobileDashboardPage() {
))} ))}
</YStack> </YStack>
{tourSheet} {tourSheet}
{packageSummarySheet}
</MobileShell> </MobileShell>
); );
} }
@@ -313,6 +393,12 @@ export default function MobileDashboardPage() {
if (!effectiveHasEvents) { if (!effectiveHasEvents) {
return ( return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}> <MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
{showPackageSummaryBanner ? (
<PackageSummaryBanner
packageName={activePackage?.package_name}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
<OnboardingEmptyState <OnboardingEmptyState
installPrompt={installPrompt} installPrompt={installPrompt}
pushState={pushState} pushState={pushState}
@@ -320,6 +406,7 @@ export default function MobileDashboardPage() {
onOpenSettings={() => navigate(adminPath('/mobile/settings'))} onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
/> />
{tourSheet} {tourSheet}
{packageSummarySheet}
</MobileShell> </MobileShell>
); );
} }
@@ -331,8 +418,15 @@ export default function MobileDashboardPage() {
title={t('mobileDashboard.title', 'Dashboard')} title={t('mobileDashboard.title', 'Dashboard')}
subtitle={t('mobileDashboard.selectEvent', 'Select an event to continue')} subtitle={t('mobileDashboard.selectEvent', 'Select an event to continue')}
> >
{showPackageSummaryBanner ? (
<PackageSummaryBanner
packageName={activePackage?.package_name}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
<EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} /> <EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} />
{tourSheet} {tourSheet}
{packageSummarySheet}
</MobileShell> </MobileShell>
); );
} }
@@ -343,6 +437,12 @@ export default function MobileDashboardPage() {
title={resolveEventDisplayName(activeEvent ?? undefined)} title={resolveEventDisplayName(activeEvent ?? undefined)}
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined} subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
> >
{showPackageSummaryBanner ? (
<PackageSummaryBanner
packageName={activePackage?.package_name}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
<DeviceSetupCard <DeviceSetupCard
installPrompt={installPrompt} installPrompt={installPrompt}
pushState={pushState} pushState={pushState}
@@ -374,10 +474,168 @@ export default function MobileDashboardPage() {
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} /> <AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
{tourSheet} {tourSheet}
{packageSummarySheet}
</MobileShell> </MobileShell>
); );
} }
function PackageSummarySheet({
open,
onClose,
onContinue,
packageName,
remainingEvents,
purchasedAt,
expiresAt,
limits,
features,
locale,
}: {
open: boolean;
onClose: () => void;
onContinue: () => void;
packageName: string;
remainingEvents: number | null | undefined;
purchasedAt: string | null | undefined;
expiresAt: string | null | undefined;
limits: Record<string, unknown> | null;
features: string[] | null;
locale: string;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
const maxPhotos = (limits as Record<string, number | null> | null)?.max_photos ?? null;
const maxGuests = (limits as Record<string, number | null> | null)?.max_guests ?? null;
const galleryDays = (limits as Record<string, number | null> | null)?.gallery_days ?? null;
const hasFeatures = Array.isArray(features) && features.length > 0;
const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown');
return (
<MobileSheet open={open} title={t('mobileDashboard.packageSummary.title', 'Your package summary')} onClose={onClose}>
<YStack space="$3">
<MobileCard space="$2.5" borderColor={border} backgroundColor={surface}>
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color={text}>
{packageName}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.packageSummary.subtitle', 'Here is a quick overview of your active package.')}
</Text>
</YStack>
<YStack space="$2" marginTop="$2">
<SummaryRow label={t('mobileDashboard.packageSummary.limitPhotos', 'Photos')} value={formatPackageLimit(maxPhotos, t)} />
<SummaryRow label={t('mobileDashboard.packageSummary.limitGuests', 'Guests')} value={formatPackageLimit(maxGuests, t)} />
<SummaryRow label={t('mobileDashboard.packageSummary.limitDays', 'Gallery days')} value={formatPackageLimit(galleryDays, t)} />
<SummaryRow
label={t('mobileDashboard.packageSummary.remaining', 'Remaining events')}
value={formatPackageLimit(remainingEvents, t)}
/>
<SummaryRow label={t('mobileDashboard.packageSummary.purchased', 'Purchased')} value={formatDate(purchasedAt)} />
{expiresAt ? (
<SummaryRow label={t('mobileDashboard.packageSummary.expires', 'Expires')} value={formatDate(expiresAt)} />
) : null}
</YStack>
</MobileCard>
{hasFeatures ? (
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.packageSummary.featuresTitle', 'Included features')}
</Text>
<YStack space="$1.5" marginTop="$2">
{features?.map((feature) => (
<XStack key={feature} alignItems="center" space="$2">
<XStack width={24} height={24} borderRadius={8} backgroundColor={accentSoft} alignItems="center" justifyContent="center">
<Sparkles size={14} color={primary} />
</XStack>
<Text fontSize="$xs" color={text}>
{getPackageFeatureLabel(feature, t)}
</Text>
</XStack>
))}
</YStack>
</MobileCard>
) : null}
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.packageSummary.hint', 'You can revisit billing any time to review or upgrade your package.')}
</Text>
</MobileCard>
<XStack space="$2">
<CTAButton label={t('mobileDashboard.packageSummary.continue', 'Continue to event setup')} onPress={onContinue} fullWidth={false} />
<CTAButton label={t('mobileDashboard.packageSummary.dismiss', 'Close')} tone="ghost" onPress={onClose} fullWidth={false} />
</XStack>
</YStack>
</MobileSheet>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
const { textStrong, muted } = useAdminTheme();
return (
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={textStrong}>
{label}
</Text>
<Text fontSize="$xs" color={muted}>
{value}
</Text>
</XStack>
);
}
function PackageSummaryBanner({
packageName,
onOpen,
}: {
packageName?: string | null;
onOpen: () => void;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const text = textStrong;
return (
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack alignItems="center" space="$2" flex={1}>
<XStack
width={36}
height={36}
borderRadius={12}
backgroundColor={accentSoft}
alignItems="center"
justifyContent="center"
>
<Sparkles size={16} color={primary} />
</XStack>
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.packageSummary.bannerTitle', 'Your package summary')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.packageSummary.bannerSubtitle', {
name: packageName ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary'),
defaultValue: '{{name}} is active. Review limits & features.',
})}
</Text>
</YStack>
</XStack>
<CTAButton
label={t('mobileDashboard.packageSummary.bannerCta', 'View')}
tone="ghost"
fullWidth={false}
onPress={onOpen}
/>
</XStack>
</MobileCard>
);
}
function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) { function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme(); const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();

View File

@@ -123,10 +123,14 @@ export default function MobileLoginPage() {
(mutation as { isPending?: boolean; isLoading?: boolean }).isPending ?? (mutation as { isPending?: boolean; isLoading?: boolean }).isPending ??
(mutation as { isPending?: boolean; isLoading?: boolean }).isLoading ?? (mutation as { isPending?: boolean; isLoading?: boolean }).isLoading ??
false; false;
const isFormValid = login.trim().length > 0 && password.length > 0;
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
if (!isFormValid) {
return;
}
mutation.mutate({ mutation.mutate({
login, login,
password, password,
@@ -228,7 +232,7 @@ export default function MobileLoginPage() {
<Button <Button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting || !isFormValid}
height={52} height={52}
borderRadius={16} borderRadius={16}
backgroundColor={primary} backgroundColor={primary}

View File

@@ -48,15 +48,18 @@ type MobileSelectProps = React.ComponentPropsWithoutRef<'select'> & ControlProps
}; };
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>( export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
function MobileInput({ hasError = false, compact = false, style, onChange, ...props }, ref) { function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme(); const { border, surface, text, primary, danger } = useAdminTheme();
const borderColor = hasError ? danger : border; const borderColor = hasError ? danger : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18); const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
const isPassword = type === 'password';
return ( return (
<Input <Input
ref={ref as React.Ref<any>} ref={ref as React.Ref<any>}
{...props} {...props}
type={type}
secureTextEntry={isPassword}
onChangeText={(value) => { onChangeText={(value) => {
onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>); onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
}} }}

View File

@@ -1,9 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveOnboardingRedirect } from './onboardingGuard'; import { resolveOnboardingRedirect } from './onboardingGuard';
import { import {
ADMIN_WELCOME_BASE_PATH, ADMIN_BILLING_PATH,
ADMIN_WELCOME_EVENT_PATH,
ADMIN_WELCOME_SUMMARY_PATH,
} from '../../constants'; } from '../../constants';
describe('resolveOnboardingRedirect', () => { describe('resolveOnboardingRedirect', () => {
@@ -11,23 +9,8 @@ describe('resolveOnboardingRedirect', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: true, hasEvents: true,
hasActivePackage: false, hasActivePackage: false,
selectedPackageId: null, remainingEvents: null,
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
});
expect(result).toBeNull();
});
it('returns null for welcome paths', () => {
const result = resolveOnboardingRedirect({
hasEvents: false,
hasActivePackage: false,
selectedPackageId: null,
pathname: ADMIN_WELCOME_BASE_PATH,
isWelcomePath: true,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false, isOnboardingDismissed: false,
isOnboardingCompleted: false, isOnboardingCompleted: false,
@@ -39,9 +22,8 @@ describe('resolveOnboardingRedirect', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: false,
selectedPackageId: null, remainingEvents: null,
pathname: '/event-admin/mobile/billing', pathname: ADMIN_BILLING_PATH,
isWelcomePath: false,
isBillingPath: true, isBillingPath: true,
isOnboardingDismissed: false, isOnboardingDismissed: false,
isOnboardingCompleted: false, isOnboardingCompleted: false,
@@ -53,9 +35,8 @@ describe('resolveOnboardingRedirect', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: true, hasActivePackage: true,
selectedPackageId: null, remainingEvents: 1,
pathname: '/event-admin/mobile/events/new', pathname: '/event-admin/mobile/events/new',
isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false, isOnboardingDismissed: false,
isOnboardingCompleted: false, isOnboardingCompleted: false,
@@ -63,55 +44,51 @@ describe('resolveOnboardingRedirect', () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it('redirects to event setup when package active', () => { it('redirects to billing when no active package', () => {
const result = resolveOnboardingRedirect({
hasEvents: false,
hasActivePackage: false,
remainingEvents: null,
pathname: '/event-admin/mobile/dashboard',
isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
});
expect(result).toBe(ADMIN_BILLING_PATH);
});
it('redirects to billing when no remaining events', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: true, hasActivePackage: true,
selectedPackageId: null, remainingEvents: 0,
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false, isOnboardingDismissed: false,
isOnboardingCompleted: false, isOnboardingCompleted: false,
}); });
expect(result).toBe(ADMIN_WELCOME_EVENT_PATH); expect(result).toBe(ADMIN_BILLING_PATH);
}); });
it('redirects to summary when selection exists', () => { it('returns null when remaining events are available', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: true,
selectedPackageId: 5, remainingEvents: 2,
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false, isOnboardingDismissed: false,
isOnboardingCompleted: false, isOnboardingCompleted: false,
}); });
expect(result).toBe(ADMIN_WELCOME_SUMMARY_PATH); expect(result).toBeNull();
}); });
it('redirects to landing when no selection exists', () => { it('returns null when remaining events are unlimited', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: true,
selectedPackageId: null, remainingEvents: null,
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: false,
});
expect(result).toBe(ADMIN_WELCOME_BASE_PATH);
});
it('does not redirect when already on target', () => {
const result = resolveOnboardingRedirect({
hasEvents: false,
hasActivePackage: false,
selectedPackageId: null,
pathname: ADMIN_WELCOME_BASE_PATH,
isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false, isOnboardingDismissed: false,
isOnboardingCompleted: false, isOnboardingCompleted: false,
@@ -123,9 +100,8 @@ describe('resolveOnboardingRedirect', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: false,
selectedPackageId: null, remainingEvents: null,
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: true, isOnboardingDismissed: true,
isOnboardingCompleted: false, isOnboardingCompleted: false,
@@ -137,11 +113,9 @@ describe('resolveOnboardingRedirect', () => {
const result = resolveOnboardingRedirect({ const result = resolveOnboardingRedirect({
hasEvents: false, hasEvents: false,
hasActivePackage: false, hasActivePackage: false,
selectedPackageId: null, remainingEvents: null,
pathname: '/event-admin/mobile/dashboard', pathname: '/event-admin/mobile/dashboard',
isWelcomePath: false,
isBillingPath: false, isBillingPath: false,
isOnboardingDismissed: false,
isOnboardingCompleted: true, isOnboardingCompleted: true,
}); });
expect(result).toBeNull(); expect(result).toBeNull();

View File

@@ -1,16 +1,13 @@
import { import {
ADMIN_EVENT_CREATE_PATH, ADMIN_EVENT_CREATE_PATH,
ADMIN_WELCOME_BASE_PATH, ADMIN_BILLING_PATH,
ADMIN_WELCOME_EVENT_PATH,
ADMIN_WELCOME_SUMMARY_PATH,
} from '../../constants'; } from '../../constants';
type OnboardingRedirectInput = { type OnboardingRedirectInput = {
hasEvents: boolean; hasEvents: boolean;
hasActivePackage: boolean; hasActivePackage: boolean;
selectedPackageId?: number | null; remainingEvents?: number | null;
pathname: string; pathname: string;
isWelcomePath: boolean;
isBillingPath: boolean; isBillingPath: boolean;
isOnboardingDismissed?: boolean; isOnboardingDismissed?: boolean;
isOnboardingCompleted?: boolean; isOnboardingCompleted?: boolean;
@@ -19,22 +16,17 @@ type OnboardingRedirectInput = {
export function resolveOnboardingRedirect({ export function resolveOnboardingRedirect({
hasEvents, hasEvents,
hasActivePackage, hasActivePackage,
selectedPackageId, remainingEvents,
pathname, pathname,
isWelcomePath,
isBillingPath, isBillingPath,
isOnboardingDismissed, isOnboardingDismissed,
isOnboardingCompleted, isOnboardingCompleted,
}: OnboardingRedirectInput): string | null { }: OnboardingRedirectInput): string | null {
if (hasEvents) {
return null;
}
if (isOnboardingDismissed || isOnboardingCompleted) { if (isOnboardingDismissed || isOnboardingCompleted) {
return null; return null;
} }
if (isWelcomePath || isBillingPath) { if (isBillingPath) {
return null; return null;
} }
@@ -42,16 +34,9 @@ export function resolveOnboardingRedirect({
return null; return null;
} }
const shouldContinueSummary = Boolean(selectedPackageId && selectedPackageId > 0); if (!hasEvents && (!hasActivePackage || (remainingEvents !== null && remainingEvents <= 0))) {
const target = hasActivePackage return ADMIN_BILLING_PATH;
? ADMIN_WELCOME_EVENT_PATH
: shouldContinueSummary
? ADMIN_WELCOME_SUMMARY_PATH
: ADMIN_WELCOME_BASE_PATH;
if (pathname === target) {
return null;
} }
return target; return null;
} }

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { formatPackageLimit, getPackageFeatureLabel } from './packageSummary';
const t = (key: string, options?: Record<string, unknown> | string) => {
if (typeof options === 'string') {
return options;
}
return (options?.defaultValue as string | undefined) ?? key;
};
describe('packageSummary helpers', () => {
it('returns translated labels for known features', () => {
expect(getPackageFeatureLabel('priority_support', t)).toBe('Priority support');
});
it('falls back to raw feature key for unknown features', () => {
expect(getPackageFeatureLabel('custom_feature', t)).toBe('custom_feature');
});
it('formats unlimited package limits', () => {
expect(formatPackageLimit(null, t)).toBe('Unlimited');
});
it('formats numeric package limits', () => {
expect(formatPackageLimit(12, t)).toBe('12');
});
});

View File

@@ -0,0 +1,65 @@
type Translate = (key: string, options?: Record<string, unknown> | string) => string;
const FEATURE_LABELS: Record<string, { key: string; fallback: string }> = {
priority_support: {
key: 'mobileDashboard.packageSummary.feature.priority_support',
fallback: 'Priority support',
},
custom_domain: {
key: 'mobileDashboard.packageSummary.feature.custom_domain',
fallback: 'Custom domain',
},
analytics: {
key: 'mobileDashboard.packageSummary.feature.analytics',
fallback: 'Analytics',
},
team_management: {
key: 'mobileDashboard.packageSummary.feature.team_management',
fallback: 'Team management',
},
moderation_tools: {
key: 'mobileDashboard.packageSummary.feature.moderation_tools',
fallback: 'Moderation tools',
},
prints: {
key: 'mobileDashboard.packageSummary.feature.prints',
fallback: 'Print uploads',
},
photo_likes_enabled: {
key: 'mobileDashboard.packageSummary.feature.photo_likes_enabled',
fallback: 'Photo likes',
},
event_checklist: {
key: 'mobileDashboard.packageSummary.feature.event_checklist',
fallback: 'Event checklist',
},
advanced_analytics: {
key: 'mobileDashboard.packageSummary.feature.advanced_analytics',
fallback: 'Advanced analytics',
},
branding_allowed: {
key: 'mobileDashboard.packageSummary.feature.branding_allowed',
fallback: 'Branding',
},
watermark_allowed: {
key: 'mobileDashboard.packageSummary.feature.watermark_allowed',
fallback: 'Watermarks',
},
};
export function getPackageFeatureLabel(feature: string, t: Translate): string {
const entry = FEATURE_LABELS[feature];
if (entry) {
return t(entry.key, entry.fallback);
}
return t(`mobileDashboard.packageSummary.feature.${feature}`, feature);
}
export function formatPackageLimit(value: number | null | undefined, t: Translate): string {
if (value === null || value === undefined) {
return t('mobileDashboard.packageSummary.unlimited', 'Unlimited');
}
return String(value);
}

View File

@@ -25,6 +25,8 @@ export default function WelcomeEventPage() {
const hasActivePackage = const hasActivePackage =
Boolean(overview?.activePackage) || Boolean(overview?.packages?.some((pkg) => pkg.active)); Boolean(overview?.activePackage) || Boolean(overview?.packages?.some((pkg) => pkg.active));
const remainingEvents = overview?.activePackage?.remaining_events ?? null;
const shouldGoBilling = !hasActivePackage || (remainingEvents !== null && remainingEvents <= 0);
const handleSkip = React.useCallback(() => { const handleSkip = React.useCallback(() => {
void trackOnboarding('dismissed'); void trackOnboarding('dismissed');
navigate(ADMIN_HOME_PATH); navigate(ADMIN_HOME_PATH);
@@ -87,7 +89,10 @@ export default function WelcomeEventPage() {
"You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.", "You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.",
)} )}
</Text> </Text>
<CTAButton label={t('eventSetup.cta.button', 'Create event')} onPress={() => navigate(adminPath('/mobile/events/new'))} /> <CTAButton
label={shouldGoBilling ? t('eventSetup.cta.billing', 'Open billing') : t('eventSetup.cta.button', 'Create event')}
onPress={() => navigate(shouldGoBilling ? adminPath('/mobile/billing#packages') : adminPath('/mobile/events/new'))}
/>
</MobileCard> </MobileCard>
<YStack space="$2"> <YStack space="$2">

View File

@@ -30,6 +30,8 @@ export default function WelcomeLandingPage() {
const hasActivePackage = const hasActivePackage =
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active)); Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
const remainingEvents = packagesData?.activePackage?.remaining_events ?? null;
const shouldGoBilling = !hasActivePackage || (remainingEvents !== null && remainingEvents <= 0);
const handleSkip = React.useCallback(() => { const handleSkip = React.useCallback(() => {
void trackOnboarding('dismissed'); void trackOnboarding('dismissed');
navigate(ADMIN_HOME_PATH); navigate(ADMIN_HOME_PATH);
@@ -60,11 +62,11 @@ export default function WelcomeLandingPage() {
<XStack space="$2" flexWrap="wrap"> <XStack space="$2" flexWrap="wrap">
<CTAButton <CTAButton
label={ label={
hasActivePackage shouldGoBilling
? t('ctaList.createEvent.button', 'Go to event manager') ? t('hero.primary.billing', 'Open billing')
: t('hero.primary.button', 'Explore packages') : t('ctaList.createEvent.button', 'Go to event manager')
} }
onPress={() => navigate(hasActivePackage ? ADMIN_WELCOME_EVENT_PATH : ADMIN_WELCOME_PACKAGES_PATH)} onPress={() => navigate(shouldGoBilling ? adminPath('/mobile/billing#packages') : ADMIN_WELCOME_EVENT_PATH)}
fullWidth={false} fullWidth={false}
/> />
{hasEvents ? ( {hasEvents ? (

View File

@@ -15,7 +15,6 @@ import {
ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_BASE_PATH,
} from './constants'; } from './constants';
import { fetchOnboardingStatus, getTenantPackagesOverview } from './api'; import { fetchOnboardingStatus, getTenantPackagesOverview } from './api';
import { getSelectedPackageId } from './mobile/lib/onboardingSelection';
import { resolveOnboardingRedirect } from './mobile/lib/onboardingGuard'; import { resolveOnboardingRedirect } from './mobile/lib/onboardingGuard';
const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage')); const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
const LoginStartPage = React.lazy(() => import('./mobile/LoginStartPage')); const LoginStartPage = React.lazy(() => import('./mobile/LoginStartPage'));
@@ -56,7 +55,6 @@ function RequireAuth() {
const { status, user } = useAuth(); const { status, user } = useAuth();
const location = useLocation(); const location = useLocation();
const { hasEvents, isLoading: eventsLoading } = useEventContext(); const { hasEvents, isLoading: eventsLoading } = useEventContext();
const selectedPackageId = getSelectedPackageId();
const isWelcomePath = location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH); const isWelcomePath = location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH);
const isBillingPath = location.pathname.startsWith(ADMIN_BILLING_PATH); const isBillingPath = location.pathname.startsWith(ADMIN_BILLING_PATH);
const isTenantAdmin = Boolean(user && user.role !== 'member'); const isTenantAdmin = Boolean(user && user.role !== 'member');
@@ -79,6 +77,7 @@ function RequireAuth() {
const hasActivePackage = const hasActivePackage =
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active)); Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
const remainingEvents = packagesData?.activePackage?.remaining_events ?? null;
const isOnboardingDismissed = Boolean(onboardingStatus?.steps?.dismissed_at); const isOnboardingDismissed = Boolean(onboardingStatus?.steps?.dismissed_at);
const isOnboardingCompleted = Boolean(onboardingStatus?.steps?.completed_at); const isOnboardingCompleted = Boolean(onboardingStatus?.steps?.completed_at);
const shouldBlockOnboarding = shouldCheckPackages && onboardingLoading; const shouldBlockOnboarding = shouldCheckPackages && onboardingLoading;
@@ -86,9 +85,8 @@ function RequireAuth() {
const redirectTarget = resolveOnboardingRedirect({ const redirectTarget = resolveOnboardingRedirect({
hasEvents, hasEvents,
hasActivePackage, hasActivePackage,
selectedPackageId, remainingEvents,
pathname: location.pathname, pathname: location.pathname,
isWelcomePath,
isBillingPath, isBillingPath,
isOnboardingDismissed, isOnboardingDismissed,
isOnboardingCompleted, isOnboardingCompleted,

View File

@@ -47,4 +47,30 @@ class OnboardingStatusTest extends TenantTestCase
$show->assertOk(); $show->assertOk();
$show->assertJsonPath('steps.completed_at', $completedAt); $show->assertJsonPath('steps.completed_at', $completedAt);
} }
public function test_tenant_can_mark_summary_seen(): void
{
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/onboarding', [
'step' => 'summary_seen',
'meta' => [
'package_id' => 123,
],
]);
$response->assertOk();
$this->tenant->refresh();
$seenPackageId = Arr::get($this->tenant->settings ?? [], 'onboarding.summary_seen_package_id');
$seenAt = Arr::get($this->tenant->settings ?? [], 'onboarding.summary_seen_at');
$this->assertSame(123, $seenPackageId);
$this->assertNotNull($seenAt);
$show = $this->authenticatedRequest('GET', '/api/v1/tenant/onboarding');
$show->assertOk();
$show->assertJsonPath('steps.summary_seen_package_id', 123);
$show->assertJsonPath('steps.summary_seen_at', $seenAt);
}
} }