310 lines
12 KiB
TypeScript
310 lines
12 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';
|
|
|
|
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)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Merge and sort packages
|
|
const sortedPackages = [...(catalog || [])].sort((a, b) => {
|
|
// 1. Recommended feature first
|
|
const aHasFeature = recommendedFeature && a.features?.[recommendedFeature];
|
|
const bHasFeature = recommendedFeature && b.features?.[recommendedFeature];
|
|
if (aHasFeature && !bHasFeature) return -1;
|
|
if (!aHasFeature && bHasFeature) return 1;
|
|
|
|
// 2. Inventory status (Owned packages later if they are fully used, but usually we want to show active stuff)
|
|
// Actually, let's keep price sorting as secondary
|
|
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 = recommendedFeature && pkg.features?.[recommendedFeature];
|
|
|
|
return (
|
|
<PackageShopCard
|
|
key={pkg.id}
|
|
pkg={pkg}
|
|
owned={owned}
|
|
isActive={isActive}
|
|
isRecommended={isRecommended}
|
|
onSelect={() => setSelectedPackage(pkg)}
|
|
/>
|
|
);
|
|
})}
|
|
</YStack>
|
|
</YStack>
|
|
</MobileShell>
|
|
);
|
|
}
|
|
|
|
function PackageShopCard({
|
|
pkg,
|
|
owned,
|
|
isActive,
|
|
isRecommended,
|
|
onSelect
|
|
}: {
|
|
pkg: Package;
|
|
owned?: TenantPackageSummary;
|
|
isActive?: boolean;
|
|
isRecommended?: any;
|
|
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;
|
|
|
|
return (
|
|
<MobileCard
|
|
onPress={onSelect}
|
|
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
|
|
borderWidth={isRecommended || isActive ? 2 : 1}
|
|
space="$3"
|
|
pressStyle={{ backgroundColor: accentSoft }}
|
|
backgroundColor={isActive ? '$green1' : undefined}
|
|
>
|
|
<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>}
|
|
{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') : t('shop.select', 'Select')}
|
|
onPress={onSelect}
|
|
tone={isActive ? 'ghost' : 'primary'}
|
|
/>
|
|
</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 {
|
|
const { checkout_url } = await createTenantPaddleCheckout(pkg.id, {
|
|
success_url: window.location.href,
|
|
return_url: window.location.href,
|
|
});
|
|
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>
|
|
);
|
|
}
|