diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 0d0a89c..4a6baf9 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2068,6 +2068,7 @@ "limitPhotos": "Fotos", "limitGuests": "Gäste", "limitDays": "Galerietage", + "limitsTitle": "Limits", "remaining": "Verbleibende Events", "purchased": "Gekauft", "expires": "Läuft ab", @@ -2076,8 +2077,15 @@ "featuresTitle": "Enthaltene Features", "feature": { "priority_support": "Priority Support", + "reseller_dashboard": "Reseller-Dashboard", "custom_domain": "Eigene Domain", + "custom_branding": "Benutzerdefiniertes Branding", + "custom_tasks": "Individuelle Aufgaben", + "unlimited_sharing": "Unbegrenztes Sharing", "analytics": "Analytics", + "advanced_reporting": "Erweitertes Reporting", + "live_slideshow": "Live-Slideshow", + "basic_uploads": "Gäste-Uploads", "team_management": "Team-Management", "moderation_tools": "Moderations-Tools", "prints": "Print-Uploads", @@ -2697,7 +2705,12 @@ "mobileBilling": { "packageFallback": "Paket", "remainingEvents": "{{count}} Events", + "eventsCreated": "{{used}} von {{limit}} Events angelegt", "openEvent": "Event öffnen", + "details": { + "limitsTitle": "Limits", + "featuresTitle": "Features" + }, "usage": { "events": "Events", "guests": "Gäste", @@ -2717,6 +2730,13 @@ "days": "+{{count}} Tage" } }, + "packageLimits": { + "max_photos": "Fotos", + "max_guests": "Gäste", + "max_tasks": "Aufgaben", + "gallery_days": "Galerietage", + "max_events_per_year": "Events pro Jahr" + }, "mobileEvents": { "edit": "Event bearbeiten" }, diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index a80216a..5c9ae59 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2072,6 +2072,7 @@ "limitPhotos": "Photos", "limitGuests": "Guests", "limitDays": "Gallery days", + "limitsTitle": "Limits", "remaining": "Remaining events", "purchased": "Purchased", "expires": "Expires", @@ -2080,8 +2081,15 @@ "featuresTitle": "Included features", "feature": { "priority_support": "Priority support", + "reseller_dashboard": "Reseller dashboard", "custom_domain": "Custom domain", + "custom_branding": "Custom branding", + "custom_tasks": "Custom tasks", + "unlimited_sharing": "Unlimited sharing", "analytics": "Analytics", + "advanced_reporting": "Advanced reporting", + "live_slideshow": "Live slideshow", + "basic_uploads": "Guest uploads", "team_management": "Team management", "moderation_tools": "Moderation tools", "prints": "Print uploads", @@ -2701,7 +2709,12 @@ "mobileBilling": { "packageFallback": "Package", "remainingEvents": "{{count}} events", + "eventsCreated": "{{used}} of {{limit}} events created", "openEvent": "Open event", + "details": { + "limitsTitle": "Limits", + "featuresTitle": "Features" + }, "usage": { "events": "Events", "guests": "Guests", @@ -2721,6 +2734,13 @@ "days": "+{{count}} days" } }, + "packageLimits": { + "max_photos": "Photos", + "max_guests": "Guests", + "max_tasks": "Tasks", + "gallery_days": "Gallery days", + "max_events_per_year": "Events per year" + }, "mobileEvents": { "edit": "Edit event" }, diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index c8ebfb8..9c59d8a 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -21,6 +21,12 @@ import { ADMIN_EVENT_VIEW_PATH, adminPath } from '../constants'; import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; +import { + collectPackageFeatures, + formatEventUsage, + getPackageFeatureLabel, + getPackageLimitEntries, +} from './lib/packageSummary'; export default function MobileBillingPage() { const { t } = useTranslation('management'); @@ -254,9 +260,17 @@ export default function MobileBillingPage() { function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean }) { const { t } = useTranslation('management'); const { border, primary, accentSoft, textStrong, muted } = useAdminTheme(); - const remaining = pkg.remaining_events ?? (pkg.package_limits?.max_events_per_year as number | undefined) ?? 0; + const limits = (pkg.package_limits ?? null) as Record | null; + const remaining = pkg.remaining_events ?? (limits?.max_events_per_year as number | undefined) ?? 0; const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null; const usageMetrics = buildPackageUsageMetrics(pkg); + const limitEntries = getPackageLimitEntries(limits, t); + const featureKeys = collectPackageFeatures(pkg); + const eventUsageText = formatEventUsage( + typeof pkg.used_events === 'number' ? pkg.used_events : null, + typeof limits?.max_events_per_year === 'number' ? (limits?.max_events_per_year as number) : null, + t + ); return ( + {eventUsageText ? ( + + {eventUsageText} + + ) : null} + {limitEntries.length ? ( + + + {t('mobileBilling.details.limitsTitle', 'Limits')} + + {limitEntries.map((entry) => ( + + + {entry.label} + + + {entry.value} + + + ))} + + ) : null} + {featureKeys.length ? ( + + + {t('mobileBilling.details.featuresTitle', 'Features')} + + {featureKeys.map((feature) => ( + + + + {getPackageFeatureLabel(feature, t)} + + + ))} + + ) : null} {usageMetrics.length ? ( {usageMetrics.map((metric) => ( diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 38c6553..ff258cb 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -17,7 +17,7 @@ import { useAdminPushSubscription } from './hooks/useAdminPushSubscription'; import { useDevicePermissions } from './hooks/useDevicePermissions'; import { useInstallPrompt } from './hooks/useInstallPrompt'; import { getTourSeen, resolveTourStepKeys, setTourSeen, type TourStepKey } from './lib/mobileTour'; -import { formatPackageLimit, getPackageFeatureLabel } from './lib/packageSummary'; +import { collectPackageFeatures, formatPackageLimit, getPackageFeatureLabel, getPackageLimitEntries } from './lib/packageSummary'; import { trackOnboarding } from '../api'; import { useAuth } from '../auth/context'; import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme'; @@ -508,7 +508,14 @@ function PackageSummarySheet({ const maxPhotos = (limits as Record | null)?.max_photos ?? null; const maxGuests = (limits as Record | null)?.max_guests ?? null; const galleryDays = (limits as Record | null)?.gallery_days ?? null; - const hasFeatures = Array.isArray(features) && features.length > 0; + const resolvedFeatures = collectPackageFeatures({ + features, + package_limits: limits, + branding_allowed: (limits as any)?.branding_allowed ?? null, + watermark_allowed: (limits as any)?.watermark_allowed ?? null, + } as any); + const limitEntries = getPackageLimitEntries(limits, t); + const hasFeatures = resolvedFeatures.length > 0; const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown'); @@ -539,13 +546,26 @@ function PackageSummarySheet({ - {hasFeatures ? ( + {limitEntries.length ? ( + + + {t('mobileDashboard.packageSummary.limitsTitle', 'Limits')} + + + {limitEntries.map((entry) => ( + + ))} + + + ) : null} + + {hasFeatures ? ( {t('mobileDashboard.packageSummary.featuresTitle', 'Included features')} - {features?.map((feature) => ( + {resolvedFeatures.map((feature) => ( diff --git a/resources/js/admin/mobile/lib/packageSummary.test.ts b/resources/js/admin/mobile/lib/packageSummary.test.ts index f5025da..a0956fb 100644 --- a/resources/js/admin/mobile/lib/packageSummary.test.ts +++ b/resources/js/admin/mobile/lib/packageSummary.test.ts @@ -1,11 +1,20 @@ import { describe, expect, it } from 'vitest'; -import { formatPackageLimit, getPackageFeatureLabel } from './packageSummary'; +import { + collectPackageFeatures, + formatEventUsage, + formatPackageLimit, + getPackageFeatureLabel, + getPackageLimitEntries, +} from './packageSummary'; const t = (key: string, options?: Record | string) => { if (typeof options === 'string') { return options; } - return (options?.defaultValue as string | undefined) ?? key; + const template = (options?.defaultValue as string | undefined) ?? key; + return template + .replace('{{used}}', String(options?.used ?? '{{used}}')) + .replace('{{limit}}', String(options?.limit ?? '{{limit}}')); }; describe('packageSummary helpers', () => { @@ -24,4 +33,28 @@ describe('packageSummary helpers', () => { it('formats numeric package limits', () => { expect(formatPackageLimit(12, t)).toBe('12'); }); + + it('collects features from package and limit payloads', () => { + const result = collectPackageFeatures({ + features: ['custom_branding'], + package_limits: { features: ['reseller_dashboard'] }, + branding_allowed: true, + watermark_allowed: false, + } as any); + + expect(result).toEqual(expect.arrayContaining(['custom_branding', 'reseller_dashboard', 'branding_allowed'])); + }); + + it('returns labeled limit entries', () => { + const result = getPackageLimitEntries({ max_photos: 120 }, t); + + expect(result[0].label).toBe('Photos'); + expect(result[0].value).toBe('120'); + }); + + it('formats event usage copy', () => { + const result = formatEventUsage(3, 10, t); + + expect(result).toBe('3 of 10 events created'); + }); }); diff --git a/resources/js/admin/mobile/lib/packageSummary.ts b/resources/js/admin/mobile/lib/packageSummary.ts index afee5ca..cb546f2 100644 --- a/resources/js/admin/mobile/lib/packageSummary.ts +++ b/resources/js/admin/mobile/lib/packageSummary.ts @@ -1,3 +1,5 @@ +import type { TenantPackageSummary } from '../../api'; + type Translate = (key: string, options?: Record | string) => string; const FEATURE_LABELS: Record = { @@ -5,14 +7,42 @@ const FEATURE_LABELS: Record = { key: 'mobileDashboard.packageSummary.feature.priority_support', fallback: 'Priority support', }, + reseller_dashboard: { + key: 'mobileDashboard.packageSummary.feature.reseller_dashboard', + fallback: 'Reseller dashboard', + }, custom_domain: { key: 'mobileDashboard.packageSummary.feature.custom_domain', fallback: 'Custom domain', }, + custom_branding: { + key: 'mobileDashboard.packageSummary.feature.custom_branding', + fallback: 'Custom branding', + }, + custom_tasks: { + key: 'mobileDashboard.packageSummary.feature.custom_tasks', + fallback: 'Custom tasks', + }, + unlimited_sharing: { + key: 'mobileDashboard.packageSummary.feature.unlimited_sharing', + fallback: 'Unlimited sharing', + }, analytics: { key: 'mobileDashboard.packageSummary.feature.analytics', fallback: 'Analytics', }, + advanced_reporting: { + key: 'mobileDashboard.packageSummary.feature.advanced_reporting', + fallback: 'Advanced reporting', + }, + live_slideshow: { + key: 'mobileDashboard.packageSummary.feature.live_slideshow', + fallback: 'Live slideshow', + }, + basic_uploads: { + key: 'mobileDashboard.packageSummary.feature.basic_uploads', + fallback: 'Guest uploads', + }, team_management: { key: 'mobileDashboard.packageSummary.feature.team_management', fallback: 'Team management', @@ -47,6 +77,40 @@ const FEATURE_LABELS: Record = { }, }; +const LIMIT_LABELS: Array<{ key: string; labelKey: string; fallback: string }> = [ + { + key: 'max_photos', + labelKey: 'packageLimits.max_photos', + fallback: 'Photos', + }, + { + key: 'max_guests', + labelKey: 'packageLimits.max_guests', + fallback: 'Guests', + }, + { + key: 'max_tasks', + labelKey: 'packageLimits.max_tasks', + fallback: 'Tasks', + }, + { + key: 'gallery_days', + labelKey: 'packageLimits.gallery_days', + fallback: 'Gallery days', + }, + { + key: 'max_events_per_year', + labelKey: 'packageLimits.max_events_per_year', + fallback: 'Events per year', + }, +]; + +export type PackageLimitEntry = { + key: string; + label: string; + value: string; +}; + export function getPackageFeatureLabel(feature: string, t: Translate): string { const entry = FEATURE_LABELS[feature]; if (entry) { @@ -63,3 +127,51 @@ export function formatPackageLimit(value: number | null | undefined, t: Translat return String(value); } + +export function getPackageLimitEntries(limits: Record | null, t: Translate): PackageLimitEntry[] { + if (!limits) { + return []; + } + + return LIMIT_LABELS.map(({ key, labelKey, fallback }) => ({ + key, + label: t(labelKey, fallback), + value: formatPackageLimit((limits as Record)[key], t), + })); +} + +export function collectPackageFeatures(pkg: TenantPackageSummary): string[] { + const features = new Set(); + const direct = Array.isArray(pkg.features) ? pkg.features : []; + const limitFeatures = Array.isArray((pkg.package_limits as any)?.features) ? (pkg.package_limits as any).features : []; + + direct.forEach((feature) => { + if (typeof feature === 'string' && feature.trim()) { + features.add(feature); + } + }); + + limitFeatures.forEach((feature: unknown) => { + if (typeof feature === 'string' && feature.trim()) { + features.add(feature); + } + }); + + if (pkg.branding_allowed) { + features.add('branding_allowed'); + } + + if (pkg.watermark_allowed) { + features.add('watermark_allowed'); + } + + return Array.from(features); +} + +export function formatEventUsage(used: number | null, limit: number | null, t: Translate): string | null { + if (limit === null || used === null) { + return null; + } + + return t('mobileBilling.eventsCreated', { used, limit, defaultValue: '{{used}} of {{limit}} events created' }); +}