feat: add package comparison view
This commit is contained in:
@@ -2911,6 +2911,26 @@
|
|||||||
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.",
|
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.",
|
||||||
"recommendationTitle": "Empfohlen für dich",
|
"recommendationTitle": "Empfohlen für dich",
|
||||||
"recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.",
|
"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",
|
"select": "Auswählen",
|
||||||
"manage": "Paket verwalten",
|
"manage": "Paket verwalten",
|
||||||
"limits": {
|
"limits": {
|
||||||
|
|||||||
@@ -2915,6 +2915,26 @@
|
|||||||
"subtitle": "Choose a package to unlock more features and limits.",
|
"subtitle": "Choose a package to unlock more features and limits.",
|
||||||
"recommendationTitle": "Recommended for you",
|
"recommendationTitle": "Recommended for you",
|
||||||
"recommendationBody": "The highlighted package includes the feature you requested.",
|
"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",
|
"select": "Select",
|
||||||
"manage": "Manage Plan",
|
"manage": "Manage Plan",
|
||||||
"limits": {
|
"limits": {
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Checkbox } from '@tamagui/checkbox';
|
import { Checkbox } from '@tamagui/checkbox';
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
import { MobileShell } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
import { getPackages, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
import { getPackages, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
||||||
import { getApiErrorMessage } from '../lib/apiError';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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() {
|
export default function MobilePackageShopPage() {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { textStrong, muted, border, primary, surface, accentSoft, warningText } = useAdminTheme();
|
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||||
const [selectedPackage, setSelectedPackage] = React.useState<Package | null>(null);
|
const [selectedPackage, setSelectedPackage] = React.useState<Package | null>(null);
|
||||||
|
const [viewMode, setViewMode] = React.useState<'cards' | 'compare'>('cards');
|
||||||
|
|
||||||
// Extract recommended feature from URL
|
// Extract recommended feature from URL
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
@@ -72,6 +72,22 @@ export default function MobilePackageShopPage() {
|
|||||||
return a.price - b.price;
|
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 (
|
return (
|
||||||
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
|
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
|
||||||
<YStack space="$4">
|
<YStack space="$4">
|
||||||
@@ -95,26 +111,45 @@ export default function MobilePackageShopPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<YStack space="$3">
|
{packageEntries.length > 1 ? (
|
||||||
{sortedPackages.map((pkg) => {
|
<XStack space="$2" paddingHorizontal="$2">
|
||||||
const owned = inventory?.packages?.find(p => p.package_id === pkg.id);
|
<CTAButton
|
||||||
const isActive = inventory?.activePackage?.package_id === pkg.id;
|
label={t('shop.compare.toggleCards', 'Cards')}
|
||||||
const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false;
|
tone={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
const { isUpgrade, isDowngrade } = classifyPackageChange(pkg, activeCatalogPackage);
|
fullWidth={false}
|
||||||
|
onPress={() => setViewMode('cards')}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<CTAButton
|
||||||
|
label={t('shop.compare.toggleCompare', 'Compare')}
|
||||||
|
tone={viewMode === 'compare' ? 'primary' : 'ghost'}
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() => setViewMode('compare')}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
return (
|
<YStack space="$3">
|
||||||
|
{viewMode === 'compare' ? (
|
||||||
|
<PackageShopCompareView
|
||||||
|
entries={packageEntries}
|
||||||
|
onSelect={(pkg) => setSelectedPackage(pkg)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
packageEntries.map((entry) => (
|
||||||
<PackageShopCard
|
<PackageShopCard
|
||||||
key={pkg.id}
|
key={entry.pkg.id}
|
||||||
pkg={pkg}
|
pkg={entry.pkg}
|
||||||
owned={owned}
|
owned={entry.owned}
|
||||||
isActive={isActive}
|
isActive={entry.isActive}
|
||||||
isRecommended={isRecommended}
|
isRecommended={entry.isRecommended}
|
||||||
isUpgrade={isUpgrade}
|
isUpgrade={entry.isUpgrade}
|
||||||
isDowngrade={isDowngrade}
|
isDowngrade={entry.isDowngrade}
|
||||||
onSelect={() => setSelectedPackage(pkg)}
|
onSelect={() => setSelectedPackage(entry.pkg)}
|
||||||
/>
|
/>
|
||||||
);
|
))
|
||||||
})}
|
)}
|
||||||
</YStack>
|
</YStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
@@ -141,16 +176,9 @@ function PackageShopCard({
|
|||||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
|
||||||
const hasRemainingEvents = owned && (owned.remaining_events === null || owned.remaining_events > 0);
|
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
|
||||||
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 isSubdued = Boolean(isDowngrade && !isActive);
|
const isSubdued = Boolean(isDowngrade && !isActive);
|
||||||
const canSelect = !isDowngrade || isActive;
|
const canSelect = canSelectPackage(isDowngrade, isActive);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
@@ -236,40 +264,224 @@ function FeatureRow({ label }: { label: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PackageEntry = {
|
||||||
|
pkg: Package;
|
||||||
|
owned?: TenantPackageSummary;
|
||||||
|
isActive: boolean;
|
||||||
|
isRecommended: boolean;
|
||||||
|
isUpgrade: boolean;
|
||||||
|
isDowngrade: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PackageShopCompareView({
|
||||||
|
entries,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
entries: PackageEntry[];
|
||||||
|
onSelect: (pkg: Package) => 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 (
|
||||||
|
<MobileCard space="$3" borderColor={border}>
|
||||||
|
<YStack space="$1">
|
||||||
|
<Text fontSize="$md" fontWeight="700" color={textStrong}>
|
||||||
|
{t('shop.compare.title', 'Compare plans')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t('shop.compare.helper', 'Swipe to compare packages side by side.')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<XStack style={{ overflowX: 'auto' }}>
|
||||||
|
<YStack space="$1.5" minWidth={labelWidth + columnWidth * entries.length}>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<XStack key={row.id} borderBottomWidth={1} borderColor={border}>
|
||||||
|
<YStack
|
||||||
|
width={labelWidth}
|
||||||
|
paddingVertical="$2"
|
||||||
|
paddingRight="$3"
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||||
|
{renderRowLabel(row)}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
{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 = (
|
||||||
|
<YStack space="$1">
|
||||||
|
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||||
|
{entry.pkg.name}
|
||||||
|
</Text>
|
||||||
|
<XStack space="$1.5" flexWrap="wrap">
|
||||||
|
{entry.isRecommended ? (
|
||||||
|
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
|
||||||
|
) : null}
|
||||||
|
{entry.isUpgrade && !entry.isActive ? (
|
||||||
|
<PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
|
||||||
|
) : null}
|
||||||
|
{entry.isDowngrade && !entry.isActive ? (
|
||||||
|
<PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge>
|
||||||
|
) : null}
|
||||||
|
{entry.isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
|
||||||
|
</XStack>
|
||||||
|
{statusLabel ? (
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{statusLabel}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
} else if (row.id === 'meta.price') {
|
||||||
|
content = (
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={primary}>
|
||||||
|
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(entry.pkg.price)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (row.type === 'limit') {
|
||||||
|
const value = entry.pkg[row.limitKey] ?? null;
|
||||||
|
content = (
|
||||||
|
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
|
||||||
|
{formatLimitValue(value)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
} else if (row.type === 'feature') {
|
||||||
|
const enabled = Boolean(entry.pkg.features?.[row.featureKey]);
|
||||||
|
content = (
|
||||||
|
<XStack alignItems="center" space="$1.5">
|
||||||
|
{enabled ? (
|
||||||
|
<Check size={16} color={primary} />
|
||||||
|
) : (
|
||||||
|
<X size={14} color={muted} />
|
||||||
|
)}
|
||||||
|
<Text fontSize="$sm" color={enabled ? textStrong : muted}>
|
||||||
|
{enabled ? t('shop.compare.values.included', 'Included') : t('shop.compare.values.notIncluded', 'Not included')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
key={`${row.id}-${entry.pkg.id}`}
|
||||||
|
width={columnWidth}
|
||||||
|
paddingVertical="$2"
|
||||||
|
paddingHorizontal="$2"
|
||||||
|
justifyContent="center"
|
||||||
|
backgroundColor={cellBackground}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</XStack>
|
||||||
|
))}
|
||||||
|
<XStack paddingTop="$2">
|
||||||
|
<YStack width={labelWidth} />
|
||||||
|
{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 (
|
||||||
|
<YStack key={`cta-${entry.pkg.id}`} width={columnWidth} paddingHorizontal="$2">
|
||||||
|
<CTAButton
|
||||||
|
label={label}
|
||||||
|
onPress={canSelect ? () => onSelect(entry.pkg) : undefined}
|
||||||
|
disabled={!canSelect}
|
||||||
|
tone={entry.isActive || entry.isDowngrade ? 'ghost' : 'primary'}
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
|
</XStack>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPackageStatusLabel({
|
||||||
|
t,
|
||||||
|
isActive,
|
||||||
|
owned,
|
||||||
|
}: {
|
||||||
|
t: (key: string, fallback?: string, options?: Record<string, unknown>) => 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 }) {
|
function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { textStrong, muted, border, primary, danger } = useAdminTheme();
|
const { textStrong, muted, border, primary } = useAdminTheme();
|
||||||
const [agbAccepted, setAgbAccepted] = React.useState(false);
|
const [agbAccepted, setAgbAccepted] = React.useState(false);
|
||||||
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
|
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
|
||||||
const [busy, setBusy] = React.useState(false);
|
const { busy, startCheckout } = usePackageCheckout();
|
||||||
|
|
||||||
const canProceed = agbAccepted && withdrawalAccepted;
|
const canProceed = agbAccepted && withdrawalAccepted;
|
||||||
|
|
||||||
const handleCheckout = async () => {
|
const handleCheckout = async () => {
|
||||||
if (!canProceed || busy) return;
|
if (!canProceed || busy) return;
|
||||||
setBusy(true);
|
await startCheckout(pkg.id);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { classifyPackageChange, selectRecommendedPackageId } from '../lib/packageShop';
|
import { buildPackageComparisonRows, classifyPackageChange, selectRecommendedPackageId } from '../lib/packageShop';
|
||||||
|
|
||||||
describe('classifyPackageChange', () => {
|
describe('classifyPackageChange', () => {
|
||||||
const active = {
|
const active = {
|
||||||
@@ -47,3 +47,21 @@ describe('selectRecommendedPackageId', () => {
|
|||||||
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
49
resources/js/admin/mobile/hooks/usePackageCheckout.ts
Normal file
49
resources/js/admin/mobile/hooks/usePackageCheckout.ts
Normal file
@@ -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<void>;
|
||||||
|
} {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -5,6 +5,18 @@ type PackageChange = {
|
|||||||
isDowngrade: boolean;
|
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<string> {
|
function collectFeatures(pkg: Package | null): Set<string> {
|
||||||
if (!pkg?.features) {
|
if (!pkg?.features) {
|
||||||
return new Set();
|
return new Set();
|
||||||
@@ -87,3 +99,30 @@ export function selectRecommendedPackageId(
|
|||||||
|
|
||||||
return sorted[0]?.id ?? null;
|
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<string>();
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user