From 5009697f7ba6e708d0a14aeea7f9ba63d989aee9 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 6 Jan 2026 18:04:03 +0100 Subject: [PATCH] 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. --- resources/js/admin/constants.ts | 1 + resources/js/admin/mobile/BillingPage.tsx | 203 +++++++++++++---- .../js/admin/mobile/EventAnalyticsPage.tsx | 2 +- resources/js/admin/mobile/PackageShopPage.tsx | 215 ++++++++++++++++++ resources/js/admin/router.tsx | 2 + 5 files changed, 382 insertions(+), 41 deletions(-) create mode 100644 resources/js/admin/mobile/PackageShopPage.tsx diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index 507dff4..38ba621 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -15,6 +15,7 @@ export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings'); export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile'); export const ADMIN_FAQ_PATH = adminPath('/mobile/help'); 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_PHOTOS_PATH = adminPath('/mobile/uploads'); export const ADMIN_LIVE_PATH = adminPath('/mobile/dashboard'); diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index d91b9cb..53be70a 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -8,13 +8,22 @@ 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 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 { createTenantBillingPortalSession, getTenantPackagesOverview, getTenantPaddleTransactions, TenantPackageSummary, PaddleTransactionSummary, - createTenantPaddleCheckout, } from '../api'; import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; @@ -41,7 +50,6 @@ export default function MobileBillingPage() { const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [portalBusy, setPortalBusy] = React.useState(false); - const [upgradeBusy, setUpgradeBusy] = React.useState(null); const packagesRef = React.useRef(null); const invoicesRef = React.useRef(null); const supportEmail = 'support@fotospiel.de'; @@ -97,26 +105,6 @@ export default function MobileBillingPage() { } }, [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(() => { void load(); }, [load]); @@ -183,12 +171,7 @@ export default function MobileBillingPage() { {packages .filter((pkg) => !activePackage || pkg.id !== activePackage.id) .map((pkg) => ( - handleUpgrade(pkg)} - upgradeBusy={upgradeBusy === pkg.package_id} - /> + ))} )} @@ -292,16 +275,12 @@ function PackageCard({ isActive = false, onOpenPortal, portalBusy, - onUpgrade, - upgradeBusy, }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean; onOpenPortal?: () => void; portalBusy?: boolean; - onUpgrade?: () => void; - upgradeBusy?: boolean; }) { const { t } = useTranslation('management'); const { border, primary, accentSoft, textStrong, muted } = useAdminTheme(); @@ -414,14 +393,6 @@ function PackageCard({ tone={isDanger ? 'danger' : 'primary'} /> ) : null} - {!isActive && onUpgrade ? ( - - ) : null} ); } @@ -577,3 +548,155 @@ function formatDate(value: string | null | undefined): string { if (Number.isNaN(date.getTime())) return '—'; 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 {enabled ? label : `${label} off`}; +} + +function UsageBar({ metric }: { metric: PackageUsageMetric }) { + const { t } = useTranslation('management'); + const { muted, textStrong, border, primary, subtle, warningText, danger } = useAdminTheme(); + const labelMap: Record = { + 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 ( + + + + {labelMap[metric.key]} + + + {statusLabel ? {statusLabel} : null} + + {valueText} + + + + + + + {remainingText ? ( + + {remainingText} + + ) : null} + + ); +} + +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 = { + 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 ? ( + + {addon.extra_photos ? ( + {t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })} + ) : null} + {addon.extra_guests ? ( + {t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })} + ) : null} + {addon.extra_gallery_days ? ( + {t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })} + ) : null} + + ) : null; + + return ( + + + + {addon.label ?? addon.addon_key} + + {status.text} + + {eventName ? ( + eventPath ? ( + navigate(eventPath)}> + + + {eventName} + + + {t('mobileBilling.openEvent', 'Open event')} + + + + ) : ( + + {eventName} + + ) + ) : null} + {impactBadges} + + {formatAmount(addon.amount, addon.currency)} + + + {formatDate(addon.purchased_at)} + + + ); +} +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' }); +} diff --git a/resources/js/admin/mobile/EventAnalyticsPage.tsx b/resources/js/admin/mobile/EventAnalyticsPage.tsx index 9cf61dd..4f52067 100644 --- a/resources/js/admin/mobile/EventAnalyticsPage.tsx +++ b/resources/js/admin/mobile/EventAnalyticsPage.tsx @@ -66,7 +66,7 @@ export default function MobileEventAnalyticsPage() { navigate(adminPath('/mobile/billing#packages'))} + onPress={() => navigate(adminPath('/mobile/billing/shop'))} /> diff --git a/resources/js/admin/mobile/PackageShopPage.tsx b/resources/js/admin/mobile/PackageShopPage.tsx new file mode 100644 index 0000000..0513b98 --- /dev/null +++ b/resources/js/admin/mobile/PackageShopPage.tsx @@ -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(null); + + const { data: packages, isLoading } = useQuery({ + queryKey: ['packages', 'endcustomer'], + queryFn: () => getPackages('endcustomer'), + }); + + if (isLoading) { + return ( + + + + + + + ); + } + + if (selectedPackage) { + return ( + setSelectedPackage(null)} + /> + ); + } + + return ( + + + + + {t('shop.subtitle', 'Choose a package to unlock more features and limits.')} + + + + + {packages?.map((pkg) => ( + setSelectedPackage(pkg)} + /> + ))} + + + + ); +} + +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, but let's be safe + const features = pkg.features; + + return ( + + + + + {pkg.name} + + + {new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)} + + + + + + + {pkg.max_photos ? ( + + ) : ( + + )} + {pkg.gallery_days ? ( + + ) : null} + + + + + ); +} + +function FeatureRow({ label }: { label: string }) { + const { textStrong, primary } = useAdminTheme(); + return ( + + + {label} + + ) +} + +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 ( + + + + {t('shop.confirmSubtitle', 'You are upgrading to:')} + {pkg.name} + + {new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(pkg.price)} + + + + + + + {t('shop.legalTitle', 'Terms & Conditions')} + + + + setAgbAccepted(!!checked)} + > + + + + + setAgbAccepted(!agbAccepted)}> + {t('shop.legal.agb', 'I agree to the Terms and Conditions and Privacy Policy.')} + + + + + setWithdrawalAccepted(!!checked)} + > + + + + + setWithdrawalAccepted(!withdrawalAccepted)}> + {t('shop.legal.withdrawal', 'I agree that the contract execution begins immediately and I lose my right of withdrawal.')} + + + + + + + + + + + ); +} diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index dfdbc2a..dda541a 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -37,6 +37,7 @@ const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalytic const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage')); const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage')); const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage')); +const MobilePackageShopPage = React.lazy(() => import('./mobile/PackageShopPage')); const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage')); const MobileDataExportsPage = React.lazy(() => import('./mobile/DataExportsPage')); const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage')); @@ -213,6 +214,7 @@ export const router = createBrowserRouter([ { path: 'mobile/notifications/:notificationId', element: }, { path: 'mobile/profile', element: }, { path: 'mobile/billing', element: }, + { path: 'mobile/billing/shop', element: }, { path: 'mobile/settings', element: }, { path: 'mobile/exports', element: }, { path: 'mobile/dashboard', element: },