diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index f08f9ca..b960656 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2911,6 +2911,26 @@ "subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.", "recommendationTitle": "Empfohlen für dich", "recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.", + "compare": { + "title": "Pakete vergleichen", + "helper": "Wische, um Pakete nebeneinander zu vergleichen.", + "toggleCards": "Karten", + "toggleCompare": "Vergleichen", + "headers": { + "plan": "Paket", + "price": "Preis" + }, + "rows": { + "photos": "Fotos", + "guests": "Gäste", + "days": "Galerietage" + }, + "values": { + "included": "Enthalten", + "notIncluded": "Nicht enthalten", + "unlimited": "Unbegrenzt" + } + }, "select": "Auswählen", "manage": "Paket verwalten", "limits": { diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index aad3549..d279da7 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2915,6 +2915,26 @@ "subtitle": "Choose a package to unlock more features and limits.", "recommendationTitle": "Recommended for you", "recommendationBody": "The highlighted package includes the feature you requested.", + "compare": { + "title": "Compare plans", + "helper": "Swipe to compare packages side by side.", + "toggleCards": "Cards", + "toggleCompare": "Compare", + "headers": { + "plan": "Plan", + "price": "Price" + }, + "rows": { + "photos": "Photos", + "guests": "Guests", + "days": "Gallery days" + }, + "values": { + "included": "Included", + "notIncluded": "Not included", + "unlimited": "Unlimited" + } + }, "select": "Select", "manage": "Manage Plan", "limits": { diff --git a/resources/js/admin/mobile/PackageShopPage.tsx b/resources/js/admin/mobile/PackageShopPage.tsx index 9d38935..c966e4c 100644 --- a/resources/js/admin/mobile/PackageShopPage.tsx +++ b/resources/js/admin/mobile/PackageShopPage.tsx @@ -1,26 +1,26 @@ 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 { Check, ChevronRight, ShieldCheck, Sparkles, X } 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 { getPackages, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api'; import { useQuery } from '@tanstack/react-query'; -import { classifyPackageChange, selectRecommendedPackageId } from './lib/packageShop'; +import { buildPackageComparisonRows, classifyPackageChange, selectRecommendedPackageId } from './lib/packageShop'; +import { usePackageCheckout } from './hooks/usePackageCheckout'; export default function MobilePackageShopPage() { const { t } = useTranslation('management'); const navigate = useNavigate(); const location = useLocation(); - const { textStrong, muted, border, primary, surface, accentSoft, warningText } = useAdminTheme(); + const { textStrong, muted, border, primary, accentSoft } = useAdminTheme(); const [selectedPackage, setSelectedPackage] = React.useState(null); + const [viewMode, setViewMode] = React.useState<'cards' | 'compare'>('cards'); // Extract recommended feature from URL const searchParams = new URLSearchParams(location.search); @@ -72,6 +72,22 @@ export default function MobilePackageShopPage() { return a.price - b.price; }); + const packageEntries = sortedPackages.map((pkg) => { + const owned = inventory?.packages?.find((entry) => entry.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 { + pkg, + owned, + isActive, + isRecommended, + isUpgrade, + isDowngrade, + }; + }); + return ( navigate(-1)} activeTab="profile"> @@ -95,26 +111,45 @@ export default function MobilePackageShopPage() { - - {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); + {packageEntries.length > 1 ? ( + + setViewMode('cards')} + style={{ flex: 1 }} + /> + setViewMode('compare')} + style={{ flex: 1 }} + /> + + ) : null} - return ( + + {viewMode === 'compare' ? ( + setSelectedPackage(pkg)} + /> + ) : ( + packageEntries.map((entry) => ( setSelectedPackage(pkg)} + key={entry.pkg.id} + pkg={entry.pkg} + owned={entry.owned} + isActive={entry.isActive} + isRecommended={entry.isRecommended} + isUpgrade={entry.isUpgrade} + isDowngrade={entry.isDowngrade} + onSelect={() => setSelectedPackage(entry.pkg)} /> - ); - })} + )) + )} @@ -141,16 +176,9 @@ function PackageShopCard({ 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 statusLabel = getPackageStatusLabel({ t, isActive, owned }); const isSubdued = Boolean(isDowngrade && !isActive); - const canSelect = !isDowngrade || isActive; + const canSelect = canSelectPackage(isDowngrade, isActive); return ( void; +}) { + const { t } = useTranslation('management'); + const { textStrong, muted, border, primary, accentSoft } = useAdminTheme(); + const comparisonRows = buildPackageComparisonRows(entries.map((entry) => entry.pkg)); + const labelWidth = 140; + const columnWidth = 150; + + const rows = [ + { id: 'meta.plan', type: 'meta' as const, label: t('shop.compare.headers.plan', 'Plan') }, + { id: 'meta.price', type: 'meta' as const, label: t('shop.compare.headers.price', 'Price') }, + ...comparisonRows, + ]; + + const renderRowLabel = (row: typeof rows[number]) => { + if (row.type === 'meta') { + return row.label; + } + + if (row.type === 'limit') { + if (row.limitKey === 'max_photos') { + return t('shop.compare.rows.photos', 'Photos'); + } + if (row.limitKey === 'max_guests') { + return t('shop.compare.rows.guests', 'Guests'); + } + return t('shop.compare.rows.days', 'Gallery days'); + } + + return t(`shop.features.${row.featureKey}`, row.featureKey); + }; + + const formatLimitValue = (value: number | null) => { + if (value === null) { + return t('shop.compare.values.unlimited', 'Unlimited'); + } + return new Intl.NumberFormat().format(value); + }; + + return ( + + + + {t('shop.compare.title', 'Compare plans')} + + + {t('shop.compare.helper', 'Swipe to compare packages side by side.')} + + + + + + {rows.map((row) => ( + + + + {renderRowLabel(row)} + + + {entries.map((entry) => { + const cellBackground = entry.isRecommended ? accentSoft : entry.isActive ? '$green1' : undefined; + let content: React.ReactNode = null; + + if (row.type === 'meta') { + if (row.id === 'meta.plan') { + const statusLabel = getPackageStatusLabel({ t, isActive: entry.isActive, owned: entry.owned }); + content = ( + + + {entry.pkg.name} + + + {entry.isRecommended ? ( + {t('shop.badges.recommended', 'Recommended')} + ) : null} + {entry.isUpgrade && !entry.isActive ? ( + {t('shop.badges.upgrade', 'Upgrade')} + ) : null} + {entry.isDowngrade && !entry.isActive ? ( + {t('shop.badges.downgrade', 'Downgrade')} + ) : null} + {entry.isActive ? {t('shop.badges.active', 'Active')} : null} + + {statusLabel ? ( + + {statusLabel} + + ) : null} + + ); + } else if (row.id === 'meta.price') { + content = ( + + {new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(entry.pkg.price)} + + ); + } + } else if (row.type === 'limit') { + const value = entry.pkg[row.limitKey] ?? null; + content = ( + + {formatLimitValue(value)} + + ); + } else if (row.type === 'feature') { + const enabled = Boolean(entry.pkg.features?.[row.featureKey]); + content = ( + + {enabled ? ( + + ) : ( + + )} + + {enabled ? t('shop.compare.values.included', 'Included') : t('shop.compare.values.notIncluded', 'Not included')} + + + ); + } + + return ( + + {content} + + ); + })} + + ))} + + + {entries.map((entry) => { + const canSelect = canSelectPackage(entry.isDowngrade, entry.isActive); + const label = entry.isActive + ? t('shop.manage', 'Manage Plan') + : entry.isDowngrade + ? t('shop.selectDisabled', 'Not available') + : t('shop.select', 'Select'); + + return ( + + onSelect(entry.pkg) : undefined} + disabled={!canSelect} + tone={entry.isActive || entry.isDowngrade ? 'ghost' : 'primary'} + /> + + ); + })} + + + + + ); +} + +function getPackageStatusLabel({ + t, + isActive, + owned, +}: { + t: (key: string, fallback?: string, options?: Record) => string; + isActive?: boolean; + owned?: TenantPackageSummary; +}): string | null { + if (isActive) { + return t('shop.status.active', 'Active Plan'); + } + if (owned) { + return owned.remaining_events !== null + ? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events }) + : t('shop.status.owned', 'Purchased'); + } + return null; +} + +function canSelectPackage(isDowngrade?: boolean, isActive?: boolean): boolean { + return !isDowngrade || Boolean(isActive); +} + function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) { const { t } = useTranslation('management'); - const { textStrong, muted, border, primary, danger } = useAdminTheme(); + const { textStrong, muted, border, primary } = useAdminTheme(); const [agbAccepted, setAgbAccepted] = React.useState(false); const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false); - const [busy, setBusy] = React.useState(false); + const { busy, startCheckout } = usePackageCheckout(); 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); - } + await startCheckout(pkg.id); }; return ( diff --git a/resources/js/admin/mobile/__tests__/packageShop.test.ts b/resources/js/admin/mobile/__tests__/packageShop.test.ts index 1032d82..b1bcf69 100644 --- a/resources/js/admin/mobile/__tests__/packageShop.test.ts +++ b/resources/js/admin/mobile/__tests__/packageShop.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { classifyPackageChange, selectRecommendedPackageId } from '../lib/packageShop'; +import { buildPackageComparisonRows, classifyPackageChange, selectRecommendedPackageId } from '../lib/packageShop'; describe('classifyPackageChange', () => { const active = { @@ -47,3 +47,21 @@ describe('selectRecommendedPackageId', () => { expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2); }); }); + +describe('buildPackageComparisonRows', () => { + it('includes limit rows and enabled feature rows', () => { + const rows = buildPackageComparisonRows([ + { features: { advanced_analytics: true, custom_branding: false } }, + { features: { custom_branding: true, watermark_removal: true } }, + ] as any); + + expect(rows.map((row) => row.id)).toEqual([ + 'limit.max_photos', + 'limit.max_guests', + 'limit.gallery_days', + 'feature.advanced_analytics', + 'feature.custom_branding', + 'feature.watermark_removal', + ]); + }); +}); diff --git a/resources/js/admin/mobile/hooks/usePackageCheckout.ts b/resources/js/admin/mobile/hooks/usePackageCheckout.ts new file mode 100644 index 0000000..75e0aa2 --- /dev/null +++ b/resources/js/admin/mobile/hooks/usePackageCheckout.ts @@ -0,0 +1,49 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import toast from 'react-hot-toast'; + +import { createTenantPaddleCheckout } from '../../api'; +import { adminPath } from '../../constants'; +import { getApiErrorMessage } from '../../lib/apiError'; + +export function usePackageCheckout(): { + busy: boolean; + startCheckout: (packageId: number) => Promise; +} { + const { t } = useTranslation('management'); + const [busy, setBusy] = React.useState(false); + + const startCheckout = React.useCallback( + async (packageId: number) => { + if (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(packageId)); + const cancelUrl = new URL(billingUrl); + cancelUrl.searchParams.set('checkout', 'cancel'); + cancelUrl.searchParams.set('package_id', String(packageId)); + + const { checkout_url } = await createTenantPaddleCheckout(packageId, { + 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); + } + }, + [busy, t], + ); + + return { busy, startCheckout }; +} diff --git a/resources/js/admin/mobile/lib/packageShop.ts b/resources/js/admin/mobile/lib/packageShop.ts index 8f231c0..fe5e643 100644 --- a/resources/js/admin/mobile/lib/packageShop.ts +++ b/resources/js/admin/mobile/lib/packageShop.ts @@ -5,6 +5,18 @@ type PackageChange = { isDowngrade: boolean; }; +export type PackageComparisonRow = + | { + id: string; + type: 'limit'; + limitKey: 'max_photos' | 'max_guests' | 'gallery_days'; + } + | { + id: string; + type: 'feature'; + featureKey: string; + }; + function collectFeatures(pkg: Package | null): Set { if (!pkg?.features) { return new Set(); @@ -87,3 +99,30 @@ export function selectRecommendedPackageId( return sorted[0]?.id ?? null; } + +export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] { + const limitRows: PackageComparisonRow[] = [ + { id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' }, + { id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' }, + { id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' }, + ]; + + const featureKeys = new Set(); + packages.forEach((pkg) => { + Object.entries(pkg.features ?? {}).forEach(([key, enabled]) => { + if (enabled && key !== 'photos') { + featureKeys.add(key); + } + }); + }); + + const featureRows = Array.from(featureKeys) + .sort((a, b) => a.localeCompare(b)) + .map((featureKey) => ({ + id: `feature.${featureKey}`, + type: 'feature' as const, + featureKey, + })); + + return [...limitRows, ...featureRows]; +}