Files
fotospiel-app/resources/js/admin/mobile/lib/packageShop.ts
Codex Agent b1f9f7cee0
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Fix TypeScript typecheck errors
2026-01-30 15:56:06 +01:00

223 lines
6.2 KiB
TypeScript

import type { Package } from '../../api';
type PackageChange = {
isUpgrade: boolean;
isDowngrade: boolean;
};
export type PackageComparisonRow =
| {
id: string;
type: 'limit';
limitKey: 'max_photos' | 'max_guests' | 'gallery_days' | 'max_events_per_year';
}
| {
id: string;
type: 'value';
valueKey: 'included_package_slug';
}
| {
id: string;
type: 'feature';
featureKey: string;
};
function normalizePackageFeatures(pkg: Package | null): string[] {
if (!pkg?.features) {
return [];
}
if (Array.isArray(pkg.features)) {
return pkg.features.filter((feature): feature is string => typeof feature === 'string' && feature.trim().length > 0);
}
if (typeof pkg.features === 'object') {
return Object.entries(pkg.features)
.filter(([, enabled]) => enabled)
.map(([key]) => key);
}
return [];
}
export function getEnabledPackageFeatures(pkg: Package): string[] {
const features = normalizePackageFeatures(pkg);
const watermarkFeature = resolvePackageWatermarkFeatureKey(pkg, features);
if (watermarkFeature) {
const cleaned = features.filter(
(feature) => !['watermark', 'watermark_allowed', 'no_watermark', 'watermark_base', 'watermark_custom'].includes(feature)
);
cleaned.push(watermarkFeature);
return Array.from(new Set(cleaned));
}
return features;
}
function collectFeatures(pkg: Package | null): Set<string> {
if (!pkg) {
return new Set();
}
return new Set(getEnabledPackageFeatures(pkg));
}
const FEATURE_COMPATIBILITY: Record<string, string[]> = {
limited_sharing: ['limited_sharing', 'unlimited_sharing'],
watermark_base: ['watermark_base', 'watermark_custom', 'no_watermark'],
};
function featureSatisfied(feature: string, candidateFeatures: Set<string>): boolean {
const compatible = FEATURE_COMPATIBILITY[feature];
if (compatible) {
return compatible.some((value) => candidateFeatures.has(value));
}
return candidateFeatures.has(feature);
}
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 };
}
if (pkg.type === 'reseller' || active.type === 'reseller') {
return { isUpgrade: false, isDowngrade: false };
}
const activeFeatures = collectFeatures(active);
const candidateFeatures = collectFeatures(pkg);
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !featureSatisfied(feature, activeFeatures));
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !featureSatisfied(feature, candidateFeatures));
const limitKeys: Array<'max_photos' | 'max_guests' | 'gallery_days'> = ['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 && !hasDowngrade) {
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,
activePackage: Package | null
): number | null {
if (!feature) {
return null;
}
if (packages.some((pkg) => pkg.type === 'reseller')) {
return null;
}
const candidates =
feature === 'watermark_allowed'
? packages.filter((pkg) => pkg.watermark_allowed === true)
: feature?.startsWith('watermark')
? packages.filter((pkg) => resolvePackageWatermarkFeatureKey(pkg, normalizePackageFeatures(pkg)) === feature)
: packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature));
if (candidates.length === 0) {
return null;
}
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;
}
export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] {
const isResellerCatalog = packages.some(
(pkg) => pkg.type === 'reseller' || pkg.max_events_per_year !== undefined || pkg.included_package_slug !== undefined
);
const limitRows: PackageComparisonRow[] = isResellerCatalog
? [
{ id: 'value.included_package_slug', type: 'value', valueKey: 'included_package_slug' },
{ id: 'limit.max_events_per_year', type: 'limit', limitKey: 'max_events_per_year' },
]
: [
{ id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' },
{ id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' },
{ id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' },
];
const featureKeys = new Set<string>();
packages.forEach((pkg) => {
getEnabledPackageFeatures(pkg).forEach((key) => {
if (key !== 'photos') {
featureKeys.add(key);
}
});
});
const featureRows = Array.from(featureKeys)
.sort((a, b) => a.localeCompare(b))
.map((featureKey) => ({
id: `feature.${featureKey}`,
type: 'feature' as const,
featureKey,
}));
return [...limitRows, ...featureRows];
}
function resolvePackageWatermarkFeatureKey(pkg: Package, features: string[]): string | null {
if (pkg.type === 'reseller') {
return null;
}
if (pkg.watermark_allowed === false) {
return 'watermark_base';
}
if (features.includes('no_watermark')) {
return 'no_watermark';
}
if (pkg.watermark_allowed === true) {
return 'watermark_custom';
}
return null;
}