diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index d2ed676..4fa1e77 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2944,6 +2944,7 @@ "payNow": "Jetzt zahlen", "errors": { "checkout": "Checkout fehlgeschlagen" - } + }, + "selectDisabled": "Nicht verfügbar" } } diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 980b2f1..79d53fe 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2948,6 +2948,7 @@ "payNow": "Pay Now", "errors": { "checkout": "Checkout failed" - } + }, + "selectDisabled": "Not available" } } diff --git a/resources/js/admin/mobile/PackageShopPage.tsx b/resources/js/admin/mobile/PackageShopPage.tsx index adda3fd..ce7b50b 100644 --- a/resources/js/admin/mobile/PackageShopPage.tsx +++ b/resources/js/admin/mobile/PackageShopPage.tsx @@ -13,7 +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'; +import { classifyPackageChange, selectRecommendedPackageId } from './lib/packageShop'; export default function MobilePackageShopPage() { const { t } = useTranslation('management'); @@ -60,8 +60,7 @@ 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); + const recommendedPackageId = selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage); // Merge and sort packages 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 isActive = inventory?.activePackage?.package_id === pkg.id; const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false; - const { isUpgrade, isDowngrade } = classifyPackageTier(pkg.price, activePrice); + const { isUpgrade, isDowngrade } = classifyPackageChange(pkg, activeCatalogPackage); return ( @@ -211,9 +211,16 @@ function PackageShopCard({ ); diff --git a/resources/js/admin/mobile/__tests__/packageShop.test.ts b/resources/js/admin/mobile/__tests__/packageShop.test.ts index 4003879..1032d82 100644 --- a/resources/js/admin/mobile/__tests__/packageShop.test.ts +++ b/resources/js/admin/mobile/__tests__/packageShop.test.ts @@ -1,14 +1,28 @@ import { describe, expect, it } from 'vitest'; -import { classifyPackageTier, selectRecommendedPackageId } from '../lib/packageShop'; +import { classifyPackageChange, selectRecommendedPackageId } from '../lib/packageShop'; -describe('classifyPackageTier', () => { - it('returns neutral when no active price', () => { - expect(classifyPackageTier(100, null)).toEqual({ isUpgrade: false, isDowngrade: false }); +describe('classifyPackageChange', () => { + const active = { + 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', () => { - expect(classifyPackageTier(150, 100)).toEqual({ isUpgrade: true, isDowngrade: false }); - expect(classifyPackageTier(80, 100)).toEqual({ isUpgrade: false, isDowngrade: true }); + it('marks upgrade when candidate adds features', () => { + const candidate = { ...active, id: 2, price: 150, features: { advanced_analytics: true } } as any; + 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', () => { - 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', () => { - 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); }); }); diff --git a/resources/js/admin/mobile/lib/packageShop.ts b/resources/js/admin/mobile/lib/packageShop.ts index 95d7429..8f231c0 100644 --- a/resources/js/admin/mobile/lib/packageShop.ts +++ b/resources/js/admin/mobile/lib/packageShop.ts @@ -1,23 +1,76 @@ import type { Package } from '../../api'; -export function classifyPackageTier(price: number, activePrice: number | null): { +type PackageChange = { isUpgrade: boolean; isDowngrade: boolean; -} { - if (activePrice === null) { +}; + +function collectFeatures(pkg: Package | null): Set { + 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: price > activePrice, - isDowngrade: price < activePrice, - }; + const activeFeatures = collectFeatures(active); + const candidateFeatures = collectFeatures(pkg); + + const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !activeFeatures.has(feature)); + const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !candidateFeatures.has(feature)); + + const limitKeys: Array = ['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( packages: Package[], feature: string | null, - activePrice: number | null + activePackage: Package | null ): number | null { if (!feature) { return null; @@ -28,12 +81,8 @@ export function selectRecommendedPackageId( return null; } - const upgradeCandidates = - activePrice === null - ? candidates - : candidates.filter((pkg) => pkg.price > activePrice); - - const pool = upgradeCandidates.length ? upgradeCandidates : candidates; + const upgrades = candidates.filter((pkg) => classifyPackageChange(pkg, activePackage).isUpgrade); + const pool = upgrades.length ? upgrades : candidates; const sorted = [...pool].sort((a, b) => a.price - b.price); return sorted[0]?.id ?? null;