feat(addons): finalize event addon catalog and ai styling upgrade flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-07 12:35:07 +01:00
parent 8cc0918881
commit d2808ffa4f
36 changed files with 1372 additions and 457 deletions

View File

@@ -1990,7 +1990,14 @@ export async function getEventAiEditSummary(slug: string): Promise<AiEditUsageSu
export async function createEventAddonCheckout(
eventSlug: string,
params: { addon_key: string; quantity?: number; success_url?: string; cancel_url?: string }
params: {
addon_key: string;
quantity?: number;
success_url?: string;
cancel_url?: string;
accepted_terms?: boolean;
accepted_waiver?: boolean;
}
): Promise<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }> {
const response = await authorizedFetch(`${eventEndpoint(eventSlug)}/addons/checkout`, {
method: 'POST',
@@ -2656,6 +2663,8 @@ export type Package = {
included_package_slug?: string | null;
lemonsqueezy_variant_id?: string | null;
lemonsqueezy_product_id?: string | null;
can_checkout?: boolean;
checkout_provider?: 'paypal' | 'lemonsqueezy' | string;
branding_allowed?: boolean | null;
watermark_allowed?: boolean | null;
features: string[] | Record<string, boolean> | null;

View File

@@ -14,10 +14,12 @@ import {
getTenantPackagesOverview,
getTenantPackageCheckoutStatus,
getEvent,
getAddonCatalog,
TenantPackageSummary,
TenantEvent,
TenantBillingTransactionSummary,
EventAddonSummary,
EventAddonCatalogItem,
PaginationMeta,
downloadTenantBillingReceipt,
} from '../api';
@@ -76,6 +78,7 @@ export default function MobileBillingPage() {
);
const [transactionsLoadingMore, setTransactionsLoadingMore] = React.useState(false);
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [addonsMeta, setAddonsMeta] = React.useState<PaginationMeta>(() =>
createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE)
);
@@ -109,7 +112,7 @@ export default function MobileBillingPage() {
const load = React.useCallback(async () => {
setLoading(true);
try {
const [pkg, trx, addonHistory] = await Promise.all([
const [pkg, trx, addonHistory, addonCatalog] = await Promise.all([
getTenantPackagesOverview({ force: true }),
getTenantBillingTransactions(1, BILLING_HISTORY_PAGE_SIZE).catch(() => ({
data: [] as TenantBillingTransactionSummary[],
@@ -119,6 +122,7 @@ export default function MobileBillingPage() {
data: [] as TenantAddonHistoryEntry[],
meta: createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE),
})),
getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]),
]);
let scopedEvent: TenantEvent | null = null;
@@ -153,6 +157,7 @@ export default function MobileBillingPage() {
setTransactions(trx.data ?? []);
setTransactionsMeta(trx.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE));
setAddons(addonHistory.data ?? []);
setCatalogAddons(addonCatalog);
setAddonsMeta(addonHistory.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE));
setScopeEvent(scopedEvent);
setScopeAddons(scopedAddons);
@@ -195,6 +200,9 @@ export default function MobileBillingPage() {
}, [scopeEvent, t]);
const scopedEventPath = scopeEvent?.slug ? ADMIN_EVENT_VIEW_PATH(scopeEvent.slug) : null;
const activeEventId = scopeEvent?.id ?? activeEvent?.id ?? null;
const hasSellableAddons = React.useMemo(() => catalogAddons.some((addon) => Boolean(addon.price_id)), [catalogAddons]);
const eventRecapPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/recap`) : null;
const eventControlRoomPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/control-room`) : null;
const scopedEventPackage = scopeEvent?.package ?? null;
const scopedEventAddons = React.useMemo<EventAddonSummary[]>(() => {
const rows = scopeEvent?.addons;
@@ -689,6 +697,25 @@ export default function MobileBillingPage() {
{t('billing.sections.currentEvent.noAddons', 'No add-ons purchased for this event.')}
</Text>
)}
{scopeEvent?.slug ? (
hasSellableAddons ? (
<YStack gap="$2" marginTop="$1">
<CTAButton
label={t('billing.sections.currentEvent.openAddonShop', 'Buy add-ons for this event')}
onPress={() => navigate(eventRecapPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/recap`))}
/>
<CTAButton
label={t('billing.sections.currentEvent.openAddonUpsell', 'Open control-room upgrades')}
tone="ghost"
onPress={() => navigate(eventControlRoomPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/control-room`))}
/>
</YStack>
) : (
<Text fontSize="$sm" color={muted}>
{t('billing.sections.currentEvent.noAddonCatalog', 'No add-ons are currently available for purchase.')}
</Text>
)
) : null}
</YStack>
</YStack>
)}
@@ -862,9 +889,17 @@ export default function MobileBillingPage() {
{t('common.loading', 'Lädt...')}
</Text>
) : addons.length === 0 ? (
<Text fontSize="$sm" color={text}>
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
</Text>
<YStack gap="$2">
<Text fontSize="$sm" color={text}>
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
</Text>
{scopeEvent?.slug && hasSellableAddons ? (
<CTAButton
label={t('billing.sections.addOns.openShop', 'Buy add-ons now')}
onPress={() => navigate(eventRecapPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/recap`))}
/>
) : null}
</YStack>
) : (
<YStack gap="$1.5">
{addons.map((addon) => (

View File

@@ -434,6 +434,24 @@ export default function MobileEventControlRoomPage() {
const aiStylingAddon = React.useMemo(() => {
return catalogAddons.find((addon) => addon.price_id && aiStylingAddonKeys.includes(addon.key)) ?? null;
}, [aiStylingAddonKeys, catalogAddons]);
const aiStylingAddonCta = React.useMemo(() => {
if (!aiStylingAddon) {
return t('controlRoom.aiUpsell.cta', 'Unlock AI Magic Edit');
}
if (typeof aiStylingAddon.price !== 'number' || !Number.isFinite(aiStylingAddon.price)) {
return t('controlRoom.aiUpsell.cta', 'Unlock AI Magic Edit');
}
const currency = aiStylingAddon.currency || 'EUR';
try {
const formattedPrice = new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(aiStylingAddon.price);
return t('controlRoom.aiUpsell.ctaWithPrice', 'Unlock AI Magic Edit ({{price}})', { price: formattedPrice });
} catch {
return t('controlRoom.aiUpsell.cta', 'Unlock AI Magic Edit');
}
}, [aiStylingAddon, t]);
const aiSettingsDirty = React.useMemo(
() => isAiSettingsDirty(aiSettingsDraft, initialAiSettingsDraft),
[aiSettingsDraft, initialAiSettingsDraft],
@@ -907,12 +925,13 @@ export default function MobileEventControlRoomPage() {
const params = new URLSearchParams(location.search);
if (params.get('addon_success')) {
toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.'));
void refetch();
setModerationPage(1);
void loadModeration();
params.delete('addon_success');
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
}
}, [location.search, slug, loadModeration, navigate, t, location.pathname]);
}, [location.search, slug, loadModeration, navigate, t, location.pathname, refetch]);
const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => {
replacePhotoQueue(queue);
@@ -1161,7 +1180,7 @@ export default function MobileEventControlRoomPage() {
cancel_url: currentUrl,
accepted_terms: consents.acceptedTerms,
accepted_waiver: consents.acceptedWaiver,
} as any);
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
@@ -1832,6 +1851,27 @@ export default function MobileEventControlRoomPage() {
/>
</XStack>
</MobileCard>
) : aiStylingAddon ? (
<MobileCard gap="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('controlRoom.aiUpsell.title', 'Unlock AI Magic Edit')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t(
'controlRoom.aiUpsell.body',
'Buy the AI Styling add-on for this event without upgrading your full package.'
)}
</Text>
<CTAButton
label={aiStylingAddonCta}
onPress={() => startAddonCheckout('ai')}
loading={busyScope === 'ai'}
disabled={consentBusy}
/>
</MobileCard>
) : null
) : null}

View File

@@ -130,25 +130,16 @@ export default function MobileEventRecapPage() {
};
}, [event?.status, i18n.language, invites, t]);
const handleCheckout = async (addonKey: string) => {
if (!slug || busyScope) return;
setBusyScope(addonKey);
try {
const { checkout_url } = await createEventAddonCheckout(slug, {
addon_key: addonKey,
success_url: window.location.href,
cancel_url: window.location.href,
});
if (checkout_url) {
window.location.href = checkout_url;
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.')));
setBusyScope(null);
const handleCheckout = (addonKey: string) => {
if (!slug || busyScope) {
return;
}
setBusyScope(addonKey);
setConsentOpen(true);
};
const handleConsentConfirm = async (consents: { acceptedTerms: boolean }) => {
const handleConsentConfirm = async (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => {
if (!slug || !busyScope) return;
try {
const { checkout_url } = await createEventAddonCheckout(slug, {
@@ -156,10 +147,16 @@ export default function MobileEventRecapPage() {
success_url: window.location.href,
cancel_url: window.location.href,
accepted_terms: consents.acceptedTerms,
} as any);
accepted_waiver: consents.acceptedWaiver,
});
if (checkout_url) {
window.location.href = checkout_url;
return;
}
toast.error(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
setBusyScope(null);
setConsentOpen(false);
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.')));
setBusyScope(null);
@@ -200,6 +197,9 @@ export default function MobileEventRecapPage() {
const activeInvite = invites.find((i) => i.is_active) ?? invites[0] ?? null;
const guestLink = activeInvite?.url ?? '';
const galleryExtensionAddons = addons
.filter((addon) => addon.key.startsWith('extend_gallery_') || Number(addon.increments?.extra_gallery_days ?? 0) > 0)
.sort((left, right) => Number(left.increments?.extra_gallery_days ?? 0) - Number(right.increments?.extra_gallery_days ?? 0));
return (
<MobileShell
@@ -397,16 +397,19 @@ export default function MobileEventRecapPage() {
</Text>
<YStack gap="$2">
{addons
.filter((a) => a.key === 'gallery_extension')
.map((addon) => (
<CTAButton
key={addon.key}
label={t('events.recap.buyExtension', 'Galerie um 30 Tage verlängern')}
onPress={() => handleCheckout(addon.key)}
loading={busyScope === addon.key}
/>
))}
{galleryExtensionAddons.map((addon) => (
<CTAButton
key={addon.key}
label={addon.label || t('events.recap.buyExtension', 'Galerie um 30 Tage verlängern')}
onPress={() => handleCheckout(addon.key)}
loading={busyScope === addon.key}
/>
))}
{galleryExtensionAddons.length === 0 ? (
<Text fontSize="$sm" color={muted}>
{t('events.recap.noAddonAvailable', 'Aktuell sind keine Galerie-Add-ons verfügbar.')}
</Text>
) : null}
</YStack>
</MobileCard>
</YStack>

View File

@@ -224,7 +224,7 @@ function PackageShopCard({
const isResellerCatalog = catalogType === 'reseller';
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
const isSubdued = Boolean(!isResellerCatalog && (isDowngrade || !isUpgrade) && !isActive);
const canSelect = isResellerCatalog ? Boolean(pkg.lemonsqueezy_variant_id) : canSelectPackage(isUpgrade, isActive);
const canSelect = isResellerCatalog ? isPackageCheckoutAvailable(pkg) : canSelectPackage(isUpgrade, isActive);
const hasManageAction = Boolean(isActive && onManage);
const includedTierLabel = resolveIncludedTierLabel(t, pkg.included_package_slug ?? null);
const handlePress = isActive ? onManage : canSelect ? onSelect : undefined;
@@ -524,7 +524,7 @@ function PackageShopCompareView({
<YStack width={labelWidth} />
{entries.map((entry) => {
const isResellerCatalog = catalogType === 'reseller';
const canSelect = isResellerCatalog ? Boolean(entry.pkg.lemonsqueezy_variant_id) : canSelectPackage(entry.isUpgrade, entry.isActive);
const canSelect = isResellerCatalog ? isPackageCheckoutAvailable(entry.pkg) : canSelectPackage(entry.isUpgrade, entry.isActive);
const label = isResellerCatalog
? canSelect
? t('shop.partner.buy', 'Kaufen')
@@ -586,6 +586,14 @@ function canSelectPackage(isUpgrade?: boolean, isActive?: boolean): boolean {
return Boolean(isActive || isUpgrade);
}
function isPackageCheckoutAvailable(pkg: Package): boolean {
if (typeof pkg.can_checkout === 'boolean') {
return pkg.can_checkout;
}
return true;
}
function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) {
const { t } = useTranslation('management');
const { textStrong, muted, border, primary } = useAdminTheme();

View File

@@ -153,6 +153,7 @@ vi.mock('../invite-layout/export-utils', () => ({
vi.mock('../../api', () => ({
getEvent: vi.fn(),
getAddonCatalog: vi.fn().mockResolvedValue([]),
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
getTenantBillingTransactions: vi.fn().mockResolvedValue({
data: [
@@ -199,6 +200,7 @@ describe('MobileBillingPage', () => {
} as any);
vi.mocked(api.getTenantAddonHistory).mockResolvedValue({ data: [] } as any);
vi.mocked(api.getEvent).mockResolvedValue(null as any);
vi.mocked(api.getAddonCatalog).mockResolvedValue([]);
});
it('shows current event scoped entitlements separately from tenant history', async () => {
@@ -480,4 +482,39 @@ describe('MobileBillingPage', () => {
expect(triggerDownloadMock).toHaveBeenCalled();
});
});
it('offers a direct add-on purchase entry in billing for the selected event', async () => {
eventContext.activeEvent = {
id: 99,
slug: 'fruehlingsfest',
name: { de: 'Frühlingsfest' },
};
vi.mocked(api.getEvent).mockResolvedValueOnce({
id: 99,
slug: 'fruehlingsfest',
name: { de: 'Frühlingsfest' },
event_date: null,
event_type_id: null,
event_type: null,
status: 'published',
settings: {},
package: null,
addons: [],
limits: null,
member_permissions: [],
} as any);
vi.mocked(api.getAddonCatalog).mockResolvedValueOnce([
{
key: 'extend_gallery_30d',
label: 'Galerie +30 Tage',
price_id: 'paypal',
increments: { extra_gallery_days: 30 },
},
] as any);
render(<MobileBillingPage />);
expect(await screen.findByText('Buy add-ons for this event')).toBeInTheDocument();
});
});

View File

@@ -219,6 +219,7 @@ vi.mock('../../api', () => ({
}));
import MobileEventControlRoomPage from '../EventControlRoomPage';
import * as api from '../../api';
describe('MobileEventControlRoomPage', () => {
it('renders compact grid actions for moderation photos', async () => {
@@ -228,4 +229,22 @@ describe('MobileEventControlRoomPage', () => {
expect(screen.getByLabelText('Hide')).toBeInTheDocument();
expect(screen.getByLabelText('Set highlight')).toBeInTheDocument();
});
it('shows AI addon upsell when AI is locked but addon is purchasable', async () => {
(api.getAddonCatalog as any).mockResolvedValueOnce([
{
key: 'ai_styling_unlock',
label: 'AI Styling Add-on',
price_id: 'paypal',
increments: {},
price: 9,
currency: 'EUR',
},
]);
render(<MobileEventControlRoomPage />);
expect(await screen.findByText('Unlock AI Magic Edit')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Unlock AI Magic Edit/i })).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
const fixtures = vi.hoisted(() => ({
event: {
@@ -23,7 +23,14 @@ const fixtures = vi.hoisted(() => ({
is_active: true,
},
invites: [],
addons: [],
addons: [
{
key: 'extend_gallery_30d',
label: 'Galerie um 30 Tage verlängern',
price_id: 'price_30d',
increments: { extra_gallery_days: 30 },
},
],
}));
vi.mock('react-router-dom', () => ({
@@ -60,7 +67,11 @@ vi.mock('../../api', () => ({
getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites),
getAddonCatalog: vi.fn().mockResolvedValue(fixtures.addons),
updateEvent: vi.fn().mockResolvedValue(fixtures.event),
createEventAddonCheckout: vi.fn(),
createEventAddonCheckout: vi.fn().mockResolvedValue({
checkout_url: null,
checkout_id: 'chk_123',
expires_at: null,
}),
getEventEngagement: vi.fn().mockResolvedValue({
summary: { totalPhotos: 0, uniqueGuests: 0, tasksSolved: 0, likesTotal: 0 },
leaderboards: { uploads: [], likes: [] },
@@ -87,7 +98,11 @@ vi.mock('../components/MobileShell', () => ({
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{label}
</button>
),
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SkeletonCard: () => <div>Loading...</div>,
}));
@@ -101,7 +116,13 @@ vi.mock('../components/FormControls', () => ({
}));
vi.mock('../components/LegalConsentSheet', () => ({
LegalConsentSheet: () => <div />,
LegalConsentSheet: ({
open,
onConfirm,
}: {
open: boolean;
onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => void;
}) => (open ? <button type="button" onClick={() => onConfirm({ acceptedTerms: true, acceptedWaiver: true })}>Confirm legal</button> : null),
}));
vi.mock('@tamagui/stacks', () => ({
@@ -164,8 +185,21 @@ vi.mock('../theme', () => ({
}));
import MobileEventRecapPage from '../EventRecapPage';
import { createEventAddonCheckout } from '../../api';
describe('MobileEventRecapPage', () => {
beforeEach(() => {
fixtures.addons = [
{
key: 'extend_gallery_30d',
label: 'Galerie um 30 Tage verlängern',
price_id: 'price_30d',
increments: { extra_gallery_days: 30 },
},
];
vi.clearAllMocks();
});
it('renders recap settings toggles', async () => {
render(<MobileEventRecapPage />);
@@ -173,4 +207,26 @@ describe('MobileEventRecapPage', () => {
expect(screen.getByLabelText('Gäste dürfen Fotos laden')).toBeInTheDocument();
expect(screen.getByLabelText('Gäste dürfen Fotos teilen')).toBeInTheDocument();
});
it('requires consent and sends both legal acceptance flags for recap addon checkout', async () => {
const checkoutMock = vi.mocked(createEventAddonCheckout);
render(<MobileEventRecapPage />);
const checkoutButton = await screen.findByRole('button', { name: 'Galerie um 30 Tage verlängern' });
fireEvent.click(checkoutButton);
expect(checkoutMock).not.toHaveBeenCalled();
fireEvent.click(await screen.findByRole('button', { name: 'Confirm legal' }));
await waitFor(() => {
expect(checkoutMock).toHaveBeenCalledWith(fixtures.event.slug, {
addon_key: 'extend_gallery_30d',
success_url: window.location.href,
cancel_url: window.location.href,
accepted_terms: true,
accepted_waiver: true,
});
});
});
});