feat(addons): finalize event addon catalog and ai styling upgrade flow
This commit is contained in:
@@ -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) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user