711 lines
26 KiB
TypeScript
711 lines
26 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Check, ChevronRight, ShieldCheck, Sparkles, X } from 'lucide-react';
|
|
import { YStack, XStack } from '@tamagui/stacks';
|
|
import { SizableText as Text } from '@tamagui/text';
|
|
import { Checkbox } from '@tamagui/checkbox';
|
|
|
|
import { MobileShell } from './components/MobileShell';
|
|
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
|
import { useAdminTheme } from './theme';
|
|
import { getPackages, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { adminPath } from '../constants';
|
|
import {
|
|
buildPackageComparisonRows,
|
|
classifyPackageChange,
|
|
getEnabledPackageFeatures,
|
|
selectRecommendedPackageId,
|
|
} from './lib/packageShop';
|
|
import { usePackageCheckout } from './hooks/usePackageCheckout';
|
|
|
|
export default function MobilePackageShopPage() {
|
|
const { t } = useTranslation('management');
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
|
const [selectedPackage, setSelectedPackage] = React.useState<Package | null>(null);
|
|
const [viewMode, setViewMode] = React.useState<'cards' | 'compare'>('cards');
|
|
|
|
// Extract recommended feature from URL
|
|
const searchParams = new URLSearchParams(location.search);
|
|
const recommendedFeature = searchParams.get('feature');
|
|
const forcedCatalogType = searchParams.get('type');
|
|
|
|
const { data: inventory, isLoading: loadingInventory } = useQuery({
|
|
queryKey: ['tenant-packages-overview'],
|
|
queryFn: () => getTenantPackagesOverview({ force: true }),
|
|
});
|
|
|
|
const catalogType: 'endcustomer' | 'reseller' =
|
|
forcedCatalogType === 'endcustomer' || forcedCatalogType === 'reseller'
|
|
? forcedCatalogType
|
|
: inventory?.activePackage?.package_type === 'reseller' ||
|
|
(inventory?.packages ?? []).some((entry) => entry.package_type === 'reseller')
|
|
? 'reseller'
|
|
: 'endcustomer';
|
|
|
|
const { data: catalog, isLoading: loadingCatalog } = useQuery({
|
|
queryKey: ['packages', catalogType],
|
|
queryFn: () => getPackages(catalogType),
|
|
});
|
|
|
|
const isLoading = loadingCatalog || loadingInventory;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<MobileShell
|
|
title={
|
|
catalogType === 'reseller'
|
|
? t('shop.partner.title', 'Event-Kontingent kaufen')
|
|
: t('shop.title', 'Upgrade Package')
|
|
}
|
|
onBack={() => navigate(-1)}
|
|
activeTab="profile"
|
|
>
|
|
<YStack space="$3">
|
|
<SkeletonCard height={150} />
|
|
<SkeletonCard height={150} />
|
|
</YStack>
|
|
</MobileShell>
|
|
);
|
|
}
|
|
|
|
if (selectedPackage) {
|
|
return (
|
|
<CheckoutConfirmation
|
|
pkg={selectedPackage}
|
|
onCancel={() => setSelectedPackage(null)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const activePackageId = inventory?.activePackage?.package_id ?? null;
|
|
const activeCatalogPackage = (catalog ?? []).find((pkg) => pkg.id === activePackageId) ?? null;
|
|
const recommendedPackageId =
|
|
catalogType === 'reseller'
|
|
? null
|
|
: selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage);
|
|
|
|
// Merge and sort packages
|
|
const sortedPackages = [...(catalog || [])].sort((a, b) => {
|
|
if (recommendedPackageId) {
|
|
if (a.id === recommendedPackageId && b.id !== recommendedPackageId) return -1;
|
|
if (b.id === recommendedPackageId && a.id !== recommendedPackageId) return 1;
|
|
}
|
|
|
|
return a.price - b.price;
|
|
});
|
|
|
|
const packageEntries = sortedPackages.map((pkg) => {
|
|
const ownedEntries = (inventory?.packages ?? []).filter((entry) => entry.package_id === pkg.id && entry.active);
|
|
const owned = ownedEntries.length ? aggregateOwnedEntries(ownedEntries) : undefined;
|
|
const isActive = catalogType === 'reseller' ? false : inventory?.activePackage?.package_id === pkg.id;
|
|
const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false;
|
|
const { isUpgrade, isDowngrade } =
|
|
catalogType === 'reseller'
|
|
? { isUpgrade: false, isDowngrade: false }
|
|
: classifyPackageChange(pkg, activeCatalogPackage);
|
|
|
|
return {
|
|
pkg,
|
|
owned,
|
|
isActive,
|
|
isRecommended,
|
|
isUpgrade,
|
|
isDowngrade,
|
|
};
|
|
});
|
|
|
|
return (
|
|
<MobileShell
|
|
title={catalogType === 'reseller' ? t('shop.partner.title', 'Event-Kontingent kaufen') : t('shop.title', 'Upgrade Package')}
|
|
onBack={() => navigate(-1)}
|
|
activeTab="profile"
|
|
>
|
|
<YStack space="$4">
|
|
{catalogType !== 'reseller' && recommendedFeature && (
|
|
<MobileCard borderColor={primary} backgroundColor={accentSoft} space="$2" padding="$3">
|
|
<XStack space="$2" alignItems="center">
|
|
<Sparkles size={16} color={primary} />
|
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
|
{t('shop.recommendationTitle', 'Recommended for you')}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('shop.recommendationBody', 'The highlighted package includes the feature you requested.')}
|
|
</Text>
|
|
</MobileCard>
|
|
)}
|
|
|
|
<YStack paddingHorizontal="$2">
|
|
<Text fontSize="$sm" color={muted}>
|
|
{catalogType === 'reseller'
|
|
? t('shop.partner.subtitle', 'Kaufe Event-Kontingente, um mehrere Events mit unseren Services umzusetzen.')
|
|
: t('shop.subtitle', 'Choose a package to unlock more features and limits.')}
|
|
</Text>
|
|
</YStack>
|
|
|
|
{packageEntries.length > 1 ? (
|
|
<XStack space="$2" paddingHorizontal="$2">
|
|
<CTAButton
|
|
label={t('shop.compare.toggleCards', 'Cards')}
|
|
tone={viewMode === 'cards' ? 'primary' : 'ghost'}
|
|
fullWidth={false}
|
|
onPress={() => setViewMode('cards')}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<CTAButton
|
|
label={t('shop.compare.toggleCompare', 'Compare')}
|
|
tone={viewMode === 'compare' ? 'primary' : 'ghost'}
|
|
fullWidth={false}
|
|
onPress={() => setViewMode('compare')}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
</XStack>
|
|
) : null}
|
|
|
|
<YStack space="$3">
|
|
{viewMode === 'compare' ? (
|
|
<PackageShopCompareView
|
|
entries={packageEntries}
|
|
onSelect={(pkg) => setSelectedPackage(pkg)}
|
|
onManage={() => navigate(adminPath('/mobile/billing#packages'))}
|
|
catalogType={catalogType}
|
|
/>
|
|
) : (
|
|
packageEntries.map((entry) => (
|
|
<PackageShopCard
|
|
key={entry.pkg.id}
|
|
pkg={entry.pkg}
|
|
owned={entry.owned}
|
|
isActive={entry.isActive}
|
|
isRecommended={entry.isRecommended}
|
|
isUpgrade={entry.isUpgrade}
|
|
isDowngrade={entry.isDowngrade}
|
|
catalogType={catalogType}
|
|
onSelect={() => setSelectedPackage(entry.pkg)}
|
|
onManage={() => navigate(adminPath('/mobile/billing#packages'))}
|
|
/>
|
|
))
|
|
)}
|
|
</YStack>
|
|
</YStack>
|
|
</MobileShell>
|
|
);
|
|
}
|
|
|
|
function PackageShopCard({
|
|
pkg,
|
|
owned,
|
|
isActive,
|
|
isRecommended,
|
|
isUpgrade,
|
|
isDowngrade,
|
|
catalogType,
|
|
onSelect,
|
|
onManage,
|
|
}: {
|
|
pkg: Package;
|
|
owned?: TenantPackageSummary;
|
|
isActive?: boolean;
|
|
isRecommended?: any;
|
|
isUpgrade?: boolean;
|
|
isDowngrade?: boolean;
|
|
catalogType: 'endcustomer' | 'reseller';
|
|
onSelect: () => void;
|
|
onManage?: () => void;
|
|
}) {
|
|
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
|
const { t } = useTranslation('management');
|
|
|
|
const isResellerCatalog = catalogType === 'reseller';
|
|
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
|
|
const isSubdued = Boolean(!isResellerCatalog && (isDowngrade || !isUpgrade) && !isActive);
|
|
const canSelect = isResellerCatalog ? Boolean(pkg.paddle_price_id) : canSelectPackage(isUpgrade, isActive);
|
|
const hasManageAction = Boolean(isActive && onManage);
|
|
const includedTierLabel = resolveIncludedTierLabel(t, pkg.included_package_slug ?? null);
|
|
const handlePress = isActive ? onManage : canSelect ? onSelect : undefined;
|
|
|
|
return (
|
|
<MobileCard
|
|
onPress={handlePress}
|
|
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
|
|
borderWidth={isRecommended || isActive ? 2 : 1}
|
|
space="$3"
|
|
pressStyle={handlePress ? { backgroundColor: accentSoft } : undefined}
|
|
backgroundColor={isActive ? '$green1' : undefined}
|
|
style={{ opacity: isSubdued ? 0.6 : 1 }}
|
|
>
|
|
<XStack justifyContent="space-between" alignItems="flex-start">
|
|
<YStack space="$1">
|
|
<XStack space="$2" alignItems="center">
|
|
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
|
{pkg.name}
|
|
</Text>
|
|
{isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
|
|
{!isResellerCatalog && isUpgrade && !isActive ? (
|
|
<PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
|
|
) : null}
|
|
{!isResellerCatalog && isDowngrade && !isActive ? (
|
|
<PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge>
|
|
) : null}
|
|
{!isResellerCatalog && isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
|
|
</XStack>
|
|
|
|
<XStack space="$2" alignItems="center">
|
|
<Text fontSize="$md" color={primary} fontWeight="700">
|
|
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)}
|
|
</Text>
|
|
{statusLabel && (
|
|
<Text fontSize="$xs" color={muted} fontWeight="600">
|
|
• {statusLabel}
|
|
</Text>
|
|
)}
|
|
</XStack>
|
|
</YStack>
|
|
<YStack marginTop="$2">
|
|
<ChevronRight size={20} color={muted} />
|
|
</YStack>
|
|
</XStack>
|
|
|
|
<YStack space="$1.5">
|
|
{isResellerCatalog ? (
|
|
<>
|
|
{includedTierLabel ? (
|
|
<FeatureRow
|
|
label={t('shop.partner.includedTier', 'Inklusive Event-Level: {{tier}}', {
|
|
tier: includedTierLabel,
|
|
})}
|
|
/>
|
|
) : null}
|
|
{typeof pkg.max_events_per_year === 'number' ? (
|
|
<FeatureRow label={t('shop.partner.eventsIncluded', '{{count}} Events im Kontingent', { count: pkg.max_events_per_year })} />
|
|
) : null}
|
|
<FeatureRow label={t('shop.partner.recommendedUsage', 'Empfohlen innerhalb von 24 Monaten zu nutzen.')} />
|
|
</>
|
|
) : (
|
|
<>
|
|
{pkg.max_photos ? (
|
|
<FeatureRow label={t('shop.limits.photos', '{{count}} Photos', { count: pkg.max_photos })} />
|
|
) : (
|
|
<FeatureRow label={t('shop.limits.unlimitedPhotos', 'Unlimited Photos')} />
|
|
)}
|
|
{pkg.gallery_days ? (
|
|
<FeatureRow label={t('shop.limits.days', '{{count}} Days Gallery', { count: pkg.gallery_days })} />
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
{/* Render specific feature if it was requested */}
|
|
{!isResellerCatalog
|
|
? getEnabledPackageFeatures(pkg)
|
|
.filter((key) => !pkg.max_photos || key !== 'photos')
|
|
.slice(0, 3)
|
|
.map((key) => (
|
|
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
|
|
))
|
|
: null}
|
|
</YStack>
|
|
|
|
<CTAButton
|
|
label={
|
|
isResellerCatalog
|
|
? canSelect
|
|
? t('shop.partner.buy', 'Kaufen')
|
|
: t('shop.partner.unavailable', 'Nicht verfügbar')
|
|
: isActive
|
|
? t('shop.manage', 'Manage Plan')
|
|
: isUpgrade
|
|
? t('shop.select', 'Select')
|
|
: t('shop.selectDisabled', 'Not available')
|
|
}
|
|
onPress={handlePress}
|
|
tone={isResellerCatalog ? (canSelect ? 'primary' : 'ghost') : isActive || !isUpgrade ? 'ghost' : 'primary'}
|
|
disabled={!canSelect && !hasManageAction}
|
|
/>
|
|
</MobileCard>
|
|
);
|
|
}
|
|
|
|
function FeatureRow({ label }: { label: string }) {
|
|
const { textStrong, primary } = useAdminTheme();
|
|
return (
|
|
<XStack alignItems="center" space="$2">
|
|
<Check size={14} color={primary} />
|
|
<Text fontSize="$sm" color={textStrong}>{label}</Text>
|
|
</XStack>
|
|
)
|
|
}
|
|
|
|
type PackageEntry = {
|
|
pkg: Package;
|
|
owned?: TenantPackageSummary;
|
|
isActive: boolean;
|
|
isRecommended: boolean;
|
|
isUpgrade: boolean;
|
|
isDowngrade: boolean;
|
|
};
|
|
|
|
function PackageShopCompareView({
|
|
entries,
|
|
onSelect,
|
|
onManage,
|
|
catalogType,
|
|
}: {
|
|
entries: PackageEntry[];
|
|
onSelect: (pkg: Package) => void;
|
|
onManage: () => void;
|
|
catalogType: 'endcustomer' | 'reseller';
|
|
}) {
|
|
const { t } = useTranslation('management');
|
|
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
|
const comparisonRows = buildPackageComparisonRows(entries.map((entry) => entry.pkg));
|
|
const labelWidth = 140;
|
|
const columnWidth = 150;
|
|
|
|
const rows = [
|
|
{ id: 'meta.plan', type: 'meta' as const, label: t('shop.compare.headers.plan', 'Plan') },
|
|
{ id: 'meta.price', type: 'meta' as const, label: t('shop.compare.headers.price', 'Price') },
|
|
...comparisonRows,
|
|
];
|
|
|
|
const renderRowLabel = (row: typeof rows[number]) => {
|
|
if (row.type === 'meta') {
|
|
return row.label;
|
|
}
|
|
|
|
if (row.type === 'limit') {
|
|
if (row.limitKey === 'max_photos') {
|
|
return t('shop.compare.rows.photos', 'Photos');
|
|
}
|
|
if (row.limitKey === 'max_guests') {
|
|
return t('shop.compare.rows.guests', 'Guests');
|
|
}
|
|
if (row.limitKey === 'max_events_per_year') {
|
|
return t('shop.partner.compare.rows.events', 'Events im Kontingent');
|
|
}
|
|
return t('shop.compare.rows.days', 'Gallery days');
|
|
}
|
|
|
|
if (row.type === 'value') {
|
|
if (row.valueKey === 'included_package_slug') {
|
|
return t('shop.partner.compare.rows.includedTier', 'Inklusive Event-Level');
|
|
}
|
|
}
|
|
|
|
return t(`shop.features.${row.featureKey}`, row.featureKey);
|
|
};
|
|
|
|
const formatLimitValue = (value: number | null) => {
|
|
if (value === null) {
|
|
return t('shop.compare.values.unlimited', 'Unlimited');
|
|
}
|
|
return new Intl.NumberFormat().format(value);
|
|
};
|
|
|
|
return (
|
|
<MobileCard space="$3" borderColor={border}>
|
|
<YStack space="$1">
|
|
<Text fontSize="$md" fontWeight="700" color={textStrong}>
|
|
{t('shop.compare.title', 'Compare plans')}
|
|
</Text>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{t('shop.compare.helper', 'Swipe to compare packages side by side.')}
|
|
</Text>
|
|
</YStack>
|
|
|
|
<XStack style={{ overflowX: 'auto' }}>
|
|
<YStack space="$1.5" minWidth={labelWidth + columnWidth * entries.length}>
|
|
{rows.map((row) => (
|
|
<XStack key={row.id} borderBottomWidth={1} borderColor={border}>
|
|
<YStack
|
|
width={labelWidth}
|
|
paddingVertical="$2"
|
|
paddingRight="$3"
|
|
justifyContent="center"
|
|
>
|
|
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
|
{renderRowLabel(row)}
|
|
</Text>
|
|
</YStack>
|
|
{entries.map((entry) => {
|
|
const cellBackground = entry.isRecommended ? accentSoft : entry.isActive ? '$green1' : undefined;
|
|
let content: React.ReactNode = null;
|
|
|
|
if (row.type === 'meta') {
|
|
if (row.id === 'meta.plan') {
|
|
const statusLabel = getPackageStatusLabel({ t, isActive: entry.isActive, owned: entry.owned });
|
|
content = (
|
|
<YStack space="$1">
|
|
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
|
{entry.pkg.name}
|
|
</Text>
|
|
<XStack space="$1.5" flexWrap="wrap">
|
|
{entry.isRecommended ? (
|
|
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
|
|
) : null}
|
|
{catalogType !== 'reseller' && entry.isUpgrade && !entry.isActive ? (
|
|
<PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
|
|
) : null}
|
|
{catalogType !== 'reseller' && entry.isDowngrade && !entry.isActive ? (
|
|
<PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge>
|
|
) : null}
|
|
{catalogType !== 'reseller' && entry.isActive ? (
|
|
<PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>
|
|
) : null}
|
|
</XStack>
|
|
{statusLabel ? (
|
|
<Text fontSize="$xs" color={muted}>
|
|
{statusLabel}
|
|
</Text>
|
|
) : null}
|
|
</YStack>
|
|
);
|
|
} else if (row.id === 'meta.price') {
|
|
content = (
|
|
<Text fontSize="$sm" fontWeight="700" color={primary}>
|
|
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(entry.pkg.price)}
|
|
</Text>
|
|
);
|
|
}
|
|
} else if (row.type === 'limit') {
|
|
const value = entry.pkg[row.limitKey] ?? null;
|
|
content = (
|
|
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
|
|
{formatLimitValue(value)}
|
|
</Text>
|
|
);
|
|
} else if (row.type === 'value') {
|
|
content = (
|
|
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
|
|
{resolveIncludedTierLabel(t, entry.pkg.included_package_slug ?? null) ??
|
|
t('shop.partner.compare.values.unknown', '—')}
|
|
</Text>
|
|
);
|
|
} else if (row.type === 'feature') {
|
|
const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey);
|
|
content = (
|
|
<XStack alignItems="center" space="$1.5">
|
|
{enabled ? (
|
|
<Check size={16} color={primary} />
|
|
) : (
|
|
<X size={14} color={muted} />
|
|
)}
|
|
<Text fontSize="$sm" color={enabled ? textStrong : muted}>
|
|
{enabled ? t('shop.compare.values.included', 'Included') : t('shop.compare.values.notIncluded', 'Not included')}
|
|
</Text>
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<YStack
|
|
key={`${row.id}-${entry.pkg.id}`}
|
|
width={columnWidth}
|
|
paddingVertical="$2"
|
|
paddingHorizontal="$2"
|
|
justifyContent="center"
|
|
backgroundColor={cellBackground}
|
|
>
|
|
{content}
|
|
</YStack>
|
|
);
|
|
})}
|
|
</XStack>
|
|
))}
|
|
<XStack paddingTop="$2">
|
|
<YStack width={labelWidth} />
|
|
{entries.map((entry) => {
|
|
const isResellerCatalog = catalogType === 'reseller';
|
|
const canSelect = isResellerCatalog ? Boolean(entry.pkg.paddle_price_id) : canSelectPackage(entry.isUpgrade, entry.isActive);
|
|
const label = isResellerCatalog
|
|
? canSelect
|
|
? t('shop.partner.buy', 'Kaufen')
|
|
: t('shop.partner.unavailable', 'Nicht verfügbar')
|
|
: entry.isActive
|
|
? t('shop.manage', 'Manage Plan')
|
|
: entry.isUpgrade
|
|
? t('shop.select', 'Select')
|
|
: t('shop.selectDisabled', 'Not available');
|
|
const handlePress = entry.isActive ? onManage : canSelect ? () => onSelect(entry.pkg) : undefined;
|
|
|
|
return (
|
|
<YStack key={`cta-${entry.pkg.id}`} width={columnWidth} paddingHorizontal="$2">
|
|
<CTAButton
|
|
label={label}
|
|
onPress={handlePress}
|
|
disabled={!canSelect && !entry.isActive}
|
|
tone={
|
|
catalogType === 'reseller'
|
|
? canSelect
|
|
? 'primary'
|
|
: 'ghost'
|
|
: entry.isActive || entry.isDowngrade
|
|
? 'ghost'
|
|
: 'primary'
|
|
}
|
|
/>
|
|
</YStack>
|
|
);
|
|
})}
|
|
</XStack>
|
|
</YStack>
|
|
</XStack>
|
|
</MobileCard>
|
|
);
|
|
}
|
|
|
|
function getPackageStatusLabel({
|
|
t,
|
|
isActive,
|
|
owned,
|
|
}: {
|
|
t: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
|
|
isActive?: boolean;
|
|
owned?: TenantPackageSummary;
|
|
}): string | null {
|
|
if (isActive) {
|
|
return t('shop.status.active', 'Active Plan');
|
|
}
|
|
if (owned) {
|
|
return owned.remaining_events !== null
|
|
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
|
|
: t('shop.status.owned', 'Purchased');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function canSelectPackage(isUpgrade?: boolean, isActive?: boolean): boolean {
|
|
return Boolean(isActive || isUpgrade);
|
|
}
|
|
|
|
function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) {
|
|
const { t } = useTranslation('management');
|
|
const { textStrong, muted, border, primary } = useAdminTheme();
|
|
const [agbAccepted, setAgbAccepted] = React.useState(false);
|
|
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
|
|
const { busy, startCheckout } = usePackageCheckout();
|
|
|
|
const canProceed = agbAccepted && withdrawalAccepted;
|
|
|
|
const handleCheckout = async () => {
|
|
if (!canProceed || busy) return;
|
|
await startCheckout(pkg.id);
|
|
};
|
|
|
|
const subtitle =
|
|
pkg.type === 'reseller'
|
|
? t('shop.partner.confirmSubtitle', 'Du kaufst:')
|
|
: t('shop.confirmSubtitle', 'You are upgrading to:');
|
|
|
|
return (
|
|
<MobileShell title={t('shop.confirmTitle', 'Confirm Purchase')} onBack={onCancel} activeTab="profile">
|
|
<YStack space="$4">
|
|
<MobileCard space="$2" borderColor={border}>
|
|
<Text fontSize="$sm" color={muted}>{subtitle}</Text>
|
|
<Text fontSize="$xl" fontWeight="800" color={textStrong}>{pkg.name}</Text>
|
|
<Text fontSize="$lg" color={primary} fontWeight="700">
|
|
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)}
|
|
</Text>
|
|
</MobileCard>
|
|
|
|
<MobileCard space="$3" borderColor={border}>
|
|
<XStack alignItems="center" space="$2">
|
|
<ShieldCheck size={18} color={textStrong} />
|
|
<Text fontSize="$md" fontWeight="700" color={textStrong}>{t('shop.legalTitle', 'Terms & Conditions')}</Text>
|
|
</XStack>
|
|
|
|
<XStack space="$3" alignItems="flex-start">
|
|
<Checkbox
|
|
id="agb"
|
|
size="$4"
|
|
checked={agbAccepted}
|
|
onCheckedChange={(checked) => setAgbAccepted(!!checked)}
|
|
>
|
|
<Checkbox.Indicator>
|
|
<Check />
|
|
</Checkbox.Indicator>
|
|
</Checkbox>
|
|
<Text fontSize="$sm" color={textStrong} flex={1} onPress={() => setAgbAccepted(!agbAccepted)}>
|
|
{t('shop.legal.agb', 'I agree to the Terms and Conditions and Privacy Policy.')}
|
|
</Text>
|
|
</XStack>
|
|
|
|
<XStack space="$3" alignItems="flex-start">
|
|
<Checkbox
|
|
id="withdrawal"
|
|
size="$4"
|
|
checked={withdrawalAccepted}
|
|
onCheckedChange={(checked) => setWithdrawalAccepted(!!checked)}
|
|
>
|
|
<Checkbox.Indicator>
|
|
<Check />
|
|
</Checkbox.Indicator>
|
|
</Checkbox>
|
|
<Text fontSize="$sm" color={textStrong} flex={1} onPress={() => setWithdrawalAccepted(!withdrawalAccepted)}>
|
|
{t('shop.legal.withdrawal', 'I agree that the contract execution begins immediately and I lose my right of withdrawal.')}
|
|
</Text>
|
|
</XStack>
|
|
</MobileCard>
|
|
|
|
<YStack space="$2">
|
|
<CTAButton
|
|
label={busy ? t('shop.processing', 'Processing...') : t('shop.payNow', 'Pay Now')}
|
|
onPress={handleCheckout}
|
|
disabled={!canProceed || busy}
|
|
tone="primary"
|
|
/>
|
|
<CTAButton
|
|
label={t('common.cancel', 'Cancel')}
|
|
onPress={onCancel}
|
|
tone="ghost"
|
|
disabled={busy}
|
|
/>
|
|
</YStack>
|
|
</YStack>
|
|
</MobileShell>
|
|
);
|
|
}
|
|
|
|
function aggregateOwnedEntries(entries: TenantPackageSummary[]): TenantPackageSummary {
|
|
const remainingTotal = entries.reduce(
|
|
(total, entry) => total + (typeof entry.remaining_events === 'number' ? entry.remaining_events : 0),
|
|
0
|
|
);
|
|
const usedTotal = entries.reduce(
|
|
(total, entry) => total + (typeof entry.used_events === 'number' ? entry.used_events : 0),
|
|
0
|
|
);
|
|
|
|
return {
|
|
...entries[0],
|
|
used_events: usedTotal,
|
|
remaining_events: Number.isFinite(remainingTotal) ? remainingTotal : entries[0].remaining_events,
|
|
};
|
|
}
|
|
|
|
function resolveIncludedTierLabel(
|
|
t: (key: string, fallback?: string, options?: Record<string, unknown>) => string,
|
|
slug: string | null
|
|
): string | null {
|
|
if (!slug) {
|
|
return null;
|
|
}
|
|
|
|
if (slug === 'starter') {
|
|
return t('shop.partner.tiers.starter', 'Starter');
|
|
}
|
|
|
|
if (slug === 'standard') {
|
|
return t('shop.partner.tiers.standard', 'Standard');
|
|
}
|
|
|
|
if (slug === 'pro') {
|
|
return t('shop.partner.tiers.premium', 'Premium');
|
|
}
|
|
|
|
return slug;
|
|
}
|