Highlight upgrades in package shop
This commit is contained in:
@@ -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:",
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
33
resources/js/admin/mobile/__tests__/packageShop.test.ts
Normal file
33
resources/js/admin/mobile/__tests__/packageShop.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
resources/js/admin/mobile/lib/packageShop.ts
Normal file
40
resources/js/admin/mobile/lib/packageShop.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user