Compare commits
3 Commits
54b3fa0d87
...
cc89cc667a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc89cc667a | ||
|
|
a796973861 | ||
|
|
eba212a056 |
@@ -34,6 +34,8 @@ class OnboardingController extends Controller
|
||||
'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'),
|
||||
'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'),
|
||||
'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'),
|
||||
'completed_at' => Arr::get($settings, 'onboarding.completed_at'),
|
||||
'branding_completed' => (bool) ($status['palette'] ?? false),
|
||||
@@ -86,6 +88,11 @@ class OnboardingController extends Controller
|
||||
Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String());
|
||||
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':
|
||||
Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String());
|
||||
break;
|
||||
|
||||
@@ -330,6 +330,8 @@ export type TenantOnboardingStatus = {
|
||||
admin_app_opened_at?: string | null;
|
||||
primary_event_id?: number | string | null;
|
||||
selected_packages?: unknown;
|
||||
summary_seen_package_id?: number | null;
|
||||
summary_seen_at?: string | null;
|
||||
dismissed_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
branding_completed?: boolean;
|
||||
|
||||
@@ -2061,6 +2061,39 @@
|
||||
"storageUnprotected": "Nicht geschützt",
|
||||
"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",
|
||||
"status": {
|
||||
"published": "Live",
|
||||
|
||||
@@ -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.",
|
||||
"primary": {
|
||||
"label": "Pakete entdecken",
|
||||
"button": "Pakete entdecken"
|
||||
"button": "Pakete entdecken",
|
||||
"billing": "Zum Billing"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "Events anzeigen",
|
||||
@@ -246,7 +247,8 @@
|
||||
"cta": {
|
||||
"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.",
|
||||
"button": "Event erstellen"
|
||||
"button": "Event erstellen",
|
||||
"billing": "Zum Billing"
|
||||
},
|
||||
"actions": {
|
||||
"back": {
|
||||
|
||||
@@ -2065,6 +2065,39 @@
|
||||
"storageUnprotected": "Not protected",
|
||||
"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",
|
||||
"status": {
|
||||
"published": "Live",
|
||||
|
||||
@@ -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.",
|
||||
"primary": {
|
||||
"label": "Explore packages",
|
||||
"button": "Explore packages"
|
||||
"button": "Explore packages",
|
||||
"billing": "Open billing"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View events",
|
||||
@@ -246,7 +247,8 @@
|
||||
"cta": {
|
||||
"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.",
|
||||
"button": "Create event"
|
||||
"button": "Create event",
|
||||
"billing": "Open billing"
|
||||
},
|
||||
"actions": {
|
||||
"back": {
|
||||
|
||||
@@ -11,12 +11,13 @@ import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } f
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
|
||||
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 { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
|
||||
import { useDevicePermissions } from './hooks/useDevicePermissions';
|
||||
import { useInstallPrompt } from './hooks/useInstallPrompt';
|
||||
import { getTourSeen, resolveTourStepKeys, setTourSeen, type TourStepKey } from './lib/mobileTour';
|
||||
import { formatPackageLimit, getPackageFeatureLabel } from './lib/packageSummary';
|
||||
import { trackOnboarding } from '../api';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
||||
@@ -39,6 +40,8 @@ export default function MobileDashboardPage() {
|
||||
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
|
||||
const [tourOpen, setTourOpen] = React.useState(false);
|
||||
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 installPrompt = useInstallPrompt();
|
||||
const pushState = useAdminPushSubscription();
|
||||
@@ -64,6 +67,16 @@ export default function MobileDashboardPage() {
|
||||
queryFn: () => getEvents({ force: true }),
|
||||
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 effectiveHasEvents = hasEvents || Boolean(dashboardEvents?.length) || fallbackEvents.length > 0;
|
||||
const effectiveMultiple =
|
||||
@@ -115,6 +128,41 @@ export default function MobileDashboardPage() {
|
||||
setTourOpen(true);
|
||||
}, [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(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -278,6 +326,37 @@ export default function MobileDashboardPage() {
|
||||
</MobileSheet>
|
||||
) : 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(() => {
|
||||
if (events.length || isLoading || fallbackLoading || fallbackAttempted) {
|
||||
return;
|
||||
@@ -306,6 +385,7 @@ export default function MobileDashboardPage() {
|
||||
))}
|
||||
</YStack>
|
||||
{tourSheet}
|
||||
{packageSummarySheet}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -313,6 +393,12 @@ export default function MobileDashboardPage() {
|
||||
if (!effectiveHasEvents) {
|
||||
return (
|
||||
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
|
||||
{showPackageSummaryBanner ? (
|
||||
<PackageSummaryBanner
|
||||
packageName={activePackage?.package_name}
|
||||
onOpen={() => setSummaryOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
<OnboardingEmptyState
|
||||
installPrompt={installPrompt}
|
||||
pushState={pushState}
|
||||
@@ -320,6 +406,7 @@ export default function MobileDashboardPage() {
|
||||
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
|
||||
/>
|
||||
{tourSheet}
|
||||
{packageSummarySheet}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -331,8 +418,15 @@ export default function MobileDashboardPage() {
|
||||
title={t('mobileDashboard.title', 'Dashboard')}
|
||||
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} />
|
||||
{tourSheet}
|
||||
{packageSummarySheet}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -343,6 +437,12 @@ export default function MobileDashboardPage() {
|
||||
title={resolveEventDisplayName(activeEvent ?? undefined)}
|
||||
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
|
||||
>
|
||||
{showPackageSummaryBanner ? (
|
||||
<PackageSummaryBanner
|
||||
packageName={activePackage?.package_name}
|
||||
onOpen={() => setSummaryOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
<DeviceSetupCard
|
||||
installPrompt={installPrompt}
|
||||
pushState={pushState}
|
||||
@@ -374,10 +474,168 @@ export default function MobileDashboardPage() {
|
||||
|
||||
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
|
||||
{tourSheet}
|
||||
{packageSummarySheet}
|
||||
</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) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||
|
||||
@@ -123,10 +123,14 @@ export default function MobileLoginPage() {
|
||||
(mutation as { isPending?: boolean; isLoading?: boolean }).isPending ??
|
||||
(mutation as { isPending?: boolean; isLoading?: boolean }).isLoading ??
|
||||
false;
|
||||
const isFormValid = login.trim().length > 0 && password.length > 0;
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
login,
|
||||
password,
|
||||
@@ -228,7 +232,7 @@ export default function MobileLoginPage() {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || !isFormValid}
|
||||
height={52}
|
||||
borderRadius={16}
|
||||
backgroundColor={primary}
|
||||
|
||||
@@ -48,15 +48,18 @@ type MobileSelectProps = React.ComponentPropsWithoutRef<'select'> & 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 borderColor = hasError ? danger : border;
|
||||
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
||||
const isPassword = type === 'password';
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref as React.Ref<any>}
|
||||
{...props}
|
||||
type={type}
|
||||
secureTextEntry={isPassword}
|
||||
onChangeText={(value) => {
|
||||
onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveOnboardingRedirect } from './onboardingGuard';
|
||||
import {
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
ADMIN_WELCOME_EVENT_PATH,
|
||||
ADMIN_WELCOME_SUMMARY_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
} from '../../constants';
|
||||
|
||||
describe('resolveOnboardingRedirect', () => {
|
||||
@@ -11,23 +9,8 @@ describe('resolveOnboardingRedirect', () => {
|
||||
const result = resolveOnboardingRedirect({
|
||||
hasEvents: true,
|
||||
hasActivePackage: false,
|
||||
selectedPackageId: null,
|
||||
remainingEvents: null,
|
||||
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,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: false,
|
||||
@@ -39,9 +22,8 @@ describe('resolveOnboardingRedirect', () => {
|
||||
const result = resolveOnboardingRedirect({
|
||||
hasEvents: false,
|
||||
hasActivePackage: false,
|
||||
selectedPackageId: null,
|
||||
pathname: '/event-admin/mobile/billing',
|
||||
isWelcomePath: false,
|
||||
remainingEvents: null,
|
||||
pathname: ADMIN_BILLING_PATH,
|
||||
isBillingPath: true,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: false,
|
||||
@@ -53,9 +35,8 @@ describe('resolveOnboardingRedirect', () => {
|
||||
const result = resolveOnboardingRedirect({
|
||||
hasEvents: false,
|
||||
hasActivePackage: true,
|
||||
selectedPackageId: null,
|
||||
remainingEvents: 1,
|
||||
pathname: '/event-admin/mobile/events/new',
|
||||
isWelcomePath: false,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: false,
|
||||
@@ -63,55 +44,51 @@ describe('resolveOnboardingRedirect', () => {
|
||||
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({
|
||||
hasEvents: false,
|
||||
hasActivePackage: true,
|
||||
selectedPackageId: null,
|
||||
remainingEvents: 0,
|
||||
pathname: '/event-admin/mobile/dashboard',
|
||||
isWelcomePath: false,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: 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({
|
||||
hasEvents: false,
|
||||
hasActivePackage: false,
|
||||
selectedPackageId: 5,
|
||||
hasActivePackage: true,
|
||||
remainingEvents: 2,
|
||||
pathname: '/event-admin/mobile/dashboard',
|
||||
isWelcomePath: false,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: 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({
|
||||
hasEvents: false,
|
||||
hasActivePackage: false,
|
||||
selectedPackageId: null,
|
||||
hasActivePackage: true,
|
||||
remainingEvents: null,
|
||||
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,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: false,
|
||||
@@ -123,9 +100,8 @@ describe('resolveOnboardingRedirect', () => {
|
||||
const result = resolveOnboardingRedirect({
|
||||
hasEvents: false,
|
||||
hasActivePackage: false,
|
||||
selectedPackageId: null,
|
||||
remainingEvents: null,
|
||||
pathname: '/event-admin/mobile/dashboard',
|
||||
isWelcomePath: false,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: true,
|
||||
isOnboardingCompleted: false,
|
||||
@@ -137,11 +113,9 @@ describe('resolveOnboardingRedirect', () => {
|
||||
const result = resolveOnboardingRedirect({
|
||||
hasEvents: false,
|
||||
hasActivePackage: false,
|
||||
selectedPackageId: null,
|
||||
remainingEvents: null,
|
||||
pathname: '/event-admin/mobile/dashboard',
|
||||
isWelcomePath: false,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: true,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import {
|
||||
ADMIN_EVENT_CREATE_PATH,
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
ADMIN_WELCOME_EVENT_PATH,
|
||||
ADMIN_WELCOME_SUMMARY_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
} from '../../constants';
|
||||
|
||||
type OnboardingRedirectInput = {
|
||||
hasEvents: boolean;
|
||||
hasActivePackage: boolean;
|
||||
selectedPackageId?: number | null;
|
||||
remainingEvents?: number | null;
|
||||
pathname: string;
|
||||
isWelcomePath: boolean;
|
||||
isBillingPath: boolean;
|
||||
isOnboardingDismissed?: boolean;
|
||||
isOnboardingCompleted?: boolean;
|
||||
@@ -19,22 +16,17 @@ type OnboardingRedirectInput = {
|
||||
export function resolveOnboardingRedirect({
|
||||
hasEvents,
|
||||
hasActivePackage,
|
||||
selectedPackageId,
|
||||
remainingEvents,
|
||||
pathname,
|
||||
isWelcomePath,
|
||||
isBillingPath,
|
||||
isOnboardingDismissed,
|
||||
isOnboardingCompleted,
|
||||
}: OnboardingRedirectInput): string | null {
|
||||
if (hasEvents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isOnboardingDismissed || isOnboardingCompleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isWelcomePath || isBillingPath) {
|
||||
if (isBillingPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -42,16 +34,9 @@ export function resolveOnboardingRedirect({
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldContinueSummary = Boolean(selectedPackageId && selectedPackageId > 0);
|
||||
const target = hasActivePackage
|
||||
? ADMIN_WELCOME_EVENT_PATH
|
||||
: shouldContinueSummary
|
||||
? ADMIN_WELCOME_SUMMARY_PATH
|
||||
: ADMIN_WELCOME_BASE_PATH;
|
||||
|
||||
if (pathname === target) {
|
||||
return null;
|
||||
if (!hasEvents && (!hasActivePackage || (remainingEvents !== null && remainingEvents <= 0))) {
|
||||
return ADMIN_BILLING_PATH;
|
||||
}
|
||||
|
||||
return target;
|
||||
return null;
|
||||
}
|
||||
|
||||
27
resources/js/admin/mobile/lib/packageSummary.test.ts
Normal file
27
resources/js/admin/mobile/lib/packageSummary.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
65
resources/js/admin/mobile/lib/packageSummary.ts
Normal file
65
resources/js/admin/mobile/lib/packageSummary.ts
Normal 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);
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export default function WelcomeEventPage() {
|
||||
|
||||
const hasActivePackage =
|
||||
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(() => {
|
||||
void trackOnboarding('dismissed');
|
||||
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.",
|
||||
)}
|
||||
</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>
|
||||
|
||||
<YStack space="$2">
|
||||
|
||||
@@ -30,6 +30,8 @@ export default function WelcomeLandingPage() {
|
||||
|
||||
const hasActivePackage =
|
||||
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(() => {
|
||||
void trackOnboarding('dismissed');
|
||||
navigate(ADMIN_HOME_PATH);
|
||||
@@ -60,11 +62,11 @@ export default function WelcomeLandingPage() {
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
<CTAButton
|
||||
label={
|
||||
hasActivePackage
|
||||
? t('ctaList.createEvent.button', 'Go to event manager')
|
||||
: t('hero.primary.button', 'Explore packages')
|
||||
shouldGoBilling
|
||||
? t('hero.primary.billing', 'Open billing')
|
||||
: 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}
|
||||
/>
|
||||
{hasEvents ? (
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
} from './constants';
|
||||
import { fetchOnboardingStatus, getTenantPackagesOverview } from './api';
|
||||
import { getSelectedPackageId } from './mobile/lib/onboardingSelection';
|
||||
import { resolveOnboardingRedirect } from './mobile/lib/onboardingGuard';
|
||||
const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
|
||||
const LoginStartPage = React.lazy(() => import('./mobile/LoginStartPage'));
|
||||
@@ -56,7 +55,6 @@ function RequireAuth() {
|
||||
const { status, user } = useAuth();
|
||||
const location = useLocation();
|
||||
const { hasEvents, isLoading: eventsLoading } = useEventContext();
|
||||
const selectedPackageId = getSelectedPackageId();
|
||||
const isWelcomePath = location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH);
|
||||
const isBillingPath = location.pathname.startsWith(ADMIN_BILLING_PATH);
|
||||
const isTenantAdmin = Boolean(user && user.role !== 'member');
|
||||
@@ -79,6 +77,7 @@ function RequireAuth() {
|
||||
|
||||
const hasActivePackage =
|
||||
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 isOnboardingCompleted = Boolean(onboardingStatus?.steps?.completed_at);
|
||||
const shouldBlockOnboarding = shouldCheckPackages && onboardingLoading;
|
||||
@@ -86,9 +85,8 @@ function RequireAuth() {
|
||||
const redirectTarget = resolveOnboardingRedirect({
|
||||
hasEvents,
|
||||
hasActivePackage,
|
||||
selectedPackageId,
|
||||
remainingEvents,
|
||||
pathname: location.pathname,
|
||||
isWelcomePath,
|
||||
isBillingPath,
|
||||
isOnboardingDismissed,
|
||||
isOnboardingCompleted,
|
||||
|
||||
@@ -47,4 +47,30 @@ class OnboardingStatusTest extends TenantTestCase
|
||||
$show->assertOk();
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user