From 738659112d5e93865c227167ace9fe703806e2f6 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 15 Jan 2026 10:17:05 +0100 Subject: [PATCH] Add upgrade CTAs for branding and watermarks --- .../js/admin/i18n/locales/de/management.json | 6 ++ .../js/admin/i18n/locales/en/management.json | 6 ++ resources/js/admin/mobile/BrandingPage.tsx | 64 ++++++++++++++++--- .../mobile/__tests__/BrandingPage.test.tsx | 36 ++++++++++- .../mobile/__tests__/packageShop.test.ts | 10 +++ resources/js/admin/mobile/lib/packageShop.ts | 4 +- 6 files changed, 116 insertions(+), 10 deletions(-) diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 32e5ca6..d40e2a2 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -1924,6 +1924,9 @@ "background": "Hintergrund", "surface": "Fläche", "lockedBranding": "Branding ist in diesem Paket gesperrt.", + "lockedTitle": "Branding freischalten", + "lockedBody": "Upgrade auf Standard oder Premium, um Event-Branding zu nutzen.", + "upgradeAction": "Paket upgraden", "source": "Branding-Quelle", "sourceHint": "Nutze das Standard-Branding oder passe nur dieses Event an.", "useDefault": "Standard", @@ -2003,6 +2006,9 @@ "offsetY": "Y-Achse", "lockedBranding": "Eigenes Wasserzeichen ist in diesem Paket gesperrt. Standard wird genutzt.", "lockedDisabled": "Wasserzeichen sind in diesem Paket deaktiviert.", + "lockedTitle": "Wasserzeichen freischalten", + "lockedBody": "Eigenes Wasserzeichen ist nur im Premium-Paket verfügbar.", + "upgradeAction": "Upgrade auf Premium", "errors": { "noAsset": "Bitte zuerst ein Wasserzeichen hochladen.", "fileTooLarge": "Wasserzeichen muss kleiner als 3 MB sein." diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 14ba498..de3f8ca 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -1928,6 +1928,9 @@ "background": "Background", "surface": "Surface", "lockedBranding": "Branding is locked for this package.", + "lockedTitle": "Unlock branding", + "lockedBody": "Upgrade to Standard or Premium to unlock event branding.", + "upgradeAction": "Upgrade package", "source": "Branding source", "sourceHint": "Use the default branding or customize this event only.", "useDefault": "Default", @@ -2007,6 +2010,9 @@ "offsetY": "Y-axis", "lockedBranding": "Custom watermark locked by this package. Using base watermark.", "lockedDisabled": "Watermarks are disabled for this package.", + "lockedTitle": "Unlock watermarks", + "lockedBody": "Custom watermarks are available with the Premium package.", + "upgradeAction": "Upgrade to Premium", "errors": { "noAsset": "Please upload a watermark image first.", "fileTooLarge": "Watermark must be under 3 MB." diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index 50dba44..d2dba71 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -339,10 +339,11 @@ export default function MobileBrandingPage() { {disabled ? ( - } - text={t('events.watermark.lockedDisabled', 'Kein Wasserzeichen in diesem Paket.')} - tone="danger" + navigate(adminPath('/mobile/billing/shop?feature=watermark_allowed'))} /> ) : null} @@ -605,10 +606,11 @@ export default function MobileBrandingPage() { {!brandingAllowed ? ( - } - text={t('events.branding.lockedBranding', 'Branding ist in diesem Paket gesperrt.')} - tone="danger" + navigate(adminPath('/mobile/billing/shop?feature=custom_branding'))} /> ) : null} @@ -1468,6 +1470,52 @@ function InfoBadge({ icon, text, tone = 'info' }: { icon?: React.ReactNode; text ); } +function UpgradeCard({ + title, + body, + actionLabel, + onPress, +}: { + title: string; + body: string; + actionLabel: string; + onPress: () => void; +}) { + const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme(); + + return ( + + + + + + + {title} + + + {body} + + + + + ); +} + function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { const { backdrop, surfaceMuted, border, surface } = useAdminTheme(); return ( diff --git a/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx b/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx index 06fcbfd..12ad6cc 100644 --- a/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; const navigateMock = vi.fn(); const backMock = vi.fn(); @@ -163,4 +163,38 @@ describe('MobileBrandingPage', () => { expect(screen.getByText('Theme')).toBeInTheDocument(); expect(screen.getByText('Colors')).toBeInTheDocument(); }); + + it('offers an upgrade CTA when branding is locked', async () => { + getEventMock.mockResolvedValueOnce({ + ...baseEvent, + package: { branding_allowed: false, watermark_allowed: false }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Unlock branding')).toBeInTheDocument(); + }); + + expect(screen.getByText('Upgrade package')).toBeInTheDocument(); + }); + + it('offers an upgrade CTA on the watermark tab when disabled', async () => { + getEventMock.mockResolvedValueOnce({ + ...baseEvent, + package: { branding_allowed: true, watermark_allowed: false }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Branding Source')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Wasserzeichen')); + + await waitFor(() => { + expect(screen.getByText('Upgrade to Premium')).toBeInTheDocument(); + }); + }); }); diff --git a/resources/js/admin/mobile/__tests__/packageShop.test.ts b/resources/js/admin/mobile/__tests__/packageShop.test.ts index 2f5e7a4..349def4 100644 --- a/resources/js/admin/mobile/__tests__/packageShop.test.ts +++ b/resources/js/admin/mobile/__tests__/packageShop.test.ts @@ -56,6 +56,16 @@ describe('selectRecommendedPackageId', () => { 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); }); + + it('selects the cheapest package with watermark access when requested', () => { + const watermarkPackages = [ + { id: 1, price: 100, watermark_allowed: false, features: {} }, + { id: 2, price: 120, watermark_allowed: true, features: {} }, + { id: 3, price: 180, watermark_allowed: true, features: {} }, + ] as any; + const active = { id: 1, price: 100, watermark_allowed: false, features: {} } as any; + expect(selectRecommendedPackageId(watermarkPackages, 'watermark_allowed', active)).toBe(2); + }); }); describe('buildPackageComparisonRows', () => { diff --git a/resources/js/admin/mobile/lib/packageShop.ts b/resources/js/admin/mobile/lib/packageShop.ts index b07ec7d..cc956d6 100644 --- a/resources/js/admin/mobile/lib/packageShop.ts +++ b/resources/js/admin/mobile/lib/packageShop.ts @@ -106,7 +106,9 @@ export function selectRecommendedPackageId( return null; } - const candidates = packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature)); + const candidates = feature === 'watermark_allowed' + ? packages.filter((pkg) => pkg.watermark_allowed === true) + : packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature)); if (candidates.length === 0) { return null; }