559 lines
18 KiB
TypeScript
559 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|