feat: implement dedicated package shop page with legal confirmation
This commit: - Creates MobilePackageShopPage.tsx for listing packages and handling checkout. - Implements a confirmation screen with mandatory AGB and Withdrawal checkboxes. - Registers the new route /mobile/billing/shop. - Updates EventAnalyticsPage to link to the new shop page. - Reverts previous inline upgrade logic in BillingPage.tsx.
This commit is contained in:
215
resources/js/admin/mobile/PackageShopPage.tsx
Normal file
215
resources/js/admin/mobile/PackageShopPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ChevronRight, ShieldCheck, ShoppingBag } 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 } 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 { textStrong, muted, border, primary, surface, accentSoft } = useAdminTheme();
|
||||
const [selectedPackage, setSelectedPackage] = React.useState<Package | null>(null);
|
||||
|
||||
const { data: packages, isLoading } = useQuery({
|
||||
queryKey: ['packages', 'endcustomer'],
|
||||
queryFn: () => getPackages('endcustomer'),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<MobileShell title={t('shop.title', 'Upgrade Package')} showBack>
|
||||
<YStack space="$3">
|
||||
<SkeletonCard height={150} />
|
||||
<SkeletonCard height={150} />
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPackage) {
|
||||
return (
|
||||
<CheckoutConfirmation
|
||||
pkg={selectedPackage}
|
||||
onCancel={() => setSelectedPackage(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell title={t('shop.title', 'Upgrade Package')} showBack>
|
||||
<YStack space="$4">
|
||||
<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">
|
||||
{packages?.map((pkg) => (
|
||||
<PackageShopCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
onSelect={() => setSelectedPackage(pkg)}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageShopCard({ pkg, onSelect }: { pkg: Package; onSelect: () => void }) {
|
||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
// Parse features if they are stored as JSON strings or objects
|
||||
// The API type says Record<string, boolean>, but let's be safe
|
||||
const features = pkg.features;
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
onPress={onSelect}
|
||||
borderColor={border}
|
||||
space="$3"
|
||||
pressStyle={{ backgroundColor: accentSoft }}
|
||||
>
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
<YStack>
|
||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||
{pkg.name}
|
||||
</Text>
|
||||
<Text fontSize="$md" color={primary} fontWeight="700">
|
||||
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)}
|
||||
</Text>
|
||||
</YStack>
|
||||
<ChevronRight size={20} color={muted} />
|
||||
</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}
|
||||
</YStack>
|
||||
|
||||
<CTAButton label={t('shop.select', 'Select')} onPress={onSelect} tone="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}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user