Add dedicated mobile event add-ons page
This commit is contained in:
@@ -352,6 +352,8 @@ export type EventAddonCatalogItem = {
|
|||||||
label: string;
|
label: string;
|
||||||
price_id: string | null;
|
price_id: string | null;
|
||||||
increments?: Record<string, number>;
|
increments?: Record<string, number>;
|
||||||
|
price?: number | null;
|
||||||
|
currency?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TenantFontVariant = {
|
export type TenantFontVariant = {
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/mo
|
|||||||
export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/photobooth`);
|
export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/photobooth`);
|
||||||
export const ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH = (slug: string): string =>
|
export const ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH = (slug: string): string =>
|
||||||
adminPath(`/mobile/events/${encodeURIComponent(slug)}/guest-notifications`);
|
adminPath(`/mobile/events/${encodeURIComponent(slug)}/guest-notifications`);
|
||||||
export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`);
|
export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/recap`);
|
||||||
|
export const ADMIN_EVENT_ADDONS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/addons`);
|
||||||
export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/branding`);
|
export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/branding`);
|
||||||
export const ADMIN_EVENT_LIVE_SHOW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show`);
|
export const ADMIN_EVENT_LIVE_SHOW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show`);
|
||||||
export const ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH = (slug: string): string =>
|
export const ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH = (slug: string): string =>
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export default function MobileBillingPage() {
|
|||||||
const scopedEventPath = scopeEvent?.slug ? ADMIN_EVENT_VIEW_PATH(scopeEvent.slug) : null;
|
const scopedEventPath = scopeEvent?.slug ? ADMIN_EVENT_VIEW_PATH(scopeEvent.slug) : null;
|
||||||
const activeEventId = scopeEvent?.id ?? activeEvent?.id ?? null;
|
const activeEventId = scopeEvent?.id ?? activeEvent?.id ?? null;
|
||||||
const hasSellableAddons = React.useMemo(() => catalogAddons.some((addon) => Boolean(addon.price_id)), [catalogAddons]);
|
const hasSellableAddons = React.useMemo(() => catalogAddons.some((addon) => Boolean(addon.price_id)), [catalogAddons]);
|
||||||
const eventRecapPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/recap`) : null;
|
const eventAddonsPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/addons`) : null;
|
||||||
const eventControlRoomPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/control-room`) : null;
|
const eventControlRoomPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/control-room`) : null;
|
||||||
const scopedEventPackage = scopeEvent?.package ?? null;
|
const scopedEventPackage = scopeEvent?.package ?? null;
|
||||||
const scopedEventAddons = React.useMemo<EventAddonSummary[]>(() => {
|
const scopedEventAddons = React.useMemo<EventAddonSummary[]>(() => {
|
||||||
@@ -702,7 +702,7 @@ export default function MobileBillingPage() {
|
|||||||
<YStack gap="$2" marginTop="$1">
|
<YStack gap="$2" marginTop="$1">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('billing.sections.currentEvent.openAddonShop', 'Buy add-ons for this event')}
|
label={t('billing.sections.currentEvent.openAddonShop', 'Buy add-ons for this event')}
|
||||||
onPress={() => navigate(eventRecapPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/recap`))}
|
onPress={() => navigate(eventAddonsPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/addons`))}
|
||||||
/>
|
/>
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('billing.sections.currentEvent.openAddonUpsell', 'Open control-room upgrades')}
|
label={t('billing.sections.currentEvent.openAddonUpsell', 'Open control-room upgrades')}
|
||||||
@@ -896,7 +896,7 @@ export default function MobileBillingPage() {
|
|||||||
{scopeEvent?.slug && hasSellableAddons ? (
|
{scopeEvent?.slug && hasSellableAddons ? (
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('billing.sections.addOns.openShop', 'Buy add-ons now')}
|
label={t('billing.sections.addOns.openShop', 'Buy add-ons now')}
|
||||||
onPress={() => navigate(eventRecapPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/recap`))}
|
onPress={() => navigate(eventAddonsPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/addons`))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|||||||
558
resources/js/admin/mobile/EventAddonsPage.tsx
Normal file
558
resources/js/admin/mobile/EventAddonsPage.tsx
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Image as ImageIcon, Sparkles, Users, Clock3, Layers } from 'lucide-react';
|
||||||
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createEventAddonCheckout,
|
||||||
|
EventAddonCatalogItem,
|
||||||
|
getAddonCatalog,
|
||||||
|
getEvent,
|
||||||
|
getTenantAddonHistory,
|
||||||
|
TenantAddonHistoryEntry,
|
||||||
|
TenantEvent,
|
||||||
|
} from '../api';
|
||||||
|
import { getApiErrorMessage } from '../lib/apiError';
|
||||||
|
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||||
|
import { adminPath, ADMIN_EVENT_VIEW_PATH } from '../constants';
|
||||||
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
|
import { useAdminTheme } from './theme';
|
||||||
|
import { MobileShell } from './components/MobileShell';
|
||||||
|
import { CTAButton, MobileCard, PillBadge, SkeletonCard } from './components/Primitives';
|
||||||
|
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||||
|
import { scopeDefaults, selectAddonKeyForScope } from './addons';
|
||||||
|
|
||||||
|
type AddonScope = 'photos' | 'guests' | 'gallery' | 'bundle' | 'feature';
|
||||||
|
|
||||||
|
type ScopedAddon = EventAddonCatalogItem & {
|
||||||
|
scope: AddonScope;
|
||||||
|
price: number | null;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function translateLimits(t: any) {
|
||||||
|
return (key: string, options?: Record<string, unknown>) => t(`limits.${key}`, key, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAddonScope(addon: EventAddonCatalogItem): AddonScope {
|
||||||
|
const increments = addon.increments ?? {};
|
||||||
|
const photos = Number(increments.extra_photos ?? 0);
|
||||||
|
const guests = Number(increments.extra_guests ?? 0);
|
||||||
|
const gallery = Number(increments.extra_gallery_days ?? 0);
|
||||||
|
|
||||||
|
const count = Number(photos > 0) + Number(guests > 0) + Number(gallery > 0);
|
||||||
|
|
||||||
|
if (count === 0 || addon.key.includes('ai')) {
|
||||||
|
return 'feature';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 1 || addon.key.includes('boost') || addon.key.includes('bundle')) {
|
||||||
|
return 'bundle';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photos > 0) {
|
||||||
|
return 'photos';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guests > 0) {
|
||||||
|
return 'guests';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'gallery';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAddon(addon: EventAddonCatalogItem): ScopedAddon {
|
||||||
|
const payload = addon as EventAddonCatalogItem & { price?: number | null; currency?: string | null };
|
||||||
|
|
||||||
|
return {
|
||||||
|
...addon,
|
||||||
|
scope: resolveAddonScope(addon),
|
||||||
|
price: typeof payload.price === 'number' && Number.isFinite(payload.price) ? payload.price : null,
|
||||||
|
currency: typeof payload.currency === 'string' && payload.currency ? payload.currency : 'EUR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(value: number | null, currency: string): string {
|
||||||
|
if (value === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value);
|
||||||
|
} catch {
|
||||||
|
return `${value} ${currency}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeIcon(scope: AddonScope) {
|
||||||
|
if (scope === 'photos') {
|
||||||
|
return ImageIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === 'guests') {
|
||||||
|
return Users;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === 'gallery') {
|
||||||
|
return Clock3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return scope === 'bundle' ? Layers : Sparkles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeLabel(scope: AddonScope, t: (key: string, fallback: string) => string): string {
|
||||||
|
if (scope === 'photos') {
|
||||||
|
return t('events.addons.groups.photos', 'Photo capacity');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === 'guests') {
|
||||||
|
return t('events.addons.groups.guests', 'Guest capacity');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === 'gallery') {
|
||||||
|
return t('events.addons.groups.gallery', 'Gallery runtime');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === 'bundle') {
|
||||||
|
return t('events.addons.groups.bundle', 'Bundles');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('events.addons.groups.feature', 'Feature unlocks');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveScopeFromQuery(input: string | null): 'photos' | 'guests' | 'gallery' | 'ai' | null {
|
||||||
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === 'photos' || input === 'guests' || input === 'gallery' || input === 'ai') {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileEventAddonsPage() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { t } = useTranslation('management');
|
||||||
|
const { textStrong, text, muted, border, primary, danger } = useAdminTheme();
|
||||||
|
const back = useBackNavigation(slug ? ADMIN_EVENT_VIEW_PATH(slug) : adminPath('/mobile/events'));
|
||||||
|
|
||||||
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||||
|
const [catalog, setCatalog] = React.useState<ScopedAddon[]>([]);
|
||||||
|
const [history, setHistory] = React.useState<TenantAddonHistoryEntry[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [consentOpen, setConsentOpen] = React.useState(false);
|
||||||
|
const [checkoutBusy, setCheckoutBusy] = React.useState(false);
|
||||||
|
const [pendingAddonKey, setPendingAddonKey] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const queryScope = React.useMemo(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
return resolveScopeFromQuery(params.get('scope'));
|
||||||
|
}, [location.search]);
|
||||||
|
const queryAddonKey = React.useMemo(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
return params.get('addon');
|
||||||
|
}, [location.search]);
|
||||||
|
|
||||||
|
const load = React.useCallback(async () => {
|
||||||
|
if (!slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [eventData, catalogData] = await Promise.all([
|
||||||
|
getEvent(slug),
|
||||||
|
getAddonCatalog(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setEvent(eventData);
|
||||||
|
setCatalog(catalogData.map(normalizeAddon));
|
||||||
|
|
||||||
|
const historyResult = await getTenantAddonHistory({
|
||||||
|
eventId: eventData.id,
|
||||||
|
perPage: 20,
|
||||||
|
page: 1,
|
||||||
|
}).catch(() => ({ data: [] as TenantAddonHistoryEntry[] }));
|
||||||
|
setHistory(historyResult.data ?? []);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(getApiErrorMessage(err, 'Add-ons konnten nicht geladen werden.'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
if (!params.get('addon_success')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.'));
|
||||||
|
params.delete('addon_success');
|
||||||
|
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: adminPath(`/mobile/events/${slug}/addons`),
|
||||||
|
search: params.toString(),
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, [load, location.search, navigate, slug, t]);
|
||||||
|
|
||||||
|
const warnings = React.useMemo(() => buildLimitWarnings(event?.limits ?? null, translateLimits(t as any)), [event?.limits, t]);
|
||||||
|
|
||||||
|
const suggestedKeys = React.useMemo(() => {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
|
||||||
|
if (queryAddonKey) {
|
||||||
|
keys.add(queryAddonKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryScope === 'photos' || queryScope === 'guests' || queryScope === 'gallery') {
|
||||||
|
keys.add(selectAddonKeyForScope(catalog, queryScope));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryScope === 'ai') {
|
||||||
|
const aiAddon = catalog.find((addon) => addon.scope === 'feature' && addon.key.includes('ai'));
|
||||||
|
if (aiAddon) {
|
||||||
|
keys.add(aiAddon.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const warning of warnings) {
|
||||||
|
if (warning.scope === 'photos' || warning.scope === 'guests' || warning.scope === 'gallery') {
|
||||||
|
keys.add(selectAddonKeyForScope(catalog, warning.scope));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}, [catalog, queryAddonKey, queryScope, warnings]);
|
||||||
|
|
||||||
|
const grouped = React.useMemo(() => {
|
||||||
|
const scopes: AddonScope[] = ['photos', 'guests', 'gallery', 'bundle', 'feature'];
|
||||||
|
return scopes.map((scope) => {
|
||||||
|
const addons = catalog
|
||||||
|
.filter((addon) => addon.scope === scope)
|
||||||
|
.sort((left, right) => (left.price ?? Number.MAX_SAFE_INTEGER) - (right.price ?? Number.MAX_SAFE_INTEGER));
|
||||||
|
|
||||||
|
return { scope, addons };
|
||||||
|
});
|
||||||
|
}, [catalog]);
|
||||||
|
|
||||||
|
const recommendedAddons = React.useMemo(
|
||||||
|
() => catalog.filter((addon) => suggestedKeys.has(addon.key)),
|
||||||
|
[catalog, suggestedKeys],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasSellableAddons = catalog.some((addon) => Boolean(addon.price_id));
|
||||||
|
|
||||||
|
function openConsent(addonKey: string) {
|
||||||
|
if (!slug || checkoutBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingAddonKey(addonKey);
|
||||||
|
setConsentOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
|
||||||
|
if (!slug || !pendingAddonKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCheckoutBusy(true);
|
||||||
|
|
||||||
|
const pagePath = adminPath(`/mobile/events/${slug}/addons`);
|
||||||
|
const currentUrl = `${window.location.origin}${pagePath}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const checkout = await createEventAddonCheckout(slug, {
|
||||||
|
addon_key: pendingAddonKey,
|
||||||
|
success_url: `${currentUrl}?addon_success=1`,
|
||||||
|
cancel_url: currentUrl,
|
||||||
|
accepted_terms: consents.acceptedTerms,
|
||||||
|
accepted_waiver: consents.acceptedWaiver,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (checkout.checkout_url) {
|
||||||
|
window.location.href = checkout.checkout_url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(t('events.errors.checkoutMissing', 'Checkout could not be started.'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on checkout failed.')));
|
||||||
|
} finally {
|
||||||
|
setCheckoutBusy(false);
|
||||||
|
setConsentOpen(false);
|
||||||
|
setPendingAddonKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<MobileShell title={t('events.addons.title', 'Event add-ons')} activeTab="home" onBack={back}>
|
||||||
|
<YStack gap="$3">
|
||||||
|
<SkeletonCard height={120} />
|
||||||
|
<SkeletonCard height={220} />
|
||||||
|
<SkeletonCard height={180} />
|
||||||
|
</YStack>
|
||||||
|
</MobileShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !event) {
|
||||||
|
return (
|
||||||
|
<MobileShell title={t('events.addons.title', 'Event add-ons')} activeTab="home" onBack={back}>
|
||||||
|
<MobileCard>
|
||||||
|
<Text fontWeight="700" color={danger}>
|
||||||
|
{error || t('common.error', 'Something went wrong.')}
|
||||||
|
</Text>
|
||||||
|
<CTAButton label={t('common.retry', 'Retry')} tone="ghost" onPress={load} />
|
||||||
|
</MobileCard>
|
||||||
|
</MobileShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileShell title={t('events.addons.title', 'Event add-ons')} activeTab="home" onBack={back}>
|
||||||
|
<YStack gap="$3">
|
||||||
|
<MobileCard gap="$2">
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('events.addons.event', 'Event')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||||
|
{typeof event.name === 'string' ? event.name : t('events.placeholders.untitled', 'Untitled event')}
|
||||||
|
</Text>
|
||||||
|
{event.package?.name ? (
|
||||||
|
<XStack gap="$2" alignItems="center" flexWrap="wrap">
|
||||||
|
<PillBadge tone="success">{event.package.name}</PillBadge>
|
||||||
|
{event.package.expires_at ? (
|
||||||
|
<PillBadge tone="muted">
|
||||||
|
{t('events.addons.packageExpires', 'Gallery active until {{date}}', {
|
||||||
|
date: formatDate(event.package.expires_at),
|
||||||
|
})}
|
||||||
|
</PillBadge>
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('events.addons.noPackage', 'No package is currently assigned to this event.')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
{recommendedAddons.length > 0 ? (
|
||||||
|
<MobileCard gap="$2" borderColor={primary}>
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
|
{t('events.addons.recommendedTitle', 'Recommended now')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$sm" color={text}>
|
||||||
|
{t('events.addons.recommendedBody', 'Based on your event usage, these add-ons are likely most relevant.')}
|
||||||
|
</Text>
|
||||||
|
<YStack gap="$2">
|
||||||
|
{recommendedAddons.map((addon) => (
|
||||||
|
<AddonCard
|
||||||
|
key={`recommended-${addon.key}`}
|
||||||
|
addon={addon}
|
||||||
|
textStrong={textStrong}
|
||||||
|
muted={muted}
|
||||||
|
onBuy={openConsent}
|
||||||
|
busy={checkoutBusy && pendingAddonKey === addon.key}
|
||||||
|
isRecommended
|
||||||
|
t={t as any}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
</MobileCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!hasSellableAddons ? (
|
||||||
|
<MobileCard>
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('events.recap.noAddonAvailable', 'No add-ons are currently available.')}
|
||||||
|
</Text>
|
||||||
|
</MobileCard>
|
||||||
|
) : (
|
||||||
|
grouped.map((group) => {
|
||||||
|
if (!group.addons.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupIcon = scopeIcon(group.scope);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCard key={group.scope} gap="$2">
|
||||||
|
<XStack gap="$2" alignItems="center">
|
||||||
|
<GroupIcon size={16} color={primary} />
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
|
{scopeLabel(group.scope, t as any)}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
<YStack gap="$2">
|
||||||
|
{group.addons.map((addon) => (
|
||||||
|
<AddonCard
|
||||||
|
key={addon.key}
|
||||||
|
addon={addon}
|
||||||
|
textStrong={textStrong}
|
||||||
|
muted={muted}
|
||||||
|
onBuy={openConsent}
|
||||||
|
busy={checkoutBusy && pendingAddonKey === addon.key}
|
||||||
|
isRecommended={suggestedKeys.has(addon.key)}
|
||||||
|
t={t as any}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MobileCard gap="$2">
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
|
{t('events.addons.historyTitle', 'Purchased add-ons for this event')}
|
||||||
|
</Text>
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('billing.sections.currentEvent.noAddons', 'No add-ons purchased for this event.')}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<YStack gap="$1.5">
|
||||||
|
{history.map((entry) => (
|
||||||
|
<XStack
|
||||||
|
key={entry.id}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
borderBottomWidth={1}
|
||||||
|
borderColor={border}
|
||||||
|
paddingBottom="$1.5"
|
||||||
|
>
|
||||||
|
<YStack>
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
|
{entry.label ?? entry.addon_key}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{formatDate(entry.purchased_at)}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
<YStack alignItems="flex-end" gap="$1">
|
||||||
|
<Text fontSize="$sm" color={textStrong}>
|
||||||
|
{formatAmount(entry.amount, entry.currency)}
|
||||||
|
</Text>
|
||||||
|
<PillBadge tone={entry.status === 'completed' ? 'success' : entry.status === 'pending' ? 'warning' : 'muted'}>
|
||||||
|
{t(`mobileBilling.status.${entry.status}`, entry.status)}
|
||||||
|
</PillBadge>
|
||||||
|
</YStack>
|
||||||
|
</XStack>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
)}
|
||||||
|
</MobileCard>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<LegalConsentSheet
|
||||||
|
open={consentOpen}
|
||||||
|
busy={checkoutBusy}
|
||||||
|
onClose={() => {
|
||||||
|
if (!checkoutBusy) {
|
||||||
|
setConsentOpen(false);
|
||||||
|
setPendingAddonKey(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={confirmCheckout}
|
||||||
|
t={t as any}
|
||||||
|
/>
|
||||||
|
</MobileShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddonCard({
|
||||||
|
addon,
|
||||||
|
textStrong,
|
||||||
|
muted,
|
||||||
|
onBuy,
|
||||||
|
busy,
|
||||||
|
isRecommended,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
addon: ScopedAddon;
|
||||||
|
textStrong: string;
|
||||||
|
muted: string;
|
||||||
|
onBuy: (addonKey: string) => void;
|
||||||
|
busy: boolean;
|
||||||
|
isRecommended: boolean;
|
||||||
|
t: (key: string, fallback: string, options?: Record<string, unknown>) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MobileCard borderRadius={14} padding="$3" gap="$2" borderWidth={isRecommended ? 2 : 1}>
|
||||||
|
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||||
|
<YStack gap="$0.5" flex={1}>
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
|
{addon.label || addon.key}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{formatAmount(addon.price, addon.currency)}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
{isRecommended ? <PillBadge tone="warning">{t('events.addons.recommendedBadge', 'Recommended')}</PillBadge> : null}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<XStack gap="$2" flexWrap="wrap">
|
||||||
|
{Number(addon.increments?.extra_photos ?? 0) > 0 ? (
|
||||||
|
<PillBadge tone="muted">
|
||||||
|
{t('mobileBilling.extra.photos', '+{{count}} photos', { count: Number(addon.increments?.extra_photos ?? 0) })}
|
||||||
|
</PillBadge>
|
||||||
|
) : null}
|
||||||
|
{Number(addon.increments?.extra_guests ?? 0) > 0 ? (
|
||||||
|
<PillBadge tone="muted">
|
||||||
|
{t('mobileBilling.extra.guests', '+{{count}} guests', { count: Number(addon.increments?.extra_guests ?? 0) })}
|
||||||
|
</PillBadge>
|
||||||
|
) : null}
|
||||||
|
{Number(addon.increments?.extra_gallery_days ?? 0) > 0 ? (
|
||||||
|
<PillBadge tone="muted">
|
||||||
|
{t('mobileBilling.extra.days', '+{{count}} days', { count: Number(addon.increments?.extra_gallery_days ?? 0) })}
|
||||||
|
</PillBadge>
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<CTAButton
|
||||||
|
label={t('events.addons.buyAction', 'Buy add-on')}
|
||||||
|
onPress={() => onBuy(addon.key)}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Check, ChevronDown, Eye, EyeOff, Image as ImageIcon, RefreshCcw, Settings, Sparkles } from 'lucide-react';
|
import { Check, ChevronDown, Eye, EyeOff, Image as ImageIcon, RefreshCcw, Settings, Sparkles } from 'lucide-react';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
clearLiveShowPhoto,
|
clearLiveShowPhoto,
|
||||||
ControlRoomSettings,
|
ControlRoomSettings,
|
||||||
ControlRoomUploaderRule,
|
ControlRoomUploaderRule,
|
||||||
createEventAddonCheckout,
|
|
||||||
EventAddonCatalogItem,
|
EventAddonCatalogItem,
|
||||||
EventAiEditingSettings,
|
EventAiEditingSettings,
|
||||||
EventLimitSummary,
|
EventLimitSummary,
|
||||||
@@ -60,8 +59,6 @@ import {
|
|||||||
} from './lib/photoModerationQueue';
|
} from './lib/photoModerationQueue';
|
||||||
import { triggerHaptic } from './lib/haptics';
|
import { triggerHaptic } from './lib/haptics';
|
||||||
import { normalizeLiveStatus, resolveLiveShowApproveMode } from './lib/controlRoom';
|
import { normalizeLiveStatus, resolveLiveShowApproveMode } from './lib/controlRoom';
|
||||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
|
||||||
import { selectAddonKeyForScope } from './addons';
|
|
||||||
import { LimitWarnings } from './components/LimitWarnings';
|
import { LimitWarnings } from './components/LimitWarnings';
|
||||||
|
|
||||||
type ModerationFilter = 'all' | 'featured' | 'hidden' | 'pending';
|
type ModerationFilter = 'all' | 'featured' | 'hidden' | 'pending';
|
||||||
@@ -349,7 +346,6 @@ function isAiSettingsDirty(current: AiSettingsDraft, initial: AiSettingsDraft):
|
|||||||
export default function MobileEventControlRoomPage() {
|
export default function MobileEventControlRoomPage() {
|
||||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { activeEvent, selectEvent, refetch } = useEventContext();
|
const { activeEvent, selectEvent, refetch } = useEventContext();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -376,10 +372,6 @@ export default function MobileEventControlRoomPage() {
|
|||||||
});
|
});
|
||||||
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
|
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
|
||||||
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
||||||
const [busyScope, setBusyScope] = React.useState<string | null>(null);
|
|
||||||
const [consentOpen, setConsentOpen] = React.useState(false);
|
|
||||||
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests' | 'ai'; addonKey: string } | null>(null);
|
|
||||||
const [consentBusy, setConsentBusy] = React.useState(false);
|
|
||||||
|
|
||||||
const [livePhotos, setLivePhotos] = React.useState<TenantPhoto[]>([]);
|
const [livePhotos, setLivePhotos] = React.useState<TenantPhoto[]>([]);
|
||||||
const [liveStatusFilter, setLiveStatusFilter] = React.useState<LiveShowQueueStatus>('pending');
|
const [liveStatusFilter, setLiveStatusFilter] = React.useState<LiveShowQueueStatus>('pending');
|
||||||
@@ -918,21 +910,6 @@ export default function MobileEventControlRoomPage() {
|
|||||||
refreshCounts();
|
refreshCounts();
|
||||||
}, [refreshCounts]);
|
}, [refreshCounts]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!location.search || !slug) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
if (params.get('addon_success')) {
|
|
||||||
toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.'));
|
|
||||||
void refetch();
|
|
||||||
setModerationPage(1);
|
|
||||||
void loadModeration();
|
|
||||||
params.delete('addon_success');
|
|
||||||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
|
|
||||||
}
|
|
||||||
}, [location.search, slug, loadModeration, navigate, t, location.pathname, refetch]);
|
|
||||||
|
|
||||||
const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => {
|
const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => {
|
||||||
replacePhotoQueue(queue);
|
replacePhotoQueue(queue);
|
||||||
setQueuedActions(queue);
|
setQueuedActions(queue);
|
||||||
@@ -1134,7 +1111,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string) {
|
function resolveScope(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string): 'photos' | 'gallery' | 'guests' | 'ai' {
|
||||||
const scope =
|
const scope =
|
||||||
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
|
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
|
||||||
? scopeOrKey
|
? scopeOrKey
|
||||||
@@ -1143,57 +1120,25 @@ export default function MobileEventControlRoomPage() {
|
|||||||
: scopeOrKey.includes('gallery')
|
: scopeOrKey.includes('gallery')
|
||||||
? 'gallery'
|
? 'gallery'
|
||||||
: scopeOrKey.includes('guest')
|
: scopeOrKey.includes('guest')
|
||||||
? 'guests'
|
? 'guests'
|
||||||
: 'photos';
|
: 'photos';
|
||||||
|
|
||||||
const addonKey =
|
return scope as 'photos' | 'gallery' | 'guests' | 'ai';
|
||||||
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
|
|
||||||
? selectAddonKeyForScope(catalogAddons, scope as 'photos' | 'gallery' | 'guests')
|
|
||||||
: scopeOrKey === 'ai'
|
|
||||||
? aiStylingAddon?.key ?? 'ai_styling_unlock'
|
|
||||||
: scopeOrKey;
|
|
||||||
|
|
||||||
return { scope, addonKey };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string) {
|
function openAddonShop(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string) {
|
||||||
if (!slug) return;
|
if (!slug) {
|
||||||
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
|
return;
|
||||||
setConsentTarget({ scope: scope as 'photos' | 'gallery' | 'guests' | 'ai', addonKey });
|
|
||||||
setConsentOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
|
|
||||||
if (!slug || !consentTarget) return;
|
|
||||||
|
|
||||||
const currentUrl = typeof window !== 'undefined'
|
|
||||||
? `${window.location.origin}${adminPath(`/mobile/events/${slug}/control-room`)}`
|
|
||||||
: '';
|
|
||||||
const successUrl = `${currentUrl}?addon_success=1`;
|
|
||||||
setBusyScope(consentTarget.scope);
|
|
||||||
setConsentBusy(true);
|
|
||||||
try {
|
|
||||||
const checkout = await createEventAddonCheckout(slug, {
|
|
||||||
addon_key: consentTarget.addonKey,
|
|
||||||
quantity: 1,
|
|
||||||
success_url: successUrl,
|
|
||||||
cancel_url: currentUrl,
|
|
||||||
accepted_terms: consents.acceptedTerms,
|
|
||||||
accepted_waiver: consents.acceptedWaiver,
|
|
||||||
});
|
|
||||||
if (checkout.checkout_url) {
|
|
||||||
window.location.href = checkout.checkout_url;
|
|
||||||
} else {
|
|
||||||
toast.error(t('events.errors.checkoutMissing', 'Checkout could not be started.'));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on checkout failed.')));
|
|
||||||
} finally {
|
|
||||||
setConsentBusy(false);
|
|
||||||
setConsentOpen(false);
|
|
||||||
setConsentTarget(null);
|
|
||||||
setBusyScope(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scope = resolveScope(scopeOrKey);
|
||||||
|
const query = new URLSearchParams({ scope });
|
||||||
|
|
||||||
|
if (scopeOrKey !== 'photos' && scopeOrKey !== 'gallery' && scopeOrKey !== 'guests' && scopeOrKey !== 'ai') {
|
||||||
|
query.set('addon', scopeOrKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(adminPath(`/mobile/events/${slug}/addons?${query.toString()}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleApprove(photo: TenantPhoto) {
|
async function handleApprove(photo: TenantPhoto) {
|
||||||
@@ -1867,9 +1812,7 @@ export default function MobileEventControlRoomPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={aiStylingAddonCta}
|
label={aiStylingAddonCta}
|
||||||
onPress={() => startAddonCheckout('ai')}
|
onPress={() => openAddonShop('ai')}
|
||||||
loading={busyScope === 'ai'}
|
|
||||||
disabled={consentBusy}
|
|
||||||
/>
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null
|
) : null
|
||||||
@@ -1879,8 +1822,8 @@ export default function MobileEventControlRoomPage() {
|
|||||||
<LimitWarnings
|
<LimitWarnings
|
||||||
limits={limits}
|
limits={limits}
|
||||||
addons={catalogAddons}
|
addons={catalogAddons}
|
||||||
onCheckout={startAddonCheckout}
|
onCheckout={openAddonShop}
|
||||||
busyScope={busyScope}
|
busyScope={null}
|
||||||
translate={translateLimits(t as any)}
|
translate={translateLimits(t as any)}
|
||||||
textColor={text}
|
textColor={text}
|
||||||
borderColor={border}
|
borderColor={border}
|
||||||
@@ -2165,17 +2108,6 @@ export default function MobileEventControlRoomPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LegalConsentSheet
|
|
||||||
open={consentOpen}
|
|
||||||
onClose={() => {
|
|
||||||
if (consentBusy) return;
|
|
||||||
setConsentOpen(false);
|
|
||||||
setConsentTarget(null);
|
|
||||||
}}
|
|
||||||
onConfirm={confirmAddonCheckout}
|
|
||||||
busy={consentBusy}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import React from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Share2, Sparkles, Trophy, Users, TrendingUp, Heart, Image as ImageIcon } from 'lucide-react';
|
import { Share2, Sparkles, Trophy, Users, TrendingUp, Heart, Image as ImageIcon } from 'lucide-react';
|
||||||
|
import { isPast, isSameDay, parseISO } from 'date-fns';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Switch } from '@tamagui/switch';
|
import { Switch } from '@tamagui/switch';
|
||||||
import { Tabs } from 'tamagui';
|
import { Tabs } from 'tamagui';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { MobileShell } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
|
||||||
import { DataExportsPanel } from './DataExportsPage';
|
import { DataExportsPanel } from './DataExportsPage';
|
||||||
import {
|
import {
|
||||||
getEvent,
|
getEvent,
|
||||||
@@ -20,14 +21,10 @@ import {
|
|||||||
EventStats,
|
EventStats,
|
||||||
EventQrInvite,
|
EventQrInvite,
|
||||||
EventEngagement,
|
EventEngagement,
|
||||||
EventAddonCatalogItem,
|
|
||||||
getAddonCatalog,
|
|
||||||
createEventAddonCheckout,
|
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import { getApiErrorMessage, isApiError } from '../lib/apiError';
|
import { getApiErrorMessage, isApiError } from '../lib/apiError';
|
||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
|
|
||||||
@@ -37,6 +34,25 @@ type GalleryCounts = {
|
|||||||
pending: number;
|
pending: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function canAccessRecap(event: TenantEvent): boolean {
|
||||||
|
if (event.status === 'archived') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.event_date) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventDate = parseISO(event.event_date);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
if (isSameDay(today, eventDate)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isPast(eventDate);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MobileEventRecapPage() {
|
export default function MobileEventRecapPage() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -48,30 +64,25 @@ export default function MobileEventRecapPage() {
|
|||||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||||
const [stats, setEventStats] = React.useState<EventStats | null>(null);
|
const [stats, setEventStats] = React.useState<EventStats | null>(null);
|
||||||
const [invites, setInvites] = React.useState<EventQrInvite[]>([]);
|
const [invites, setInvites] = React.useState<EventQrInvite[]>([]);
|
||||||
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
|
||||||
const [engagement, setEngagement] = React.useState<EventEngagement | null>(null);
|
const [engagement, setEngagement] = React.useState<EventEngagement | null>(null);
|
||||||
const [engagementLoading, setEngagementLoading] = React.useState(false);
|
const [engagementLoading, setEngagementLoading] = React.useState(false);
|
||||||
const [engagementError, setEngagementError] = React.useState<string | null>(null);
|
const [engagementError, setEngagementError] = React.useState<string | null>(null);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [consentOpen, setConsentOpen] = React.useState(false);
|
|
||||||
const [busyScope, setBusyScope] = React.useState<string | null>(null);
|
|
||||||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||||||
|
|
||||||
const load = React.useCallback(async () => {
|
const load = React.useCallback(async () => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [eventData, statsData, invitesData, addonsData] = await Promise.all([
|
const [eventData, statsData, invitesData] = await Promise.all([
|
||||||
getEvent(slug),
|
getEvent(slug),
|
||||||
getEventStats(slug),
|
getEventStats(slug),
|
||||||
getEventQrInvites(slug),
|
getEventQrInvites(slug),
|
||||||
getAddonCatalog(),
|
|
||||||
]);
|
]);
|
||||||
setEvent(eventData);
|
setEvent(eventData);
|
||||||
setEventStats(statsData);
|
setEventStats(statsData);
|
||||||
setInvites(invitesData);
|
setInvites(invitesData);
|
||||||
setAddons(addonsData);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
@@ -130,39 +141,13 @@ export default function MobileEventRecapPage() {
|
|||||||
};
|
};
|
||||||
}, [event?.status, i18n.language, invites, t]);
|
}, [event?.status, i18n.language, invites, t]);
|
||||||
|
|
||||||
const handleCheckout = (addonKey: string) => {
|
const recapAvailable = event ? canAccessRecap(event) : true;
|
||||||
if (!slug || busyScope) {
|
|
||||||
return;
|
React.useEffect(() => {
|
||||||
|
if (event && !recapAvailable) {
|
||||||
|
navigate(adminPath(`/mobile/events/${event.slug}`), { replace: true });
|
||||||
}
|
}
|
||||||
|
}, [event, navigate, recapAvailable]);
|
||||||
setBusyScope(addonKey);
|
|
||||||
setConsentOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConsentConfirm = async (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => {
|
|
||||||
if (!slug || !busyScope) return;
|
|
||||||
try {
|
|
||||||
const { checkout_url } = await createEventAddonCheckout(slug, {
|
|
||||||
addon_key: busyScope,
|
|
||||||
success_url: window.location.href,
|
|
||||||
cancel_url: window.location.href,
|
|
||||||
accepted_terms: consents.acceptedTerms,
|
|
||||||
accepted_waiver: consents.acceptedWaiver,
|
|
||||||
});
|
|
||||||
if (checkout_url) {
|
|
||||||
window.location.href = checkout_url;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
|
|
||||||
setBusyScope(null);
|
|
||||||
setConsentOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.')));
|
|
||||||
setBusyScope(null);
|
|
||||||
setConsentOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -197,9 +182,10 @@ export default function MobileEventRecapPage() {
|
|||||||
|
|
||||||
const activeInvite = invites.find((i) => i.is_active) ?? invites[0] ?? null;
|
const activeInvite = invites.find((i) => i.is_active) ?? invites[0] ?? null;
|
||||||
const guestLink = activeInvite?.url ?? '';
|
const guestLink = activeInvite?.url ?? '';
|
||||||
const galleryExtensionAddons = addons
|
|
||||||
.filter((addon) => addon.key.startsWith('extend_gallery_') || Number(addon.increments?.extra_gallery_days ?? 0) > 0)
|
if (!recapAvailable) {
|
||||||
.sort((left, right) => Number(left.increments?.extra_gallery_days ?? 0) - Number(right.increments?.extra_gallery_days ?? 0));
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
@@ -389,28 +375,17 @@ export default function MobileEventRecapPage() {
|
|||||||
<XStack alignItems="center" gap="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
<Sparkles size={18} color={primary} />
|
<Sparkles size={18} color={primary} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.recap.addons', 'Galerie verlängern')}
|
{t('events.recap.addons', 'Manage add-ons')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
<Text fontSize="$sm" color={text}>
|
<Text fontSize="$sm" color={text}>
|
||||||
{t('events.recap.addonBody', 'Die Online-Zeit deiner Galerie neigt sich dem Ende? Hier kannst du sie verlängern.')}
|
{t('events.recap.addonBody', 'Need more runtime or capacity? Manage all event add-ons in one place.')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<YStack gap="$2">
|
<CTAButton
|
||||||
{galleryExtensionAddons.map((addon) => (
|
label={t('events.recap.openAddons', 'Open add-on manager')}
|
||||||
<CTAButton
|
onPress={() => navigate(adminPath(`/mobile/events/${event.slug}/addons?scope=gallery`))}
|
||||||
key={addon.key}
|
/>
|
||||||
label={addon.label || t('events.recap.buyExtension', 'Galerie um 30 Tage verlängern')}
|
|
||||||
onPress={() => handleCheckout(addon.key)}
|
|
||||||
loading={busyScope === addon.key}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{galleryExtensionAddons.length === 0 ? (
|
|
||||||
<Text fontSize="$sm" color={muted}>
|
|
||||||
{t('events.recap.noAddonAvailable', 'Aktuell sind keine Galerie-Add-ons verfügbar.')}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</YStack>
|
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</YStack>
|
</YStack>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
@@ -611,16 +586,6 @@ export default function MobileEventRecapPage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<LegalConsentSheet
|
|
||||||
open={consentOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setConsentOpen(false);
|
|
||||||
setBusyScope(null);
|
|
||||||
}}
|
|
||||||
onConfirm={handleConsentConfirm}
|
|
||||||
busy={Boolean(busyScope)}
|
|
||||||
t={t as any}
|
|
||||||
/>
|
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -515,6 +515,11 @@ describe('MobileBillingPage', () => {
|
|||||||
|
|
||||||
render(<MobileBillingPage />);
|
render(<MobileBillingPage />);
|
||||||
|
|
||||||
expect(await screen.findByText('Buy add-ons for this event')).toBeInTheDocument();
|
const buyAddonsButton = await screen.findByText('Buy add-ons for this event');
|
||||||
|
expect(buyAddonsButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(buyAddonsButton);
|
||||||
|
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith('/mobile/events/fruehlingsfest/addons');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
195
resources/js/admin/mobile/__tests__/EventAddonsPage.test.tsx
Normal file
195
resources/js/admin/mobile/__tests__/EventAddonsPage.test.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
const navigateMock = vi.fn();
|
||||||
|
|
||||||
|
const fixtures = vi.hoisted(() => ({
|
||||||
|
event: {
|
||||||
|
id: 42,
|
||||||
|
name: 'Demo Event',
|
||||||
|
slug: 'demo-event',
|
||||||
|
status: 'published',
|
||||||
|
event_date: '2026-02-01T12:00:00Z',
|
||||||
|
settings: {},
|
||||||
|
package: {
|
||||||
|
id: 10,
|
||||||
|
name: 'Premium',
|
||||||
|
expires_at: '2026-03-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
limits: null,
|
||||||
|
},
|
||||||
|
catalog: [
|
||||||
|
{
|
||||||
|
key: 'extend_gallery_30d',
|
||||||
|
label: 'Extend gallery 30 days',
|
||||||
|
price_id: 'paypal',
|
||||||
|
increments: { extra_gallery_days: 30 },
|
||||||
|
price: 4,
|
||||||
|
currency: 'EUR',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'extra_photos_500',
|
||||||
|
label: 'Extra photos 500',
|
||||||
|
price_id: 'paypal',
|
||||||
|
increments: { extra_photos: 500 },
|
||||||
|
price: 5,
|
||||||
|
currency: 'EUR',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: 501,
|
||||||
|
addon_key: 'extend_gallery_30d',
|
||||||
|
label: 'Extend gallery 30 days',
|
||||||
|
amount: 4,
|
||||||
|
currency: 'EUR',
|
||||||
|
status: 'completed',
|
||||||
|
purchased_at: '2026-01-22T10:00:00Z',
|
||||||
|
extra_photos: 0,
|
||||||
|
extra_guests: 0,
|
||||||
|
extra_gallery_days: 30,
|
||||||
|
quantity: 1,
|
||||||
|
event: { id: 42, slug: 'demo-event', name: 'Demo Event' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-router-dom', () => ({
|
||||||
|
useNavigate: () => navigateMock,
|
||||||
|
useParams: () => ({ slug: fixtures.event.slug }),
|
||||||
|
useLocation: () => ({ pathname: '/event-admin/mobile/events/demo-event/addons', search: '' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, fallback?: string | Record<string, unknown>, options?: Record<string, unknown>) => {
|
||||||
|
let value = typeof fallback === 'string' ? fallback : key;
|
||||||
|
if (options) {
|
||||||
|
Object.entries(options).forEach(([optionKey, optionValue]) => {
|
||||||
|
value = value.replaceAll(`{{${optionKey}}}`, String(optionValue));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
initReactI18next: {
|
||||||
|
type: '3rdParty',
|
||||||
|
init: () => undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../hooks/useBackNavigation', () => ({
|
||||||
|
useBackNavigation: () => undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../lib/apiError', () => ({
|
||||||
|
getApiErrorMessage: (_err: unknown, fallback: string) => fallback,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../theme', () => ({
|
||||||
|
useAdminTheme: () => ({
|
||||||
|
text: '#111827',
|
||||||
|
muted: '#6b7280',
|
||||||
|
subtle: '#94a3b8',
|
||||||
|
border: '#e5e7eb',
|
||||||
|
primary: '#2563eb',
|
||||||
|
successText: '#16a34a',
|
||||||
|
danger: '#dc2626',
|
||||||
|
surface: '#ffffff',
|
||||||
|
surfaceMuted: '#f9fafb',
|
||||||
|
backdrop: '#111827',
|
||||||
|
accentSoft: '#eef2ff',
|
||||||
|
textStrong: '#0f172a',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/MobileShell', () => ({
|
||||||
|
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Primitives', () => ({
|
||||||
|
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
|
||||||
|
<button type="button" onClick={onPress}>{label}</button>
|
||||||
|
),
|
||||||
|
PillBadge: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||||
|
SkeletonCard: () => <div>Loading...</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/LegalConsentSheet', () => ({
|
||||||
|
LegalConsentSheet: ({
|
||||||
|
open,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => void;
|
||||||
|
}) => (open ? <button type="button" onClick={() => onConfirm({ acceptedTerms: true, acceptedWaiver: true })}>Confirm legal</button> : null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/stacks', () => ({
|
||||||
|
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/text', () => ({
|
||||||
|
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-hot-toast', () => ({
|
||||||
|
default: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../api', () => ({
|
||||||
|
getEvent: vi.fn().mockResolvedValue(fixtures.event),
|
||||||
|
getAddonCatalog: vi.fn().mockResolvedValue(fixtures.catalog),
|
||||||
|
getTenantAddonHistory: vi.fn().mockResolvedValue({ data: fixtures.history }),
|
||||||
|
createEventAddonCheckout: vi.fn().mockResolvedValue({
|
||||||
|
checkout_url: null,
|
||||||
|
checkout_id: 'chk_123',
|
||||||
|
expires_at: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import MobileEventAddonsPage from '../EventAddonsPage';
|
||||||
|
import { createEventAddonCheckout } from '../../api';
|
||||||
|
|
||||||
|
describe('MobileEventAddonsPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders add-ons and event-specific purchase history', async () => {
|
||||||
|
render(<MobileEventAddonsPage />);
|
||||||
|
|
||||||
|
expect((await screen.findAllByText('Extend gallery 30 days')).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('Extra photos 500')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Purchased add-ons for this event')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires legal consent and sends checkout payload', async () => {
|
||||||
|
const checkoutMock = vi.mocked(createEventAddonCheckout);
|
||||||
|
|
||||||
|
render(<MobileEventAddonsPage />);
|
||||||
|
|
||||||
|
const buyButtons = await screen.findAllByRole('button', { name: 'Buy add-on' });
|
||||||
|
fireEvent.click(buyButtons[0]);
|
||||||
|
|
||||||
|
expect(checkoutMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Confirm legal' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(checkoutMock).toHaveBeenCalledWith(fixtures.event.slug, {
|
||||||
|
addon_key: 'extra_photos_500',
|
||||||
|
success_url: `${window.location.origin}/event-admin/mobile/events/demo-event/addons?addon_success=1`,
|
||||||
|
cancel_url: `${window.location.origin}/event-admin/mobile/events/demo-event/addons`,
|
||||||
|
accepted_terms: true,
|
||||||
|
accepted_waiver: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,13 +2,15 @@ import React from 'react';
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
const navigateMock = vi.fn();
|
||||||
|
|
||||||
const fixtures = vi.hoisted(() => ({
|
const fixtures = vi.hoisted(() => ({
|
||||||
event: {
|
event: {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Demo Event',
|
name: 'Demo Event',
|
||||||
slug: 'demo-event',
|
slug: 'demo-event',
|
||||||
event_date: '2026-02-19',
|
event_date: '2026-02-19',
|
||||||
status: 'published',
|
status: 'archived',
|
||||||
settings: {
|
settings: {
|
||||||
guest_downloads_enabled: true,
|
guest_downloads_enabled: true,
|
||||||
guest_sharing_enabled: false,
|
guest_sharing_enabled: false,
|
||||||
@@ -34,7 +36,7 @@ const fixtures = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('react-router-dom', () => ({
|
vi.mock('react-router-dom', () => ({
|
||||||
useNavigate: () => vi.fn(),
|
useNavigate: () => navigateMock,
|
||||||
useParams: () => ({ slug: fixtures.event.slug }),
|
useParams: () => ({ slug: fixtures.event.slug }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -65,13 +67,7 @@ vi.mock('../../api', () => ({
|
|||||||
getEvent: vi.fn().mockResolvedValue(fixtures.event),
|
getEvent: vi.fn().mockResolvedValue(fixtures.event),
|
||||||
getEventStats: vi.fn().mockResolvedValue(fixtures.stats),
|
getEventStats: vi.fn().mockResolvedValue(fixtures.stats),
|
||||||
getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
|
getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
|
||||||
getAddonCatalog: vi.fn().mockResolvedValue(fixtures.addons),
|
|
||||||
updateEvent: vi.fn().mockResolvedValue(fixtures.event),
|
updateEvent: vi.fn().mockResolvedValue(fixtures.event),
|
||||||
createEventAddonCheckout: vi.fn().mockResolvedValue({
|
|
||||||
checkout_url: null,
|
|
||||||
checkout_id: 'chk_123',
|
|
||||||
expires_at: null,
|
|
||||||
}),
|
|
||||||
getEventEngagement: vi.fn().mockResolvedValue({
|
getEventEngagement: vi.fn().mockResolvedValue({
|
||||||
summary: { totalPhotos: 0, uniqueGuests: 0, tasksSolved: 0, likesTotal: 0 },
|
summary: { totalPhotos: 0, uniqueGuests: 0, tasksSolved: 0, likesTotal: 0 },
|
||||||
leaderboards: { uploads: [], likes: [] },
|
leaderboards: { uploads: [], likes: [] },
|
||||||
@@ -185,10 +181,11 @@ vi.mock('../theme', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import MobileEventRecapPage from '../EventRecapPage';
|
import MobileEventRecapPage from '../EventRecapPage';
|
||||||
import { createEventAddonCheckout } from '../../api';
|
|
||||||
|
|
||||||
describe('MobileEventRecapPage', () => {
|
describe('MobileEventRecapPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
fixtures.event.status = 'archived';
|
||||||
|
fixtures.event.event_date = '2026-02-19';
|
||||||
fixtures.addons = [
|
fixtures.addons = [
|
||||||
{
|
{
|
||||||
key: 'extend_gallery_30d',
|
key: 'extend_gallery_30d',
|
||||||
@@ -208,25 +205,25 @@ describe('MobileEventRecapPage', () => {
|
|||||||
expect(screen.getByLabelText('Gäste dürfen Fotos teilen')).toBeInTheDocument();
|
expect(screen.getByLabelText('Gäste dürfen Fotos teilen')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires consent and sends both legal acceptance flags for recap addon checkout', async () => {
|
it('links to the dedicated add-on manager from recap', async () => {
|
||||||
const checkoutMock = vi.mocked(createEventAddonCheckout);
|
|
||||||
render(<MobileEventRecapPage />);
|
render(<MobileEventRecapPage />);
|
||||||
|
|
||||||
const checkoutButton = await screen.findByRole('button', { name: 'Galerie um 30 Tage verlängern' });
|
const openAddonsButton = await screen.findByRole('button', { name: 'Open add-on manager' });
|
||||||
fireEvent.click(checkoutButton);
|
fireEvent.click(openAddonsButton);
|
||||||
|
|
||||||
expect(checkoutMock).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: 'Confirm legal' }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(checkoutMock).toHaveBeenCalledWith(fixtures.event.slug, {
|
expect(navigateMock).toHaveBeenCalledWith('/event-admin/mobile/events/demo-event/addons?scope=gallery');
|
||||||
addon_key: 'extend_gallery_30d',
|
});
|
||||||
success_url: window.location.href,
|
});
|
||||||
cancel_url: window.location.href,
|
|
||||||
accepted_terms: true,
|
it('redirects to the event overview when recap is not available yet', async () => {
|
||||||
accepted_waiver: true,
|
fixtures.event.status = 'published';
|
||||||
});
|
fixtures.event.event_date = '2099-02-19';
|
||||||
|
|
||||||
|
render(<MobileEventRecapPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith('/event-admin/mobile/events/demo-event', { replace: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const MobileEventLiveShowSettingsPage = React.lazy(() => import('./mobile/EventL
|
|||||||
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
|
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
|
||||||
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
|
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
|
||||||
const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage'));
|
const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage'));
|
||||||
|
const MobileEventAddonsPage = React.lazy(() => import('./mobile/EventAddonsPage'));
|
||||||
const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalyticsPage'));
|
const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalyticsPage'));
|
||||||
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
|
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
|
||||||
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
|
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
|
||||||
@@ -199,6 +200,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'events/:slug/photos', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> },
|
{ path: 'events/:slug/photos', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> },
|
||||||
{ path: 'events/:slug/members', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/members`} /> },
|
{ path: 'events/:slug/members', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/members`} /> },
|
||||||
{ path: 'events/:slug/tasks', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/tasks`} /> },
|
{ path: 'events/:slug/tasks', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/tasks`} /> },
|
||||||
|
{ path: 'events/:slug/addons', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/addons`} /> },
|
||||||
{ path: 'events/:slug/invites', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/qr`} /> },
|
{ path: 'events/:slug/invites', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/qr`} /> },
|
||||||
{ path: 'events/:slug/branding', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/branding`} /> },
|
{ path: 'events/:slug/branding', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/branding`} /> },
|
||||||
{ path: 'events/:slug/photobooth', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/photobooth`} /> },
|
{ path: 'events/:slug/photobooth', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/photobooth`} /> },
|
||||||
@@ -216,6 +218,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'mobile/events/:slug/live-show', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> },
|
{ path: 'mobile/events/:slug/live-show', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> },
|
||||||
{ path: 'mobile/events/:slug/live-show/settings', element: <RequireAdminAccess><MobileEventLiveShowSettingsPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/live-show/settings', element: <RequireAdminAccess><MobileEventLiveShowSettingsPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
|
||||||
|
{ path: 'mobile/events/:slug/addons', element: <RequireAdminAccess><MobileEventAddonsPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/:slug/analytics', element: <RequireAdminAccess><MobileEventAnalyticsPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/analytics', element: <RequireAdminAccess><MobileEventAnalyticsPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
|
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user