Files
fotospiel-app/resources/js/admin/mobile/PackageShopPage.tsx
Codex Agent b854e3feaa
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Show billing activation banner
2026-01-12 12:07:37 +01:00

343 lines
13 KiB
TypeScript

import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Check, ChevronRight, ShieldCheck, ShoppingBag, Sparkles, Star } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Checkbox } from '@tamagui/checkbox';
import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { useAdminTheme } from './theme';
import { getPackages, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { useQuery } from '@tanstack/react-query';
import { classifyPackageChange, selectRecommendedPackageId } from './lib/packageShop';
export default function MobilePackageShopPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
const location = useLocation();
const { textStrong, muted, border, primary, surface, accentSoft, warningText } = useAdminTheme();
const [selectedPackage, setSelectedPackage] = React.useState<Package | null>(null);
// 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 { data: inventory, isLoading: loadingInventory } = useQuery({
queryKey: ['tenant-packages-overview'],
queryFn: () => getTenantPackagesOverview({ force: true }),
});
const isLoading = loadingCatalog || loadingInventory;
if (isLoading) {
return (
<MobileShell title={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 = 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;
});
return (
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
<YStack space="$4">
{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}>
{t('shop.subtitle', 'Choose a package to unlock more features and limits.')}
</Text>
</YStack>
<YStack space="$3">
{sortedPackages.map((pkg) => {
const owned = inventory?.packages?.find(p => p.package_id === pkg.id);
const isActive = inventory?.activePackage?.package_id === pkg.id;
const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false;
const { isUpgrade, isDowngrade } = classifyPackageChange(pkg, activeCatalogPackage);
return (
<PackageShopCard
key={pkg.id}
pkg={pkg}
owned={owned}
isActive={isActive}
isRecommended={isRecommended}
isUpgrade={isUpgrade}
isDowngrade={isDowngrade}
onSelect={() => setSelectedPackage(pkg)}
/>
);
})}
</YStack>
</YStack>
</MobileShell>
);
}
function PackageShopCard({
pkg,
owned,
isActive,
isRecommended,
isUpgrade,
isDowngrade,
onSelect
}: {
pkg: Package;
owned?: TenantPackageSummary;
isActive?: boolean;
isRecommended?: any;
isUpgrade?: boolean;
isDowngrade?: boolean;
onSelect: () => void
}) {
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
const { t } = useTranslation('management');
const hasRemainingEvents = owned && (owned.remaining_events === null || owned.remaining_events > 0);
const statusLabel = isActive
? t('shop.status.active', 'Active Plan')
: owned
? (owned.remaining_events !== null
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
: t('shop.status.owned', 'Purchased'))
: null;
const isSubdued = Boolean(isDowngrade && !isActive);
const canSelect = !isDowngrade || isActive;
return (
<MobileCard
onPress={canSelect ? onSelect : undefined}
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
borderWidth={isRecommended || isActive ? 2 : 1}
space="$3"
pressStyle={canSelect ? { 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>}
{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>}
</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">
{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 */}
{Object.entries(pkg.features || {})
.filter(([key, val]) => val === true && (!pkg.max_photos || key !== 'photos'))
.slice(0, 3)
.map(([key]) => (
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
))
}
</YStack>
<CTAButton
label={
isActive
? t('shop.manage', 'Manage Plan')
: isDowngrade
? t('shop.selectDisabled', 'Not available')
: t('shop.select', 'Select')
}
onPress={canSelect ? onSelect : undefined}
tone={isActive || isDowngrade ? 'ghost' : 'primary'}
disabled={!canSelect}
/>
</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>
)
}
function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) {
const { t } = useTranslation('management');
const { textStrong, muted, border, primary, danger } = useAdminTheme();
const [agbAccepted, setAgbAccepted] = React.useState(false);
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const canProceed = agbAccepted && withdrawalAccepted;
const handleCheckout = async () => {
if (!canProceed || busy) return;
setBusy(true);
try {
if (typeof window === 'undefined') {
throw new Error('Checkout is only available in the browser.');
}
const billingUrl = new URL(adminPath('/mobile/billing'), window.location.origin);
const successUrl = new URL(billingUrl);
successUrl.searchParams.set('checkout', 'success');
successUrl.searchParams.set('package_id', String(pkg.id));
const cancelUrl = new URL(billingUrl);
cancelUrl.searchParams.set('checkout', 'cancel');
cancelUrl.searchParams.set('package_id', String(pkg.id));
const { checkout_url } = await createTenantPaddleCheckout(pkg.id, {
success_url: successUrl.toString(),
return_url: cancelUrl.toString(),
});
window.location.href = checkout_url;
} catch (err) {
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
setBusy(false);
}
};
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="$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>
);
}