From ab2cf3e0231d8974000f602a0ca1ad55674cc1db Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 12 Jan 2026 11:38:16 +0100 Subject: [PATCH] Highlight upgrades in package shop --- .../js/admin/i18n/locales/de/management.json | 4 +- .../js/admin/i18n/locales/en/management.json | 4 +- resources/js/admin/mobile/PackageShopPage.tsx | 30 ++++++++++---- .../mobile/__tests__/packageShop.test.ts | 33 +++++++++++++++ resources/js/admin/mobile/lib/packageShop.ts | 40 +++++++++++++++++++ 5 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 resources/js/admin/mobile/__tests__/packageShop.test.ts create mode 100644 resources/js/admin/mobile/lib/packageShop.ts diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index aaa3d8e..d2ed676 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2929,7 +2929,9 @@ }, "badges": { "recommended": "Empfohlen", - "active": "Aktiv" + "active": "Aktiv", + "upgrade": "Upgrade", + "downgrade": "Downgrade" }, "confirmTitle": "Kauf bestätigen", "confirmSubtitle": "Du upgradest auf:", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index cb7932e..980b2f1 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2933,7 +2933,9 @@ }, "badges": { "recommended": "Recommended", - "active": "Active" + "active": "Active", + "upgrade": "Upgrade", + "downgrade": "Downgrade" }, "confirmTitle": "Confirm Purchase", "confirmSubtitle": "You are upgrading to:", diff --git a/resources/js/admin/mobile/PackageShopPage.tsx b/resources/js/admin/mobile/PackageShopPage.tsx index 03d1fe1..adda3fd 100644 --- a/resources/js/admin/mobile/PackageShopPage.tsx +++ b/resources/js/admin/mobile/PackageShopPage.tsx @@ -13,6 +13,7 @@ import { useAdminTheme } from './theme'; import { getPackages, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; import { useQuery } from '@tanstack/react-query'; +import { classifyPackageTier, selectRecommendedPackageId } from './lib/packageShop'; export default function MobilePackageShopPage() { 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 const sortedPackages = [...(catalog || [])].sort((a, b) => { - // 1. Recommended feature first - const aHasFeature = recommendedFeature && a.features?.[recommendedFeature]; - const bHasFeature = recommendedFeature && b.features?.[recommendedFeature]; - if (aHasFeature && !bHasFeature) return -1; - if (!aHasFeature && bHasFeature) return 1; + if (recommendedPackageId) { + if (a.id === recommendedPackageId && b.id !== recommendedPackageId) return -1; + if (b.id === recommendedPackageId && a.id !== recommendedPackageId) 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; }); @@ -97,7 +100,8 @@ 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 = recommendedFeature && pkg.features?.[recommendedFeature]; + const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false; + const { isUpgrade, isDowngrade } = classifyPackageTier(pkg.price, activePrice); return ( setSelectedPackage(pkg)} /> ); @@ -121,12 +127,16 @@ function PackageShopCard({ owned, isActive, isRecommended, + isUpgrade, + isDowngrade, onSelect }: { pkg: Package; owned?: TenantPackageSummary; isActive?: boolean; isRecommended?: any; + isUpgrade?: boolean; + isDowngrade?: boolean; onSelect: () => void }) { 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.owned', 'Purchased')) : null; + const isSubdued = Boolean(isDowngrade && !isActive); return ( @@ -157,6 +169,8 @@ function PackageShopCard({ {pkg.name} {isRecommended && {t('shop.badges.recommended', 'Recommended')}} + {isUpgrade && !isActive ? {t('shop.badges.upgrade', 'Upgrade')} : null} + {isDowngrade && !isActive ? {t('shop.badges.downgrade', 'Downgrade')} : null} {isActive && {t('shop.badges.active', 'Active')}} diff --git a/resources/js/admin/mobile/__tests__/packageShop.test.ts b/resources/js/admin/mobile/__tests__/packageShop.test.ts new file mode 100644 index 0000000..4003879 --- /dev/null +++ b/resources/js/admin/mobile/__tests__/packageShop.test.ts @@ -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); + }); +}); diff --git a/resources/js/admin/mobile/lib/packageShop.ts b/resources/js/admin/mobile/lib/packageShop.ts new file mode 100644 index 0000000..95d7429 --- /dev/null +++ b/resources/js/admin/mobile/lib/packageShop.ts @@ -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; +}