Update partner packages, copy, and demo switcher
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-15 17:33:36 +01:00
parent 2f93271d94
commit ad829ae509
50 changed files with 1335 additions and 411 deletions

View File

@@ -58,6 +58,12 @@ export default function MobileBillingPage() {
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de';
const back = useBackNavigation(adminPath('/mobile/profile'));
const shopLink = React.useMemo(() => {
const isPartner =
activePackage?.package_type === 'reseller' || packages.some((pkg) => pkg.package_type === 'reseller');
return isPartner ? adminPath('/mobile/billing/shop?type=reseller') : adminPath('/mobile/billing/shop');
}, [activePackage?.package_type, packages]);
const load = React.useCallback(async () => {
setLoading(true);
@@ -281,7 +287,7 @@ export default function MobileBillingPage() {
<XStack space="$2">
<CTAButton
label={t('billing.checkoutFailedRetry', 'Try again')}
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
onPress={() => navigate(shopLink)}
fullWidth={false}
/>
<CTAButton
@@ -316,7 +322,7 @@ export default function MobileBillingPage() {
window.open(checkoutActionUrl, '_blank', 'noopener');
return;
}
navigate(adminPath('/mobile/billing/shop'));
navigate(shopLink);
}}
fullWidth={false}
/>
@@ -385,7 +391,7 @@ export default function MobileBillingPage() {
pkg={activePackage}
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
isActive
onOpenShop={() => navigate(adminPath('/mobile/billing/shop'))}
onOpenShop={() => navigate(shopLink)}
/>
) : null}
{packages
@@ -501,6 +507,15 @@ function PackageCard({
const { t } = useTranslation('management');
const { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
const limits = (pkg.package_limits ?? null) as Record<string, unknown> | null;
const isPartnerPackage = pkg.package_type === 'reseller';
const includedTierLabel =
pkg.included_package_slug === 'starter'
? t('shop.partner.tiers.starter', 'Starter')
: pkg.included_package_slug === 'standard'
? t('shop.partner.tiers.standard', 'Standard')
: pkg.included_package_slug === 'pro'
? t('shop.partner.tiers.premium', 'Premium')
: pkg.included_package_slug;
const limitMaxEvents = typeof limits?.max_events_per_year === 'number' ? (limits?.max_events_per_year as number) : null;
const remaining = pkg.remaining_events ?? limitMaxEvents ?? 0;
const remainingText =
@@ -520,7 +535,7 @@ function PackageCard({
const limitEntries = getPackageLimitEntries(limits, t, {
remainingEvents: pkg.remaining_events ?? null,
usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null,
});
}, { packageType: pkg.package_type });
const featureKeys = collectPackageFeatures(pkg);
const eventUsageText = formatEventUsage(
typeof pkg.used_events === 'number' ? pkg.used_events : null,
@@ -550,8 +565,9 @@ function PackageCard({
{pkg.price !== null && pkg.price !== undefined ? (
<PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge>
) : null}
{renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))}
{renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))}
{isPartnerPackage && includedTierLabel ? <PillBadge tone="muted">{includedTierLabel}</PillBadge> : null}
{!isPartnerPackage ? renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding')) : null}
{!isPartnerPackage ? renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark')) : null}
</XStack>
{eventUsageText ? (
<Text fontSize="$xs" color={muted}>

View File

@@ -354,6 +354,7 @@ export default function MobileDashboardPage() {
navigate(adminPath('/mobile/events/new'));
}}
packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')}
packageType={activePackage.package_type ?? null}
remainingEvents={remainingEvents}
purchasedAt={activePackage.purchased_at}
expiresAt={activePackage.expires_at}
@@ -497,6 +498,7 @@ function PackageSummarySheet({
onClose,
onContinue,
packageName,
packageType,
remainingEvents,
purchasedAt,
expiresAt,
@@ -508,6 +510,7 @@ function PackageSummarySheet({
onClose: () => void;
onContinue: () => void;
packageName: string;
packageType: string | null;
remainingEvents: number | null | undefined;
purchasedAt: string | null | undefined;
expiresAt: string | null | undefined;
@@ -523,8 +526,9 @@ function PackageSummarySheet({
package_limits: limits,
branding_allowed: (limits as any)?.branding_allowed ?? null,
watermark_allowed: (limits as any)?.watermark_allowed ?? null,
package_type: packageType,
} as any);
const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents });
const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents }, { packageType });
const hasFeatures = resolvedFeatures.length > 0;
const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown');

View File

@@ -9,7 +9,19 @@ import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { createEvent, getEvent, updateEvent, getEventTypes, getPackages, Package, TenantEvent, TenantEventType, trackOnboarding } from '../api';
import {
createEvent,
getEvent,
updateEvent,
getEventTypes,
getPackages,
getTenantPackagesOverview,
Package,
TenantEvent,
TenantEventType,
TenantPackageSummary,
trackOnboarding,
} from '../api';
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
@@ -30,6 +42,7 @@ type FormState = {
autoApproveUploads: boolean;
tasksEnabled: boolean;
packageId: number | null;
servicePackageSlug: string | null;
};
export default function MobileEventFormPage() {
@@ -52,11 +65,14 @@ export default function MobileEventFormPage() {
autoApproveUploads: true,
tasksEnabled: true,
packageId: null,
servicePackageSlug: null,
});
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
const [typesLoading, setTypesLoading] = React.useState(false);
const [packages, setPackages] = React.useState<Package[]>([]);
const [packagesLoading, setPackagesLoading] = React.useState(false);
const [kontingentOptions, setKontingentOptions] = React.useState<Array<{ slug: string; remaining: number }>>([]);
const [kontingentLoading, setKontingentLoading] = React.useState(false);
const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false);
const [consentOpen, setConsentOpen] = React.useState(false);
@@ -84,6 +100,7 @@ export default function MobileEventFormPage() {
(data.settings?.engagement_mode as string | undefined) !== 'photo_only' &&
(data.engagement_mode as string | undefined) !== 'photo_only',
packageId: null,
servicePackageSlug: null,
});
setError(null);
} catch (err) {
@@ -139,6 +156,75 @@ export default function MobileEventFormPage() {
})();
}, [isSuperAdmin, isEdit]);
React.useEffect(() => {
if (isEdit) {
return;
}
(async () => {
setKontingentLoading(true);
try {
const overview = await getTenantPackagesOverview();
const packages = overview.packages ?? [];
const active = packages.filter((pkg) => pkg.active && pkg.package_type === 'reseller');
const totals = new Map<string, number>();
active.forEach((pkg: TenantPackageSummary) => {
const slugValue = pkg.included_package_slug ?? 'standard';
if (!slugValue) {
return;
}
const remaining = Number.isFinite(pkg.remaining_events as number) ? Number(pkg.remaining_events) : 0;
if (remaining <= 0) {
return;
}
totals.set(slugValue, (totals.get(slugValue) ?? 0) + remaining);
});
const options = Array.from(totals.entries())
.map(([slugValue, remaining]) => ({ slug: slugValue, remaining }))
.sort((a, b) => a.slug.localeCompare(b.slug));
setKontingentOptions(options);
setForm((prev) => {
if (prev.servicePackageSlug || options.length === 0) {
return prev;
}
if (options.length === 1) {
return { ...prev, servicePackageSlug: options[0].slug };
}
const standard = options.find((row) => row.slug === 'standard');
return { ...prev, servicePackageSlug: standard?.slug ?? options[0].slug };
});
} catch {
setKontingentOptions([]);
} finally {
setKontingentLoading(false);
}
})();
}, [isEdit]);
const resolveServiceTierLabel = React.useCallback((slugValue: string) => {
if (slugValue === 'starter') {
return 'Starter';
}
if (slugValue === 'standard') {
return 'Standard';
}
if (slugValue === 'pro') {
return 'Premium';
}
return slugValue;
}, []);
async function handleSubmit() {
setSaving(true);
setError(null);
@@ -165,6 +251,7 @@ export default function MobileEventFormPage() {
event_date: form.date || undefined,
status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
service_package_slug: form.servicePackageSlug ?? undefined,
settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
@@ -188,6 +275,7 @@ export default function MobileEventFormPage() {
event_date: form.date || undefined,
status: form.published ? 'published' : 'draft',
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
service_package_slug: form.servicePackageSlug ?? undefined,
settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
@@ -283,6 +371,34 @@ export default function MobileEventFormPage() {
</MobileField>
) : null}
{!isEdit && (kontingentLoading || kontingentOptions.length > 0) ? (
<MobileField label={t('eventForm.fields.servicePackage.label', 'Event-Level (Event-Kontingent)')}>
{kontingentLoading ? (
<Text fontSize="$sm" color={muted}>
{t('eventForm.fields.servicePackage.loading', 'Loading Event-Kontingente…')}
</Text>
) : (
<MobileSelect
value={form.servicePackageSlug ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, servicePackageSlug: String(e.target.value) }))}
>
<option value="">{t('eventForm.fields.servicePackage.placeholder', 'Select tier')}</option>
{kontingentOptions.map((opt) => (
<option key={opt.slug} value={opt.slug}>
{resolveServiceTierLabel(opt.slug)} · {opt.remaining} {t('eventForm.fields.servicePackage.events', 'Events')}
</option>
))}
</MobileSelect>
)}
<Text fontSize="$xs" color={muted}>
{t(
'eventForm.fields.servicePackage.help',
'Wählt das Event-Level. Pro Event wird 1 aus dem passenden Event-Kontingent verbraucht.',
)}
</Text>
</MobileField>
) : null}
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
<XStack alignItems="center" space="$2">
<NativeDateTimeInput

View File

@@ -30,22 +30,39 @@ export default function MobilePackageShopPage() {
// Extract recommended feature from URL
const searchParams = new URLSearchParams(location.search);
const recommendedFeature = searchParams.get('feature');
const { data: catalog, isLoading: loadingCatalog } = useQuery({
queryKey: ['packages', 'endcustomer'],
queryFn: () => getPackages('endcustomer'),
});
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={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
<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} />
@@ -65,7 +82,10 @@ export default function MobilePackageShopPage() {
const activePackageId = inventory?.activePackage?.package_id ?? null;
const activeCatalogPackage = (catalog ?? []).find((pkg) => pkg.id === activePackageId) ?? null;
const recommendedPackageId = selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage);
const recommendedPackageId =
catalogType === 'reseller'
? null
: selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage);
// Merge and sort packages
const sortedPackages = [...(catalog || [])].sort((a, b) => {
@@ -78,10 +98,14 @@ export default function MobilePackageShopPage() {
});
const packageEntries = sortedPackages.map((pkg) => {
const owned = inventory?.packages?.find((entry) => entry.package_id === pkg.id);
const isActive = inventory?.activePackage?.package_id === pkg.id;
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 } = classifyPackageChange(pkg, activeCatalogPackage);
const { isUpgrade, isDowngrade } =
catalogType === 'reseller'
? { isUpgrade: false, isDowngrade: false }
: classifyPackageChange(pkg, activeCatalogPackage);
return {
pkg,
@@ -94,9 +118,13 @@ export default function MobilePackageShopPage() {
});
return (
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
<MobileShell
title={catalogType === 'reseller' ? t('shop.partner.title', 'Event-Kontingent kaufen') : t('shop.title', 'Upgrade Package')}
onBack={() => navigate(-1)}
activeTab="profile"
>
<YStack space="$4">
{recommendedFeature && (
{catalogType !== 'reseller' && recommendedFeature && (
<MobileCard borderColor={primary} backgroundColor={accentSoft} space="$2" padding="$3">
<XStack space="$2" alignItems="center">
<Sparkles size={16} color={primary} />
@@ -112,7 +140,9 @@ export default function MobilePackageShopPage() {
<YStack paddingHorizontal="$2">
<Text fontSize="$sm" color={muted}>
{t('shop.subtitle', 'Choose a package to unlock more features and limits.')}
{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>
@@ -140,6 +170,7 @@ export default function MobilePackageShopPage() {
<PackageShopCompareView
entries={packageEntries}
onSelect={(pkg) => setSelectedPackage(pkg)}
catalogType={catalogType}
/>
) : (
packageEntries.map((entry) => (
@@ -151,6 +182,7 @@ export default function MobilePackageShopPage() {
isRecommended={entry.isRecommended}
isUpgrade={entry.isUpgrade}
isDowngrade={entry.isDowngrade}
catalogType={catalogType}
onSelect={() => setSelectedPackage(entry.pkg)}
/>
))
@@ -168,6 +200,7 @@ function PackageShopCard({
isRecommended,
isUpgrade,
isDowngrade,
catalogType,
onSelect
}: {
pkg: Package;
@@ -176,14 +209,17 @@ function PackageShopCard({
isRecommended?: any;
isUpgrade?: boolean;
isDowngrade?: boolean;
catalogType: 'endcustomer' | 'reseller';
onSelect: () => 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((isDowngrade || !isUpgrade) && !isActive);
const canSelect = canSelectPackage(isUpgrade, isActive);
const isSubdued = Boolean(!isResellerCatalog && (isDowngrade || !isUpgrade) && !isActive);
const canSelect = isResellerCatalog ? Boolean(pkg.paddle_price_id) : canSelectPackage(isUpgrade, isActive);
const includedTierLabel = resolveIncludedTierLabel(t, pkg.included_package_slug ?? null);
return (
<MobileCard
@@ -202,9 +238,13 @@ function PackageShopCard({
{pkg.name}
</Text>
{isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
{isUpgrade && !isActive ? <PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge> : null}
{isDowngrade && !isActive ? <PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge> : null}
{isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</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">
@@ -224,34 +264,58 @@ function PackageShopCard({
</XStack>
<YStack space="$1.5">
{pkg.max_photos ? (
<FeatureRow label={t('shop.limits.photos', '{{count}} Photos', { count: pkg.max_photos })} />
{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.')} />
</>
) : (
<FeatureRow label={t('shop.limits.unlimitedPhotos', 'Unlimited Photos')} />
<>
{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}
</>
)}
{pkg.gallery_days ? (
<FeatureRow label={t('shop.limits.days', '{{count}} Days Gallery', { count: pkg.gallery_days })} />
) : null}
{/* Render specific feature if it was requested */}
{getEnabledPackageFeatures(pkg)
.filter((key) => !pkg.max_photos || key !== 'photos')
.slice(0, 3)
.map((key) => (
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
))}
{!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={
isActive
? t('shop.manage', 'Manage Plan')
: isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available')
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={canSelect ? onSelect : undefined}
tone={isActive || !isUpgrade ? 'ghost' : 'primary'}
tone={isResellerCatalog ? (canSelect ? 'primary' : 'ghost') : isActive || !isUpgrade ? 'ghost' : 'primary'}
disabled={!canSelect}
/>
</MobileCard>
@@ -280,9 +344,11 @@ type PackageEntry = {
function PackageShopCompareView({
entries,
onSelect,
catalogType,
}: {
entries: PackageEntry[];
onSelect: (pkg: Package) => void;
catalogType: 'endcustomer' | 'reseller';
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
@@ -308,9 +374,18 @@ function PackageShopCompareView({
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);
};
@@ -362,13 +437,15 @@ function PackageShopCompareView({
{entry.isRecommended ? (
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
) : null}
{entry.isUpgrade && !entry.isActive ? (
{catalogType !== 'reseller' && entry.isUpgrade && !entry.isActive ? (
<PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
) : null}
{entry.isDowngrade && !entry.isActive ? (
{catalogType !== 'reseller' && entry.isDowngrade && !entry.isActive ? (
<PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge>
) : null}
{entry.isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
{catalogType !== 'reseller' && entry.isActive ? (
<PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>
) : null}
</XStack>
{statusLabel ? (
<Text fontSize="$xs" color={muted}>
@@ -391,6 +468,13 @@ function PackageShopCompareView({
{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 = (
@@ -425,12 +509,17 @@ function PackageShopCompareView({
<XStack paddingTop="$2">
<YStack width={labelWidth} />
{entries.map((entry) => {
const canSelect = canSelectPackage(entry.isUpgrade, entry.isActive);
const label = entry.isActive
? t('shop.manage', 'Manage Plan')
: entry.isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available');
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');
return (
<YStack key={`cta-${entry.pkg.id}`} width={columnWidth} paddingHorizontal="$2">
@@ -438,7 +527,15 @@ function PackageShopCompareView({
label={label}
onPress={canSelect ? () => onSelect(entry.pkg) : undefined}
disabled={!canSelect}
tone={entry.isActive || entry.isDowngrade ? 'ghost' : 'primary'}
tone={
catalogType === 'reseller'
? canSelect
? 'primary'
: 'ghost'
: entry.isActive || entry.isDowngrade
? 'ghost'
: 'primary'
}
/>
</YStack>
);
@@ -488,11 +585,16 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
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}>{t('shop.confirmSubtitle', 'You are upgrading to:')}</Text>
<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)}
@@ -556,3 +658,43 @@ function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () =>
</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;
}

View File

@@ -6,6 +6,8 @@ const basePackage: TenantPackageSummary = {
id: 1,
package_id: 1,
package_name: 'Pro',
package_type: 'reseller',
included_package_slug: 'pro',
active: true,
used_events: 2,
remaining_events: 3,

View File

@@ -9,7 +9,12 @@ export type PackageComparisonRow =
| {
id: string;
type: 'limit';
limitKey: 'max_photos' | 'max_guests' | 'gallery_days';
limitKey: 'max_photos' | 'max_guests' | 'gallery_days' | 'max_events_per_year';
}
| {
id: string;
type: 'value';
valueKey: 'included_package_slug';
}
| {
id: string;
@@ -62,6 +67,10 @@ export function classifyPackageChange(pkg: Package, active: Package | null): Pac
return { isUpgrade: false, isDowngrade: false };
}
if (pkg.type === 'reseller' || active.type === 'reseller') {
return { isUpgrade: false, isDowngrade: false };
}
const activeFeatures = collectFeatures(active);
const candidateFeatures = collectFeatures(pkg);
@@ -106,6 +115,10 @@ export function selectRecommendedPackageId(
return null;
}
if (packages.some((pkg) => pkg.type === 'reseller')) {
return null;
}
const candidates = feature === 'watermark_allowed'
? packages.filter((pkg) => pkg.watermark_allowed === true)
: packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature));
@@ -121,11 +134,20 @@ export function selectRecommendedPackageId(
}
export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] {
const limitRows: PackageComparisonRow[] = [
{ id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' },
{ id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' },
{ id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' },
];
const isResellerCatalog = packages.some(
(pkg) => pkg.type === 'reseller' || pkg.max_events_per_year !== undefined || pkg.included_package_slug !== undefined
);
const limitRows: PackageComparisonRow[] = isResellerCatalog
? [
{ id: 'value.included_package_slug', type: 'value', valueKey: 'included_package_slug' },
{ id: 'limit.max_events_per_year', type: 'limit', limitKey: 'max_events_per_year' },
]
: [
{ id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' },
{ id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' },
{ id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' },
];
const featureKeys = new Set<string>();
packages.forEach((pkg) => {

View File

@@ -174,13 +174,19 @@ export function formatPackageLimit(value: number | null | undefined, t: Translat
export function getPackageLimitEntries(
limits: Record<string, unknown> | null,
t: Translate,
usageOverrides: LimitUsageOverrides = {}
usageOverrides: LimitUsageOverrides = {},
options: { packageType?: string | null } = {}
): PackageLimitEntry[] {
if (!limits) {
return [];
}
return LIMIT_LABELS.map(({ key, labelKey, fallback }) => ({
const labels =
options.packageType === 'reseller'
? LIMIT_LABELS.filter(({ key }) => key === 'max_events_per_year')
: LIMIT_LABELS;
return labels.map(({ key, labelKey, fallback }) => ({
key,
label: t(labelKey, fallback),
value: formatLimitWithRemaining(
@@ -231,11 +237,11 @@ export function collectPackageFeatures(pkg: TenantPackageSummary): string[] {
}
});
if (pkg.branding_allowed) {
if (pkg.package_type !== 'reseller' && pkg.branding_allowed) {
features.add('branding_allowed');
}
if (pkg.watermark_allowed) {
if (pkg.package_type !== 'reseller' && pkg.watermark_allowed) {
features.add('watermark_allowed');
}