Disallow downgrades in package shop
This commit is contained in:
@@ -2944,6 +2944,7 @@
|
|||||||
"payNow": "Jetzt zahlen",
|
"payNow": "Jetzt zahlen",
|
||||||
"errors": {
|
"errors": {
|
||||||
"checkout": "Checkout fehlgeschlagen"
|
"checkout": "Checkout fehlgeschlagen"
|
||||||
}
|
},
|
||||||
|
"selectDisabled": "Nicht verfügbar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2948,6 +2948,7 @@
|
|||||||
"payNow": "Pay Now",
|
"payNow": "Pay Now",
|
||||||
"errors": {
|
"errors": {
|
||||||
"checkout": "Checkout failed"
|
"checkout": "Checkout failed"
|
||||||
}
|
},
|
||||||
|
"selectDisabled": "Not available"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useAdminTheme } from './theme';
|
|||||||
import { getPackages, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
import { getPackages, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
||||||
import { getApiErrorMessage } from '../lib/apiError';
|
import { getApiErrorMessage } from '../lib/apiError';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { classifyPackageTier, selectRecommendedPackageId } from './lib/packageShop';
|
import { classifyPackageChange, selectRecommendedPackageId } from './lib/packageShop';
|
||||||
|
|
||||||
export default function MobilePackageShopPage() {
|
export default function MobilePackageShopPage() {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
@@ -60,8 +60,7 @@ export default function MobilePackageShopPage() {
|
|||||||
|
|
||||||
const activePackageId = inventory?.activePackage?.package_id ?? null;
|
const activePackageId = inventory?.activePackage?.package_id ?? null;
|
||||||
const activeCatalogPackage = (catalog ?? []).find((pkg) => pkg.id === activePackageId) ?? null;
|
const activeCatalogPackage = (catalog ?? []).find((pkg) => pkg.id === activePackageId) ?? null;
|
||||||
const activePrice = activeCatalogPackage?.price ?? null;
|
const recommendedPackageId = selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage);
|
||||||
const recommendedPackageId = selectRecommendedPackageId(catalog ?? [], recommendedFeature, activePrice);
|
|
||||||
|
|
||||||
// Merge and sort packages
|
// Merge and sort packages
|
||||||
const sortedPackages = [...(catalog || [])].sort((a, b) => {
|
const sortedPackages = [...(catalog || [])].sort((a, b) => {
|
||||||
@@ -101,7 +100,7 @@ export default function MobilePackageShopPage() {
|
|||||||
const owned = inventory?.packages?.find(p => p.package_id === pkg.id);
|
const owned = inventory?.packages?.find(p => p.package_id === pkg.id);
|
||||||
const isActive = inventory?.activePackage?.package_id === pkg.id;
|
const isActive = inventory?.activePackage?.package_id === pkg.id;
|
||||||
const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false;
|
const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false;
|
||||||
const { isUpgrade, isDowngrade } = classifyPackageTier(pkg.price, activePrice);
|
const { isUpgrade, isDowngrade } = classifyPackageChange(pkg, activeCatalogPackage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PackageShopCard
|
<PackageShopCard
|
||||||
@@ -151,14 +150,15 @@ function PackageShopCard({
|
|||||||
: t('shop.status.owned', 'Purchased'))
|
: t('shop.status.owned', 'Purchased'))
|
||||||
: null;
|
: null;
|
||||||
const isSubdued = Boolean(isDowngrade && !isActive);
|
const isSubdued = Boolean(isDowngrade && !isActive);
|
||||||
|
const canSelect = !isDowngrade || isActive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
onPress={onSelect}
|
onPress={canSelect ? onSelect : undefined}
|
||||||
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
|
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
|
||||||
borderWidth={isRecommended || isActive ? 2 : 1}
|
borderWidth={isRecommended || isActive ? 2 : 1}
|
||||||
space="$3"
|
space="$3"
|
||||||
pressStyle={{ backgroundColor: accentSoft }}
|
pressStyle={canSelect ? { backgroundColor: accentSoft } : undefined}
|
||||||
backgroundColor={isActive ? '$green1' : undefined}
|
backgroundColor={isActive ? '$green1' : undefined}
|
||||||
style={{ opacity: isSubdued ? 0.6 : 1 }}
|
style={{ opacity: isSubdued ? 0.6 : 1 }}
|
||||||
>
|
>
|
||||||
@@ -211,9 +211,16 @@ function PackageShopCard({
|
|||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={isActive ? t('shop.manage', 'Manage Plan') : t('shop.select', 'Select')}
|
label={
|
||||||
onPress={onSelect}
|
isActive
|
||||||
tone={isActive ? 'ghost' : 'primary'}
|
? t('shop.manage', 'Manage Plan')
|
||||||
|
: isDowngrade
|
||||||
|
? t('shop.selectDisabled', 'Not available')
|
||||||
|
: t('shop.select', 'Select')
|
||||||
|
}
|
||||||
|
onPress={canSelect ? onSelect : undefined}
|
||||||
|
tone={isActive || isDowngrade ? 'ghost' : 'primary'}
|
||||||
|
disabled={!canSelect}
|
||||||
/>
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { classifyPackageTier, selectRecommendedPackageId } from '../lib/packageShop';
|
import { classifyPackageChange, selectRecommendedPackageId } from '../lib/packageShop';
|
||||||
|
|
||||||
describe('classifyPackageTier', () => {
|
describe('classifyPackageChange', () => {
|
||||||
it('returns neutral when no active price', () => {
|
const active = {
|
||||||
expect(classifyPackageTier(100, null)).toEqual({ isUpgrade: false, isDowngrade: false });
|
id: 1,
|
||||||
|
price: 200,
|
||||||
|
max_photos: 100,
|
||||||
|
max_guests: 50,
|
||||||
|
gallery_days: 30,
|
||||||
|
features: { advanced_analytics: false },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
it('returns neutral when no active package', () => {
|
||||||
|
expect(classifyPackageChange(active, null)).toEqual({ isUpgrade: false, isDowngrade: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks upgrades and downgrades by price', () => {
|
it('marks upgrade when candidate adds features', () => {
|
||||||
expect(classifyPackageTier(150, 100)).toEqual({ isUpgrade: true, isDowngrade: false });
|
const candidate = { ...active, id: 2, price: 150, features: { advanced_analytics: true } } as any;
|
||||||
expect(classifyPackageTier(80, 100)).toEqual({ isUpgrade: false, isDowngrade: true });
|
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: true, isDowngrade: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks downgrade when candidate removes features or limits', () => {
|
||||||
|
const candidate = { ...active, id: 3, max_photos: 50, features: { advanced_analytics: false } } as any;
|
||||||
|
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,10 +38,12 @@ describe('selectRecommendedPackageId', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('selects the cheapest upgrade with the feature', () => {
|
it('selects the cheapest upgrade with the feature', () => {
|
||||||
expect(selectRecommendedPackageId(packages, 'advanced_analytics', 120)).toBe(2);
|
const active = { id: 10, price: 120, max_photos: 100, max_guests: 50, gallery_days: 30, features: {} } as any;
|
||||||
|
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to cheapest feature package if no upgrades exist', () => {
|
it('falls back to cheapest feature package if no upgrades exist', () => {
|
||||||
expect(selectRecommendedPackageId(packages, 'advanced_analytics', 250)).toBe(2);
|
const active = { id: 10, price: 250, max_photos: 999, max_guests: 999, gallery_days: 365, features: { advanced_analytics: true } } as any;
|
||||||
|
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,76 @@
|
|||||||
import type { Package } from '../../api';
|
import type { Package } from '../../api';
|
||||||
|
|
||||||
export function classifyPackageTier(price: number, activePrice: number | null): {
|
type PackageChange = {
|
||||||
isUpgrade: boolean;
|
isUpgrade: boolean;
|
||||||
isDowngrade: boolean;
|
isDowngrade: boolean;
|
||||||
} {
|
};
|
||||||
if (activePrice === null) {
|
|
||||||
|
function collectFeatures(pkg: Package | null): Set<string> {
|
||||||
|
if (!pkg?.features) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set(Object.entries(pkg.features).filter(([, enabled]) => enabled).map(([key]) => key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareLimit(candidate: number | null, active: number | null): number {
|
||||||
|
if (active === null) {
|
||||||
|
return candidate === null ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate === null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate > active) return 1;
|
||||||
|
if (candidate < active) return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyPackageChange(pkg: Package, active: Package | null): PackageChange {
|
||||||
|
if (!active) {
|
||||||
return { isUpgrade: false, isDowngrade: false };
|
return { isUpgrade: false, isDowngrade: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const activeFeatures = collectFeatures(active);
|
||||||
isUpgrade: price > activePrice,
|
const candidateFeatures = collectFeatures(pkg);
|
||||||
isDowngrade: price < activePrice,
|
|
||||||
};
|
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !activeFeatures.has(feature));
|
||||||
|
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !candidateFeatures.has(feature));
|
||||||
|
|
||||||
|
const limitKeys: Array<keyof Package> = ['max_photos', 'max_guests', 'gallery_days'];
|
||||||
|
let hasLimitUpgrade = false;
|
||||||
|
let hasLimitDowngrade = false;
|
||||||
|
|
||||||
|
limitKeys.forEach((key) => {
|
||||||
|
const candidateLimit = pkg[key] ?? null;
|
||||||
|
const activeLimit = active[key] ?? null;
|
||||||
|
const delta = compareLimit(candidateLimit, activeLimit);
|
||||||
|
if (delta > 0) {
|
||||||
|
hasLimitUpgrade = true;
|
||||||
|
} else if (delta < 0) {
|
||||||
|
hasLimitDowngrade = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasUpgrade = hasFeatureUpgrade || hasLimitUpgrade;
|
||||||
|
const hasDowngrade = hasFeatureDowngrade || hasLimitDowngrade;
|
||||||
|
|
||||||
|
if (hasUpgrade) {
|
||||||
|
return { isUpgrade: true, isDowngrade: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDowngrade) {
|
||||||
|
return { isUpgrade: false, isDowngrade: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isUpgrade: false, isDowngrade: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectRecommendedPackageId(
|
export function selectRecommendedPackageId(
|
||||||
packages: Package[],
|
packages: Package[],
|
||||||
feature: string | null,
|
feature: string | null,
|
||||||
activePrice: number | null
|
activePackage: Package | null
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
return null;
|
return null;
|
||||||
@@ -28,12 +81,8 @@ export function selectRecommendedPackageId(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const upgradeCandidates =
|
const upgrades = candidates.filter((pkg) => classifyPackageChange(pkg, activePackage).isUpgrade);
|
||||||
activePrice === null
|
const pool = upgrades.length ? upgrades : candidates;
|
||||||
? candidates
|
|
||||||
: candidates.filter((pkg) => pkg.price > activePrice);
|
|
||||||
|
|
||||||
const pool = upgradeCandidates.length ? upgradeCandidates : candidates;
|
|
||||||
const sorted = [...pool].sort((a, b) => a.price - b.price);
|
const sorted = [...pool].sort((a, b) => a.price - b.price);
|
||||||
|
|
||||||
return sorted[0]?.id ?? null;
|
return sorted[0]?.id ?? null;
|
||||||
|
|||||||
Reference in New Issue
Block a user