Add photobooth connect codes and uploader pipeline
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
createTenantBillingPortalSession,
|
||||
getTenantPackagesOverview,
|
||||
getTenantPaddleTransactions,
|
||||
getTenantPackageCheckoutStatus,
|
||||
TenantPackageSummary,
|
||||
PaddleTransactionSummary,
|
||||
} from '../api';
|
||||
@@ -27,6 +28,14 @@ import {
|
||||
getPackageFeatureLabel,
|
||||
getPackageLimitEntries,
|
||||
} from './lib/packageSummary';
|
||||
import {
|
||||
PendingCheckout,
|
||||
loadPendingCheckout,
|
||||
shouldClearPendingCheckout,
|
||||
storePendingCheckout,
|
||||
} from './lib/billingCheckout';
|
||||
|
||||
const CHECKOUT_POLL_INTERVAL_MS = 10000;
|
||||
|
||||
export default function MobileBillingPage() {
|
||||
const { t } = useTranslation('management');
|
||||
@@ -40,6 +49,11 @@ export default function MobileBillingPage() {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [portalBusy, setPortalBusy] = React.useState(false);
|
||||
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
|
||||
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
|
||||
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
|
||||
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
|
||||
const lastCheckoutStatusRef = React.useRef<string | null>(null);
|
||||
const packagesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const supportEmail = 'support@fotospiel.de';
|
||||
@@ -95,6 +109,11 @@ export default function MobileBillingPage() {
|
||||
}
|
||||
}, [portalBusy, t]);
|
||||
|
||||
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
|
||||
setPendingCheckout(next);
|
||||
storePendingCheckout(next);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -108,6 +127,115 @@ export default function MobileBillingPage() {
|
||||
}
|
||||
}, [location.hash, loading]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!location.search) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const checkout = params.get('checkout');
|
||||
const packageId = params.get('package_id');
|
||||
if (!checkout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkout === 'success') {
|
||||
const packageIdNumber = packageId ? Number(packageId) : null;
|
||||
const existingSessionId = pendingCheckout?.checkoutSessionId ?? null;
|
||||
const pendingEntry = {
|
||||
packageId: Number.isFinite(packageIdNumber) ? packageIdNumber : null,
|
||||
checkoutSessionId: existingSessionId,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
persistPendingCheckout(pendingEntry);
|
||||
toast.success(t('billing.checkoutSuccess', 'Checkout completed. Your package will activate shortly.'));
|
||||
} else if (checkout === 'cancel') {
|
||||
persistPendingCheckout(null);
|
||||
toast(t('billing.checkoutCancelled', 'Checkout was cancelled.'));
|
||||
}
|
||||
|
||||
params.delete('checkout');
|
||||
params.delete('package_id');
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
hash: location.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [location.hash, location.pathname, location.search, navigate, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingCheckout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldClearPendingCheckout(pendingCheckout, activePackage?.package_id ?? null)) {
|
||||
persistPendingCheckout(null);
|
||||
}
|
||||
}, [activePackage?.package_id, pendingCheckout, persistPendingCheckout]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingCheckout?.checkoutSessionId) {
|
||||
setCheckoutStatus(null);
|
||||
setCheckoutStatusReason(null);
|
||||
setCheckoutActionUrl(null);
|
||||
lastCheckoutStatusRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await getTenantPackageCheckoutStatus(pendingCheckout.checkoutSessionId as string);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setCheckoutStatus(result.status);
|
||||
setCheckoutStatusReason(result.reason ?? null);
|
||||
setCheckoutActionUrl(typeof result.checkout_url === 'string' ? result.checkout_url : null);
|
||||
|
||||
const lastStatus = lastCheckoutStatusRef.current;
|
||||
lastCheckoutStatusRef.current = result.status;
|
||||
|
||||
if (result.status === 'completed') {
|
||||
persistPendingCheckout(null);
|
||||
if (lastStatus !== 'completed') {
|
||||
toast.success(t('billing.checkoutActivated', 'Your package is now active.'));
|
||||
}
|
||||
await load();
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'failed' || result.status === 'cancelled') {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
intervalId = setInterval(poll, CHECKOUT_POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, [load, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="profile"
|
||||
@@ -127,6 +255,109 @@ export default function MobileBillingPage() {
|
||||
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
||||
</MobileCard>
|
||||
) : null}
|
||||
{pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? (
|
||||
<MobileCard borderColor={danger} backgroundColor="$red1" space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={danger}>
|
||||
{t('billing.checkoutFailedTitle', 'Checkout failed')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(
|
||||
'billing.checkoutFailedBody',
|
||||
'The payment did not complete. You can try again or contact support.'
|
||||
)}
|
||||
</Text>
|
||||
{checkoutStatusReason ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(`billing.checkoutFailureReasons.${checkoutStatusReason}`, checkoutStatusReason)}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<PillBadge tone="danger">
|
||||
{t('billing.checkoutFailedBadge', 'Failed')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={t('billing.checkoutFailedRetry', 'Try again')}
|
||||
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
|
||||
tone="ghost"
|
||||
onPress={() => persistPendingCheckout(null)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
{pendingCheckout && checkoutStatus === 'requires_customer_action' ? (
|
||||
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{t('billing.checkoutActionTitle', 'Action required')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.checkoutActionBody', 'Complete your payment to activate the package.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="warning">
|
||||
{t('billing.checkoutActionBadge', 'Action needed')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={t('billing.checkoutActionButton', 'Continue checkout')}
|
||||
onPress={() => {
|
||||
if (checkoutActionUrl && typeof window !== 'undefined') {
|
||||
window.open(checkoutActionUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
navigate(adminPath('/mobile/billing/shop'));
|
||||
}}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
|
||||
tone="ghost"
|
||||
onPress={() => persistPendingCheckout(null)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' && checkoutStatus !== 'requires_customer_action' ? (
|
||||
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{t('billing.checkoutPendingTitle', 'Activating your package')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(
|
||||
'billing.checkoutPendingBody',
|
||||
'This can take a few minutes. We will update this screen once the package is active.'
|
||||
)}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="warning">
|
||||
{t('billing.checkoutPendingBadge', 'Pending')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton label={t('billing.checkoutPendingRefresh', 'Refresh')} onPress={load} fullWidth={false} />
|
||||
<CTAButton
|
||||
label={t('billing.checkoutPendingDismiss', 'Dismiss')}
|
||||
tone="ghost"
|
||||
onPress={() => persistPendingCheckout(null)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$2" ref={packagesRef as any}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
@@ -535,4 +766,4 @@ function formatDate(value: string | null | undefined): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user