Highlight upgrades in package shop

This commit is contained in:
Codex Agent
2026-01-12 11:38:16 +01:00
parent c08fbf2e45
commit ad336f5e18
5 changed files with 101 additions and 10 deletions

View File

@@ -2929,7 +2929,9 @@
}, },
"badges": { "badges": {
"recommended": "Empfohlen", "recommended": "Empfohlen",
"active": "Aktiv" "active": "Aktiv",
"upgrade": "Upgrade",
"downgrade": "Downgrade"
}, },
"confirmTitle": "Kauf bestätigen", "confirmTitle": "Kauf bestätigen",
"confirmSubtitle": "Du upgradest auf:", "confirmSubtitle": "Du upgradest auf:",

View File

@@ -2933,7 +2933,9 @@
}, },
"badges": { "badges": {
"recommended": "Recommended", "recommended": "Recommended",
"active": "Active" "active": "Active",
"upgrade": "Upgrade",
"downgrade": "Downgrade"
}, },
"confirmTitle": "Confirm Purchase", "confirmTitle": "Confirm Purchase",
"confirmSubtitle": "You are upgrading to:", "confirmSubtitle": "You are upgrading to:",

View File

@@ -13,6 +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';
export default function MobilePackageShopPage() { export default function MobilePackageShopPage() {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -57,16 +58,18 @@ export default function MobilePackageShopPage() {
); );
} }
const activePackageId = inventory?.activePackage?.package_id ?? null;
const activeCatalogPackage = (catalog ?? []).find((pkg) => pkg.id === activePackageId) ?? null;
const activePrice = activeCatalogPackage?.price ?? null;
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) => {
// 1. Recommended feature first if (recommendedPackageId) {
const aHasFeature = recommendedFeature && a.features?.[recommendedFeature]; if (a.id === recommendedPackageId && b.id !== recommendedPackageId) return -1;
const bHasFeature = recommendedFeature && b.features?.[recommendedFeature]; if (b.id === recommendedPackageId && a.id !== recommendedPackageId) return 1;
if (aHasFeature && !bHasFeature) return -1; }
if (!aHasFeature && bHasFeature) return 1;
// 2. Inventory status (Owned packages later if they are fully used, but usually we want to show active stuff)
// Actually, let's keep price sorting as secondary
return a.price - b.price; return a.price - b.price;
}); });
@@ -97,7 +100,8 @@ export default function MobilePackageShopPage() {
{sortedPackages.map((pkg) => { {sortedPackages.map((pkg) => {
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 = recommendedFeature && pkg.features?.[recommendedFeature]; const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false;
const { isUpgrade, isDowngrade } = classifyPackageTier(pkg.price, activePrice);
return ( return (
<PackageShopCard <PackageShopCard
@@ -106,6 +110,8 @@ export default function MobilePackageShopPage() {
owned={owned} owned={owned}
isActive={isActive} isActive={isActive}
isRecommended={isRecommended} isRecommended={isRecommended}
isUpgrade={isUpgrade}
isDowngrade={isDowngrade}
onSelect={() => setSelectedPackage(pkg)} onSelect={() => setSelectedPackage(pkg)}
/> />
); );
@@ -121,12 +127,16 @@ function PackageShopCard({
owned, owned,
isActive, isActive,
isRecommended, isRecommended,
isUpgrade,
isDowngrade,
onSelect onSelect
}: { }: {
pkg: Package; pkg: Package;
owned?: TenantPackageSummary; owned?: TenantPackageSummary;
isActive?: boolean; isActive?: boolean;
isRecommended?: any; isRecommended?: any;
isUpgrade?: boolean;
isDowngrade?: boolean;
onSelect: () => void onSelect: () => void
}) { }) {
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme(); const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
@@ -140,6 +150,7 @@ function PackageShopCard({
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events }) ? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
: t('shop.status.owned', 'Purchased')) : t('shop.status.owned', 'Purchased'))
: null; : null;
const isSubdued = Boolean(isDowngrade && !isActive);
return ( return (
<MobileCard <MobileCard
@@ -149,6 +160,7 @@ function PackageShopCard({
space="$3" space="$3"
pressStyle={{ backgroundColor: accentSoft }} pressStyle={{ backgroundColor: accentSoft }}
backgroundColor={isActive ? '$green1' : undefined} backgroundColor={isActive ? '$green1' : undefined}
style={{ opacity: isSubdued ? 0.6 : 1 }}
> >
<XStack justifyContent="space-between" alignItems="flex-start"> <XStack justifyContent="space-between" alignItems="flex-start">
<YStack space="$1"> <YStack space="$1">
@@ -157,6 +169,8 @@ function PackageShopCard({
{pkg.name} {pkg.name}
</Text> </Text>
{isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>} {isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
{isUpgrade && !isActive ? <PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge> : null}
{isDowngrade && !isActive ? <PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge> : null}
{isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>} {isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>}
</XStack> </XStack>

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { classifyPackageTier, selectRecommendedPackageId } from '../lib/packageShop';
describe('classifyPackageTier', () => {
it('returns neutral when no active price', () => {
expect(classifyPackageTier(100, null)).toEqual({ isUpgrade: false, isDowngrade: false });
});
it('marks upgrades and downgrades by price', () => {
expect(classifyPackageTier(150, 100)).toEqual({ isUpgrade: true, isDowngrade: false });
expect(classifyPackageTier(80, 100)).toEqual({ isUpgrade: false, isDowngrade: true });
});
});
describe('selectRecommendedPackageId', () => {
const packages = [
{ id: 1, price: 100, features: { advanced_analytics: false } },
{ id: 2, price: 150, features: { advanced_analytics: true } },
{ id: 3, price: 200, features: { advanced_analytics: true } },
] as any;
it('returns null when no feature is requested', () => {
expect(selectRecommendedPackageId(packages, null, 100)).toBeNull();
});
it('selects the cheapest upgrade with the feature', () => {
expect(selectRecommendedPackageId(packages, 'advanced_analytics', 120)).toBe(2);
});
it('falls back to cheapest feature package if no upgrades exist', () => {
expect(selectRecommendedPackageId(packages, 'advanced_analytics', 250)).toBe(2);
});
});

View File

@@ -0,0 +1,40 @@
import type { Package } from '../../api';
export function classifyPackageTier(price: number, activePrice: number | null): {
isUpgrade: boolean;
isDowngrade: boolean;
} {
if (activePrice === null) {
return { isUpgrade: false, isDowngrade: false };
}
return {
isUpgrade: price > activePrice,
isDowngrade: price < activePrice,
};
}
export function selectRecommendedPackageId(
packages: Package[],
feature: string | null,
activePrice: number | null
): number | null {
if (!feature) {
return null;
}
const candidates = packages.filter((pkg) => pkg.features?.[feature]);
if (candidates.length === 0) {
return null;
}
const upgradeCandidates =
activePrice === null
? candidates
: candidates.filter((pkg) => pkg.price > activePrice);
const pool = upgradeCandidates.length ? upgradeCandidates : candidates;
const sorted = [...pool].sort((a, b) => a.price - b.price);
return sorted[0]?.id ?? null;
}