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:
@@ -15,6 +15,7 @@ export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings');
|
|||||||
export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile');
|
export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile');
|
||||||
export const ADMIN_FAQ_PATH = adminPath('/mobile/help');
|
export const ADMIN_FAQ_PATH = adminPath('/mobile/help');
|
||||||
export const ADMIN_BILLING_PATH = adminPath('/mobile/billing');
|
export const ADMIN_BILLING_PATH = adminPath('/mobile/billing');
|
||||||
|
export const ADMIN_PACKAGE_SHOP_PATH = adminPath('/mobile/billing/shop');
|
||||||
export const ADMIN_DATA_EXPORTS_PATH = adminPath('/mobile/exports');
|
export const ADMIN_DATA_EXPORTS_PATH = adminPath('/mobile/exports');
|
||||||
export const ADMIN_PHOTOS_PATH = adminPath('/mobile/uploads');
|
export const ADMIN_PHOTOS_PATH = adminPath('/mobile/uploads');
|
||||||
export const ADMIN_LIVE_PATH = adminPath('/mobile/dashboard');
|
export const ADMIN_LIVE_PATH = adminPath('/mobile/dashboard');
|
||||||
|
|||||||
@@ -8,13 +8,22 @@ import { Pressable } from '@tamagui/react-native-web-lite';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||||
|
import React from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react';
|
||||||
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||||
import {
|
import {
|
||||||
createTenantBillingPortalSession,
|
createTenantBillingPortalSession,
|
||||||
getTenantPackagesOverview,
|
getTenantPackagesOverview,
|
||||||
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';
|
||||||
@@ -41,7 +50,6 @@ 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';
|
||||||
@@ -97,26 +105,6 @@ 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]);
|
||||||
@@ -183,12 +171,7 @@ 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
|
<PackageCard key={pkg.id} pkg={pkg} />
|
||||||
key={pkg.id}
|
|
||||||
pkg={pkg}
|
|
||||||
onUpgrade={() => handleUpgrade(pkg)}
|
|
||||||
upgradeBusy={upgradeBusy === pkg.package_id}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
)}
|
)}
|
||||||
@@ -292,16 +275,12 @@ 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();
|
||||||
@@ -414,14 +393,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -577,3 +548,155 @@ function formatDate(value: string | null | undefined): string {
|
|||||||
if (Number.isNaN(date.getTime())) return '—';
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, label: string) {
|
||||||
|
const value = (pkg.package_limits as any)?.[key] ?? (pkg as any)[key];
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
const enabled = value !== false;
|
||||||
|
return <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
|
||||||
|
const { t } = useTranslation('management');
|
||||||
|
const { muted, textStrong, border, primary, subtle, warningText, danger } = useAdminTheme();
|
||||||
|
const labelMap: Record<PackageUsageMetric['key'], string> = {
|
||||||
|
events: t('mobileBilling.usage.events', 'Events'),
|
||||||
|
guests: t('mobileBilling.usage.guests', 'Guests'),
|
||||||
|
photos: t('mobileBilling.usage.photos', 'Photos'),
|
||||||
|
gallery: t('mobileBilling.usage.gallery', 'Gallery days'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!metric.limit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = getUsageState(metric);
|
||||||
|
const hasUsage = metric.used !== null;
|
||||||
|
const valueText = hasUsage
|
||||||
|
? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit })
|
||||||
|
: t('mobileBilling.usage.limit', { limit: metric.limit });
|
||||||
|
const remainingText = metric.remaining !== null
|
||||||
|
? t('mobileBilling.usage.remainingOf', {
|
||||||
|
remaining: metric.remaining,
|
||||||
|
limit: metric.limit,
|
||||||
|
defaultValue: 'Remaining {{remaining}} of {{limit}}',
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const fill = usagePercent(metric);
|
||||||
|
const statusLabel =
|
||||||
|
status === 'danger'
|
||||||
|
? t('mobileBilling.usage.statusDanger', 'Limit reached')
|
||||||
|
: status === 'warning'
|
||||||
|
? t('mobileBilling.usage.statusWarning', 'Low')
|
||||||
|
: null;
|
||||||
|
const fillColor = status === 'danger' ? danger : status === 'warning' ? warningText : primary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack space="$1.5">
|
||||||
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{labelMap[metric.key]}
|
||||||
|
</Text>
|
||||||
|
<XStack alignItems="center" space="$1.5">
|
||||||
|
{statusLabel ? <PillBadge tone={status === 'danger' ? 'danger' : 'warning'}>{statusLabel}</PillBadge> : null}
|
||||||
|
<Text fontSize="$xs" color={textStrong} fontWeight="700">
|
||||||
|
{valueText}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
<YStack height={6} borderRadius={999} backgroundColor={border} overflow="hidden">
|
||||||
|
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? fillColor : subtle} />
|
||||||
|
</YStack>
|
||||||
|
{remainingText ? (
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{remainingText}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(value: number | null | undefined, currency: string | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const cur = currency ?? 'EUR';
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(undefined, { style: 'currency', currency: cur }).format(value);
|
||||||
|
} catch {
|
||||||
|
return `${value} ${cur}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
||||||
|
const { t } = useTranslation('management');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { border, textStrong, text, muted, subtle, primary } = useAdminTheme();
|
||||||
|
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
|
||||||
|
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
|
||||||
|
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
|
||||||
|
failed: { tone: 'muted', text: t('mobileBilling.status.failed', 'Failed') },
|
||||||
|
};
|
||||||
|
const status = labels[addon.status];
|
||||||
|
const eventName =
|
||||||
|
(addon.event?.name && typeof addon.event.name === 'string' && addon.event.name) ||
|
||||||
|
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
|
||||||
|
null;
|
||||||
|
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
|
||||||
|
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
|
||||||
|
const impactBadges = hasImpact ? (
|
||||||
|
<XStack space="$2" marginTop="$1.5" flexWrap="wrap">
|
||||||
|
{addon.extra_photos ? (
|
||||||
|
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
|
||||||
|
) : null}
|
||||||
|
{addon.extra_guests ? (
|
||||||
|
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
|
||||||
|
) : null}
|
||||||
|
{addon.extra_gallery_days ? (
|
||||||
|
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCard borderColor={border} padding="$3" space="$1.5">
|
||||||
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
|
{addon.label ?? addon.addon_key}
|
||||||
|
</Text>
|
||||||
|
<PillBadge tone={status.tone}>{status.text}</PillBadge>
|
||||||
|
</XStack>
|
||||||
|
{eventName ? (
|
||||||
|
eventPath ? (
|
||||||
|
<Pressable onPress={() => navigate(eventPath)}>
|
||||||
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
|
<Text fontSize="$xs" color={textStrong} fontWeight="600">
|
||||||
|
{eventName}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={primary} fontWeight="700">
|
||||||
|
{t('mobileBilling.openEvent', 'Open event')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="$xs" color={subtle}>
|
||||||
|
{eventName}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{impactBadges}
|
||||||
|
<Text fontSize="$sm" color={text} marginTop="$1.5">
|
||||||
|
{formatAmount(addon.amount, addon.currency)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{formatDate(addon.purchased_at)}
|
||||||
|
</Text>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
|
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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#packages'))}
|
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
|
||||||
/>
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalytic
|
|||||||
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
|
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
|
||||||
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
|
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
|
||||||
const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
|
const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
|
||||||
|
const MobilePackageShopPage = React.lazy(() => import('./mobile/PackageShopPage'));
|
||||||
const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage'));
|
const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage'));
|
||||||
const MobileDataExportsPage = React.lazy(() => import('./mobile/DataExportsPage'));
|
const MobileDataExportsPage = React.lazy(() => import('./mobile/DataExportsPage'));
|
||||||
const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage'));
|
const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage'));
|
||||||
@@ -213,6 +214,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> },
|
{ path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> },
|
||||||
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
|
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
|
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
|
||||||
|
{ path: 'mobile/billing/shop', element: <RequireAdminAccess><MobilePackageShopPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },
|
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/exports', element: <RequireAdminAccess><MobileDataExportsPage /></RequireAdminAccess> },
|
{ path: 'mobile/exports', element: <RequireAdminAccess><MobileDataExportsPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/dashboard', element: <MobileDashboardPage /> },
|
{ path: 'mobile/dashboard', element: <MobileDashboardPage /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user