feat: implement AI styling foundation and billing scope rework
This commit is contained in:
@@ -13,8 +13,12 @@ import {
|
||||
getTenantBillingTransactions,
|
||||
getTenantPackagesOverview,
|
||||
getTenantPackageCheckoutStatus,
|
||||
getEvent,
|
||||
TenantPackageSummary,
|
||||
TenantEvent,
|
||||
TenantBillingTransactionSummary,
|
||||
EventAddonSummary,
|
||||
PaginationMeta,
|
||||
downloadTenantBillingReceipt,
|
||||
} from '../api';
|
||||
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
|
||||
@@ -43,18 +47,45 @@ import {
|
||||
storePendingCheckout,
|
||||
} from './lib/billingCheckout';
|
||||
import { triggerDownloadFromBlob } from './invite-layout/export-utils';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
|
||||
const CHECKOUT_POLL_INTERVAL_MS = 10000;
|
||||
const BILLING_HISTORY_PAGE_SIZE = 8;
|
||||
const CURRENT_EVENT_ADDONS_PAGE_SIZE = 6;
|
||||
|
||||
function createInitialPaginationMeta(perPage: number): PaginationMeta {
|
||||
return {
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
per_page: perPage,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function MobileBillingPage() {
|
||||
const { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { activeEvent } = useEventContext();
|
||||
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
|
||||
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
||||
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
||||
const [transactions, setTransactions] = React.useState<TenantBillingTransactionSummary[]>([]);
|
||||
const [transactionsMeta, setTransactionsMeta] = React.useState<PaginationMeta>(() =>
|
||||
createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE)
|
||||
);
|
||||
const [transactionsLoadingMore, setTransactionsLoadingMore] = React.useState(false);
|
||||
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
|
||||
const [addonsMeta, setAddonsMeta] = React.useState<PaginationMeta>(() =>
|
||||
createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE)
|
||||
);
|
||||
const [addonsLoadingMore, setAddonsLoadingMore] = React.useState(false);
|
||||
const [scopeAddons, setScopeAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
|
||||
const [scopeAddonsMeta, setScopeAddonsMeta] = React.useState<PaginationMeta>(() =>
|
||||
createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE)
|
||||
);
|
||||
const [scopeAddonsLoadingMore, setScopeAddonsLoadingMore] = React.useState(false);
|
||||
const [scopeEvent, setScopeEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [showPackageHistory, setShowPackageHistory] = React.useState(false);
|
||||
@@ -63,6 +94,7 @@ export default function MobileBillingPage() {
|
||||
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
|
||||
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
|
||||
const lastCheckoutStatusRef = React.useRef<string | null>(null);
|
||||
const mismatchTrackingRef = React.useRef<string | null>(null);
|
||||
const packagesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const supportEmail = 'support@fotospiel.de';
|
||||
@@ -79,13 +111,51 @@ export default function MobileBillingPage() {
|
||||
try {
|
||||
const [pkg, trx, addonHistory] = await Promise.all([
|
||||
getTenantPackagesOverview({ force: true }),
|
||||
getTenantBillingTransactions().catch(() => ({ data: [] as TenantBillingTransactionSummary[] })),
|
||||
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
|
||||
getTenantBillingTransactions(1, BILLING_HISTORY_PAGE_SIZE).catch(() => ({
|
||||
data: [] as TenantBillingTransactionSummary[],
|
||||
meta: createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE),
|
||||
})),
|
||||
getTenantAddonHistory({ page: 1, perPage: BILLING_HISTORY_PAGE_SIZE }).catch(() => ({
|
||||
data: [] as TenantAddonHistoryEntry[],
|
||||
meta: createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE),
|
||||
})),
|
||||
]);
|
||||
|
||||
let scopedEvent: TenantEvent | null = null;
|
||||
let scopedAddons: TenantAddonHistoryEntry[] = [];
|
||||
const scopeSlug = activeEvent?.slug ?? null;
|
||||
const scopeEventIdFallback = activeEvent?.id ?? null;
|
||||
|
||||
if (scopeSlug) {
|
||||
scopedEvent = await getEvent(scopeSlug).catch(() => activeEvent ?? null);
|
||||
const scopedEventId = scopedEvent?.id ?? scopeEventIdFallback;
|
||||
|
||||
if (scopedEventId) {
|
||||
const scopedAddonHistory = await getTenantAddonHistory({
|
||||
eventId: scopedEventId,
|
||||
perPage: CURRENT_EVENT_ADDONS_PAGE_SIZE,
|
||||
page: 1,
|
||||
}).catch(() => ({
|
||||
data: [] as TenantAddonHistoryEntry[],
|
||||
meta: createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE),
|
||||
}));
|
||||
scopedAddons = scopedAddonHistory.data ?? [];
|
||||
setScopeAddonsMeta(scopedAddonHistory.meta ?? createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE));
|
||||
} else {
|
||||
setScopeAddonsMeta(createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE));
|
||||
}
|
||||
} else {
|
||||
setScopeAddonsMeta(createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE));
|
||||
}
|
||||
|
||||
setPackages(pkg.packages ?? []);
|
||||
setActivePackage(pkg.activePackage ?? null);
|
||||
setTransactions(trx.data ?? []);
|
||||
setTransactionsMeta(trx.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE));
|
||||
setAddons(addonHistory.data ?? []);
|
||||
setAddonsMeta(addonHistory.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE));
|
||||
setScopeEvent(scopedEvent);
|
||||
setScopeAddons(scopedAddons);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message = getApiErrorMessage(err, t('billing.errors.load', 'Konnte Abrechnung nicht laden.'));
|
||||
@@ -93,7 +163,7 @@ export default function MobileBillingPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, [activeEvent, t]);
|
||||
|
||||
const scrollToPackages = React.useCallback(() => {
|
||||
packagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
@@ -116,6 +186,49 @@ export default function MobileBillingPage() {
|
||||
);
|
||||
const hiddenPackageCount = Math.max(0, nonActivePackages.length - 3);
|
||||
const visiblePackageHistory = showPackageHistory ? nonActivePackages : nonActivePackages.slice(0, 3);
|
||||
const scopedEventName = React.useMemo(() => {
|
||||
if (!scopeEvent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolveLinkedEventName(scopeEvent.name, t);
|
||||
}, [scopeEvent, t]);
|
||||
const scopedEventPath = scopeEvent?.slug ? ADMIN_EVENT_VIEW_PATH(scopeEvent.slug) : null;
|
||||
const activeEventId = scopeEvent?.id ?? activeEvent?.id ?? null;
|
||||
const scopedEventPackage = scopeEvent?.package ?? null;
|
||||
const scopedEventAddons = React.useMemo<EventAddonSummary[]>(() => {
|
||||
const rows = scopeEvent?.addons;
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
}, [scopeEvent?.addons]);
|
||||
const scopeMismatchCount = React.useMemo(() => {
|
||||
if (!activeEventId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return addons.filter((addon) => addon.event?.id && Number(addon.event.id) !== Number(activeEventId)).length;
|
||||
}, [activeEventId, addons]);
|
||||
|
||||
const trackBillingInteraction = React.useCallback(
|
||||
(action: string, value?: number) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybePaq = (window as unknown as { _paq?: unknown[] })._paq;
|
||||
if (!Array.isArray(maybePaq)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = activeEventId ? `event:${activeEventId}` : 'event:none';
|
||||
const payload: (string | number)[] = ['trackEvent', 'Admin Billing', action, label];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
payload.push(value);
|
||||
}
|
||||
|
||||
maybePaq.push(payload);
|
||||
},
|
||||
[activeEventId],
|
||||
);
|
||||
|
||||
const handleReceiptDownload = React.useCallback(
|
||||
async (transaction: TenantBillingTransactionSummary) => {
|
||||
@@ -138,10 +251,100 @@ export default function MobileBillingPage() {
|
||||
[t],
|
||||
);
|
||||
|
||||
const loadMoreTransactions = React.useCallback(async () => {
|
||||
if (transactionsLoadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (transactionsMeta.current_page >= transactionsMeta.last_page) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTransactionsLoadingMore(true);
|
||||
try {
|
||||
const nextPage = transactionsMeta.current_page + 1;
|
||||
const result = await getTenantBillingTransactions(nextPage, BILLING_HISTORY_PAGE_SIZE);
|
||||
setTransactions((current) => [...current, ...(result.data ?? [])]);
|
||||
setTransactionsMeta(result.meta ?? transactionsMeta);
|
||||
trackBillingInteraction('transactions_load_more', nextPage);
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('billing.errors.more', 'Konnte weitere Einträge nicht laden.')));
|
||||
} finally {
|
||||
setTransactionsLoadingMore(false);
|
||||
}
|
||||
}, [trackBillingInteraction, transactionsLoadingMore, transactionsMeta, t]);
|
||||
|
||||
const loadMoreAddonHistory = React.useCallback(async () => {
|
||||
if (addonsLoadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (addonsMeta.current_page >= addonsMeta.last_page) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAddonsLoadingMore(true);
|
||||
try {
|
||||
const nextPage = addonsMeta.current_page + 1;
|
||||
const result = await getTenantAddonHistory({
|
||||
page: nextPage,
|
||||
perPage: BILLING_HISTORY_PAGE_SIZE,
|
||||
});
|
||||
setAddons((current) => [...current, ...(result.data ?? [])]);
|
||||
setAddonsMeta(result.meta ?? addonsMeta);
|
||||
trackBillingInteraction('addon_history_load_more', nextPage);
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('billing.errors.more', 'Konnte weitere Einträge nicht laden.')));
|
||||
} finally {
|
||||
setAddonsLoadingMore(false);
|
||||
}
|
||||
}, [addonsLoadingMore, addonsMeta, t, trackBillingInteraction]);
|
||||
|
||||
const loadMoreScopeAddons = React.useCallback(async () => {
|
||||
if (scopeAddonsLoadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeEventId || scopeAddonsMeta.current_page >= scopeAddonsMeta.last_page) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScopeAddonsLoadingMore(true);
|
||||
try {
|
||||
const nextPage = scopeAddonsMeta.current_page + 1;
|
||||
const result = await getTenantAddonHistory({
|
||||
eventId: activeEventId,
|
||||
page: nextPage,
|
||||
perPage: CURRENT_EVENT_ADDONS_PAGE_SIZE,
|
||||
});
|
||||
setScopeAddons((current) => [...current, ...(result.data ?? [])]);
|
||||
setScopeAddonsMeta(result.meta ?? scopeAddonsMeta);
|
||||
trackBillingInteraction('scope_addons_load_more', nextPage);
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('billing.errors.more', 'Konnte weitere Einträge nicht laden.')));
|
||||
} finally {
|
||||
setScopeAddonsLoadingMore(false);
|
||||
}
|
||||
}, [activeEventId, scopeAddonsLoadingMore, scopeAddonsMeta, t, trackBillingInteraction]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeEventId || scopeMismatchCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${activeEventId}:${scopeMismatchCount}`;
|
||||
if (mismatchTrackingRef.current === key) {
|
||||
return;
|
||||
}
|
||||
|
||||
mismatchTrackingRef.current = key;
|
||||
trackBillingInteraction('scope_mismatch_visible', scopeMismatchCount);
|
||||
}, [activeEventId, scopeMismatchCount, trackBillingInteraction]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!location.hash) return;
|
||||
const hash = location.hash.replace('#', '');
|
||||
@@ -387,15 +590,122 @@ export default function MobileBillingPage() {
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard gap="$2">
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Package size={18} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('billing.sections.currentEvent.title', 'Current event')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(
|
||||
'billing.sections.currentEvent.hint',
|
||||
'This section shows what is active for your currently selected event.'
|
||||
)}
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('common.loading', 'Lädt...')}
|
||||
</Text>
|
||||
) : !scopeEvent ? (
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{t('billing.sections.currentEvent.empty', 'Select an event to view event-specific packages and add-ons.')}
|
||||
</Text>
|
||||
) : (
|
||||
<YStack gap="$2">
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.currentEvent.eventLabel', 'Selected event')}
|
||||
</Text>
|
||||
{scopedEventName ? (
|
||||
scopedEventPath ? (
|
||||
<Pressable onPress={() => navigate(scopedEventPath)}>
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{scopedEventName}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||
{scopedEventName}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</YStack>
|
||||
{scopedEventPackage ? (
|
||||
<MobileCard borderColor={border} padding="$3" gap="$1.5">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{scopedEventPackage.name ?? t('mobileBilling.packageFallback', 'Package')}
|
||||
</Text>
|
||||
<PillBadge tone="success">
|
||||
{t('billing.sections.currentEvent.packageActive', 'Active for this event')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
{scopedEventPackage.expires_at ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.currentEvent.packageExpires', 'Gallery active until {{date}}', {
|
||||
date: formatDate(scopedEventPackage.expires_at),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
) : (
|
||||
<MobileCard borderColor={border} padding="$3" gap="$1">
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{t('billing.sections.currentEvent.noPackage', 'No package is currently assigned to this event.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
)}
|
||||
|
||||
<YStack gap="$1.5">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.currentEvent.addonsLabel', 'Add-ons for this event')}
|
||||
</Text>
|
||||
{scopeAddons.length > 0 ? (
|
||||
<YStack gap="$1.5">
|
||||
{scopeAddons.map((addon) => (
|
||||
<AddonRow key={`scope-${addon.id}`} addon={addon} hideEventLink />
|
||||
))}
|
||||
{scopeAddonsMeta.current_page < scopeAddonsMeta.last_page ? (
|
||||
<CTAButton
|
||||
label={
|
||||
scopeAddonsLoadingMore
|
||||
? t('billing.addOns.loadingMore', 'Add-ons werden geladen…')
|
||||
: t('billing.addOns.loadMore', 'Weitere Add-ons laden')
|
||||
}
|
||||
onPress={() => void loadMoreScopeAddons()}
|
||||
tone="ghost"
|
||||
/>
|
||||
) : null}
|
||||
</YStack>
|
||||
) : scopedEventAddons.length > 0 ? (
|
||||
<YStack gap="$1.5">
|
||||
{scopedEventAddons.slice(0, 6).map((addon) => (
|
||||
<EventAddonRow key={`event-addon-${addon.id}`} addon={addon} />
|
||||
))}
|
||||
</YStack>
|
||||
) : (
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{t('billing.sections.currentEvent.noAddons', 'No add-ons purchased for this event.')}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)}
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard gap="$2" ref={packagesRef as any}>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Package size={18} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('billing.sections.packages.title', 'Packages')}
|
||||
{t('billing.sections.packages.title', 'Package history (all events)')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
|
||||
{t(
|
||||
'billing.sections.packages.hint',
|
||||
'All purchased packages across all events.'
|
||||
)}
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
@@ -406,7 +716,7 @@ export default function MobileBillingPage() {
|
||||
{activePackage ? (
|
||||
<PackageCard
|
||||
pkg={activePackage}
|
||||
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
|
||||
label={t('billing.sections.packages.card.statusActiveTenant', 'Currently active')}
|
||||
isActive
|
||||
onOpenShop={() => navigate(shopLink)}
|
||||
/>
|
||||
@@ -470,7 +780,7 @@ export default function MobileBillingPage() {
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack gap="$1.5">
|
||||
{transactions.slice(0, 8).map((trx) => {
|
||||
{transactions.map((trx) => {
|
||||
const statusLabel = trx.status
|
||||
? t(`billing.sections.transactions.status.${trx.status}`, trx.status)
|
||||
: '—';
|
||||
@@ -519,6 +829,17 @@ export default function MobileBillingPage() {
|
||||
</XStack>
|
||||
);
|
||||
})}
|
||||
{transactionsMeta.current_page < transactionsMeta.last_page ? (
|
||||
<CTAButton
|
||||
label={
|
||||
transactionsLoadingMore
|
||||
? t('billing.sections.transactions.loadingMore', 'Laden…')
|
||||
: t('billing.sections.transactions.loadMore', 'Weitere Transaktionen laden')
|
||||
}
|
||||
onPress={() => void loadMoreTransactions()}
|
||||
tone="ghost"
|
||||
/>
|
||||
) : null}
|
||||
</YStack>
|
||||
)}
|
||||
</MobileCard>
|
||||
@@ -527,11 +848,14 @@ export default function MobileBillingPage() {
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Sparkles size={18} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('billing.sections.addOns.title', 'Add-ons')}
|
||||
{t('billing.sections.addOns.title', 'Add-on purchase history')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')}
|
||||
{t(
|
||||
'billing.sections.addOns.hint',
|
||||
'History across all events. Entries here are historical and not automatically active for the current event.'
|
||||
)}
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
@@ -543,9 +867,20 @@ export default function MobileBillingPage() {
|
||||
</Text>
|
||||
) : (
|
||||
<YStack gap="$1.5">
|
||||
{addons.slice(0, 8).map((addon) => (
|
||||
<AddonRow key={addon.id} addon={addon} />
|
||||
{addons.map((addon) => (
|
||||
<AddonRow key={addon.id} addon={addon} currentEventId={activeEventId} />
|
||||
))}
|
||||
{addonsMeta.current_page < addonsMeta.last_page ? (
|
||||
<CTAButton
|
||||
label={
|
||||
addonsLoadingMore
|
||||
? t('billing.addOns.loadingMore', 'Add-ons werden geladen…')
|
||||
: t('billing.addOns.loadMore', 'Weitere Add-ons laden')
|
||||
}
|
||||
onPress={() => void loadMoreAddonHistory()}
|
||||
tone="ghost"
|
||||
/>
|
||||
) : null}
|
||||
</YStack>
|
||||
)}
|
||||
</MobileCard>
|
||||
@@ -846,7 +1181,15 @@ function formatAmount(value: number | null | undefined, currency: string | null
|
||||
}
|
||||
}
|
||||
|
||||
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
||||
function AddonRow({
|
||||
addon,
|
||||
hideEventLink = false,
|
||||
currentEventId = null,
|
||||
}: {
|
||||
addon: TenantAddonHistoryEntry;
|
||||
hideEventLink?: boolean;
|
||||
currentEventId?: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const { border, textStrong, text, muted, subtle, primary } = useAdminTheme();
|
||||
@@ -861,6 +1204,12 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
||||
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
|
||||
null;
|
||||
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
|
||||
const purchasedForDifferentEvent = Boolean(
|
||||
!hideEventLink &&
|
||||
currentEventId &&
|
||||
addon.event?.id &&
|
||||
Number(addon.event.id) !== Number(currentEventId)
|
||||
);
|
||||
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
|
||||
const impactBadges = hasImpact ? (
|
||||
<XStack gap="$2" marginTop="$1.5" flexWrap="wrap">
|
||||
@@ -884,7 +1233,7 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
||||
</Text>
|
||||
<PillBadge tone={status.tone}>{status.text}</PillBadge>
|
||||
</XStack>
|
||||
{eventName ? (
|
||||
{!hideEventLink && eventName ? (
|
||||
eventPath ? (
|
||||
<Pressable onPress={() => navigate(eventPath)}>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
@@ -909,9 +1258,56 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{formatDate(addon.purchased_at)}
|
||||
</Text>
|
||||
{purchasedForDifferentEvent ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.addOns.otherEventNotice', 'Purchased for another event')}
|
||||
</Text>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
|
||||
function EventAddonRow({ addon }: { addon: EventAddonSummary }) {
|
||||
const { t } = useTranslation('management');
|
||||
const { border, textStrong, text, muted } = useAdminTheme();
|
||||
|
||||
const labels: Record<EventAddonSummary['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
|
||||
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
|
||||
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
|
||||
failed: { tone: 'muted', text: t('mobileBilling.status.failed', 'Failed') },
|
||||
};
|
||||
|
||||
const status = labels[addon.status];
|
||||
|
||||
return (
|
||||
<MobileCard borderColor={border} padding="$3" gap="$1.5">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{addon.label ?? addon.key}
|
||||
</Text>
|
||||
<PillBadge tone={status.tone}>{status.text}</PillBadge>
|
||||
</XStack>
|
||||
<XStack gap="$2" marginTop="$1.5" flexWrap="wrap">
|
||||
{addon.extra_photos ? (
|
||||
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
|
||||
) : null}
|
||||
{addon.extra_guests ? (
|
||||
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
|
||||
) : null}
|
||||
{addon.extra_gallery_days ? (
|
||||
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
|
||||
) : null}
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{formatDate(addon.purchased_at)}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.currentEvent.eventAddonSource', 'Source: event package')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
|
||||
Reference in New Issue
Block a user