feat: implement upgrade to premium flow

This commit adds the ability for tenants to upgrade their package directly from the mobile billing page. It includes:
- New API function createTenantPaddleCheckout in api.ts
- Upgrade handler and UI in BillingPage.tsx
- Updated navigation in EventAnalyticsPage.tsx to link to the packages section of the billing page
This commit is contained in:
Codex Agent
2026-01-06 16:35:10 +01:00
parent ee3e9737c4
commit c4fa0fc06e
3 changed files with 60 additions and 2 deletions

View File

@@ -2454,6 +2454,25 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
}; };
} }
export async function createTenantPaddleCheckout(
packageId: number,
urls?: { success_url?: string; return_url?: string }
): Promise<{ checkout_url: string; id: string; expires_at?: string }> {
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
package_id: packageId,
success_url: urls?.success_url,
return_url: urls?.return_url,
}),
});
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string }>(
response,
'Failed to create checkout'
);
}
export async function createTenantBillingPortalSession(): Promise<{ url: string }> { export async function createTenantBillingPortalSession(): Promise<{ url: string }> {
const response = await authorizedFetch('/api/v1/tenant/billing/portal', { const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
method: 'POST', method: 'POST',

View File

@@ -14,6 +14,7 @@ import {
getTenantPaddleTransactions, getTenantPaddleTransactions,
TenantPackageSummary, TenantPackageSummary,
PaddleTransactionSummary, PaddleTransactionSummary,
createTenantPaddleCheckout,
} from '../api'; } from '../api';
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
@@ -40,6 +41,7 @@ export default function MobileBillingPage() {
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [portalBusy, setPortalBusy] = React.useState(false); const [portalBusy, setPortalBusy] = React.useState(false);
const [upgradeBusy, setUpgradeBusy] = React.useState<number | null>(null);
const packagesRef = React.useRef<HTMLDivElement | null>(null); const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null); const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de'; const supportEmail = 'support@fotospiel.de';
@@ -95,6 +97,26 @@ export default function MobileBillingPage() {
} }
}, [portalBusy, t]); }, [portalBusy, t]);
const handleUpgrade = React.useCallback(async (pkg: TenantPackageSummary) => {
if (upgradeBusy) return;
setUpgradeBusy(pkg.package_id);
try {
const { checkout_url } = await createTenantPaddleCheckout(pkg.package_id, {
success_url: window.location.href,
return_url: window.location.href,
});
if (typeof window !== 'undefined') {
window.location.href = checkout_url;
}
} catch (err) {
const message = getApiErrorMessage(err, t('billing.errors.upgrade', 'Konnte Checkout nicht starten.'));
toast.error(message);
setUpgradeBusy(null);
}
}, [upgradeBusy, t]);
React.useEffect(() => { React.useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
@@ -161,7 +183,12 @@ export default function MobileBillingPage() {
{packages {packages
.filter((pkg) => !activePackage || pkg.id !== activePackage.id) .filter((pkg) => !activePackage || pkg.id !== activePackage.id)
.map((pkg) => ( .map((pkg) => (
<PackageCard key={pkg.id} pkg={pkg} /> <PackageCard
key={pkg.id}
pkg={pkg}
onUpgrade={() => handleUpgrade(pkg)}
upgradeBusy={upgradeBusy === pkg.package_id}
/>
))} ))}
</YStack> </YStack>
)} )}
@@ -265,12 +292,16 @@ function PackageCard({
isActive = false, isActive = false,
onOpenPortal, onOpenPortal,
portalBusy, portalBusy,
onUpgrade,
upgradeBusy,
}: { }: {
pkg: TenantPackageSummary; pkg: TenantPackageSummary;
label?: string; label?: string;
isActive?: boolean; isActive?: boolean;
onOpenPortal?: () => void; onOpenPortal?: () => void;
portalBusy?: boolean; portalBusy?: boolean;
onUpgrade?: () => void;
upgradeBusy?: boolean;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { border, primary, accentSoft, textStrong, muted } = useAdminTheme(); const { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
@@ -383,6 +414,14 @@ function PackageCard({
tone={isDanger ? 'danger' : 'primary'} tone={isDanger ? 'danger' : 'primary'}
/> />
) : null} ) : null}
{!isActive && onUpgrade ? (
<CTAButton
label={upgradeBusy ? t('billing.actions.upgradeBusy', 'Einen Moment...') : t('billing.actions.upgrade', 'Buy / Upgrade')}
onPress={onUpgrade}
disabled={upgradeBusy}
tone="primary"
/>
) : null}
</MobileCard> </MobileCard>
); );
} }

View File

@@ -66,7 +66,7 @@ export default function MobileEventAnalyticsPage() {
</YStack> </YStack>
<CTAButton <CTAButton
label={t('analytics.upgradeAction', 'Upgrade to Premium')} label={t('analytics.upgradeAction', 'Upgrade to Premium')}
onPress={() => navigate(adminPath('/mobile/billing'))} onPress={() => navigate(adminPath('/mobile/billing#packages'))}
/> />
</MobileCard> </MobileCard>
</MobileShell> </MobileShell>