verschieben des sofortigen verzichts auf das Widerrrufsrecht zum Anlegen des Events

This commit is contained in:
Codex Agent
2025-12-22 13:11:16 +01:00
parent 84234bfb8e
commit c947e638eb
29 changed files with 877 additions and 374 deletions

View File

@@ -682,6 +682,7 @@ type EventSavePayload = {
status?: 'draft' | 'published' | 'archived';
is_active?: boolean;
package_id?: number;
accepted_waiver?: boolean;
settings?: Record<string, unknown> & {
watermark?: WatermarkSettings;
watermark_serve_originals?: boolean | null;
@@ -2158,6 +2159,27 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
};
}
export async function createTenantBillingPortalSession(): Promise<{ url: string }> {
const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
method: 'POST',
});
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to create Paddle portal session', response.status, payload);
throw new Error('Failed to create Paddle portal session');
}
const payload = await safeJson(response);
const url = payload?.url;
if (typeof url !== 'string' || url.length === 0) {
throw new Error('Paddle portal session missing URL');
}
return { url };
}
export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
data: TenantAddonHistoryEntry[];
meta: PaginationMeta;

View File

@@ -4,7 +4,11 @@
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
"actions": {
"refresh": "Aktualisieren",
"exportCsv": "Export als CSV"
"exportCsv": "Export als CSV",
"portal": "Im Paddle-Portal verwalten",
"portalBusy": "Portal wird geöffnet...",
"openPackages": "Pakete öffnen",
"contactSupport": "Support kontaktieren"
},
"stats": {
"package": {
@@ -17,7 +21,7 @@
"helper": "Verfügbar: {{count}}"
},
"addons": {
"label": "Add-ons",
"label": "Zusatzpakete",
"helper": "Historie insgesamt"
},
"transactions": {
@@ -27,16 +31,19 @@
},
"errors": {
"load": "Paketdaten konnten nicht geladen werden.",
"more": "Weitere Einträge konnten nicht geladen werden."
"more": "Weitere Einträge konnten nicht geladen werden.",
"portal": "Paddle-Portal konnte nicht geöffnet werden."
},
"sections": {
"invoices": {
"title": "Rechnungen & Zahlungen",
"hint": "Zahlungen prüfen und Belege herunterladen.",
"empty": "Keine Zahlungen gefunden."
},
"addOns": {
"title": "Add-ons",
"empty": "Keine Add-ons gebucht."
"title": "Zusatzpakete",
"hint": "Zusatzkontingente je Event im Blick behalten.",
"empty": "Keine Zusatzpakete gebucht."
},
"overview": {
"title": "Paketübersicht",
@@ -68,7 +75,8 @@
}
},
"packages": {
"title": "Paket-Historie",
"title": "Pakete",
"hint": "Aktives Paket, Limits und Historie auf einen Blick.",
"description": "Übersicht über aktive und vergangene Pakete.",
"empty": "Noch keine Pakete gebucht.",
"card": {
@@ -335,6 +343,14 @@
"confirm": "Weiter zum Checkout",
"cancel": "Abbrechen"
},
"eventStartConsent": {
"title": "Vor dem ersten Event",
"description": "Bitte bestätige den sofortigen Beginn der digitalen Leistung, bevor du dein erstes Event erstellst.",
"checkboxWaiver": "Ich verlange ausdrücklich, dass mit der Bereitstellung der digitalen Leistung jetzt begonnen wird. Mir ist bekannt, dass ich mein Widerrufsrecht verliere, sobald der Vertrag vollständig erfüllt ist.",
"errorWaiver": "Bitte bestätige den sofortigen Leistungsbeginn und das vorzeitige Erlöschen des Widerrufsrechts.",
"confirm": "Event erstellen",
"cancel": "Abbrechen"
},
"placeholders": {
"untitled": "Unbenanntes Event"
},
@@ -1369,55 +1385,6 @@
"confirm": {
"disable": "Photobooth-Zugang deaktivieren?"
}
},
"billing": {
"title": "Pakete & Abrechnung",
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
"actions": {
"refresh": "Aktualisieren",
"exportCsv": "Export als CSV"
},
"errors": {
"load": "Paketdaten konnten nicht geladen werden.",
"more": "Weitere Einträge konnten nicht geladen werden."
},
"sections": {
"overview": {
"title": "Paketübersicht",
"description": "Dein aktives Paket und die wichtigsten Kennzahlen.",
"empty": "Noch kein Paket aktiv.",
"emptyBadge": "Kein aktives Paket",
"cards": {
"package": {
"label": "Aktives Paket",
"helper": "Aktuell zugewiesen"
},
"used": {
"label": "Genutzte Events",
"helper": "Verfügbar: {{count}}"
},
"price": {
"label": "Preis (netto)"
},
"expires": {
"label": "Läuft ab",
"helper": "Automatische Verlängerung, falls aktiv"
}
}
}
},
"packages": {
"title": "Paket-Historie",
"description": "Übersicht über aktuelle und vergangene Pakete.",
"empty": "Noch keine Pakete gebucht.",
"card": {
"statusActive": "Aktiv",
"statusInactive": "Inaktiv",
"used": "Genutzte Events",
"available": "Verfügbar",
"expires": "Läuft ab"
}
}
}
},
"settings": {
@@ -2185,6 +2152,15 @@
"mobileBilling": {
"packageFallback": "Paket",
"remainingEvents": "{{count}} Events",
"openEvent": "Event öffnen",
"usage": {
"events": "Events",
"guests": "Gäste",
"photos": "Fotos",
"value": "{{used}} / {{limit}}",
"limit": "Limit {{limit}}",
"remaining": "Verbleibend {{count}}"
},
"status": {
"completed": "Abgeschlossen",
"pending": "Ausstehend",

View File

@@ -4,7 +4,11 @@
"subtitle": "Manage your purchased packages and track their durations.",
"actions": {
"refresh": "Refresh",
"exportCsv": "Export CSV"
"exportCsv": "Export CSV",
"portal": "Manage in Paddle",
"portalBusy": "Opening portal...",
"openPackages": "Open packages",
"contactSupport": "Contact support"
},
"stats": {
"package": {
@@ -27,15 +31,18 @@
},
"errors": {
"load": "Unable to load package data.",
"more": "Unable to load more entries."
"more": "Unable to load more entries.",
"portal": "Unable to open the Paddle portal."
},
"sections": {
"invoices": {
"title": "Invoices & payments",
"hint": "Review transactions and download receipts.",
"empty": "No payments found."
},
"addOns": {
"title": "Add-ons",
"hint": "Track extra photo, guest, or time bundles per event.",
"empty": "No add-ons booked."
},
"overview": {
@@ -68,7 +75,8 @@
}
},
"packages": {
"title": "Package history",
"title": "Packages",
"hint": "Active package, limits, and history at a glance.",
"description": "Overview of active and past packages.",
"empty": "No packages purchased yet.",
"card": {
@@ -938,6 +946,14 @@
"confirm": "Continue to checkout",
"cancel": "Cancel"
},
"eventStartConsent": {
"title": "Before your first event",
"description": "Please confirm the immediate start of the digital service before creating your first event.",
"checkboxWaiver": "I expressly request that the digital service begins now and understand my right of withdrawal expires once the contract has been fully performed.",
"errorWaiver": "Please confirm the immediate start of the digital service and the early expiry of the right of withdrawal.",
"confirm": "Create event",
"cancel": "Cancel"
},
"placeholders": {
"untitled": "Untitled event"
},
@@ -1382,55 +1398,6 @@
"confirm": {
"disable": "Disable photobooth access?"
}
},
"billing": {
"title": "Packages & billing",
"subtitle": "Manage your purchased packages and track their durations.",
"actions": {
"refresh": "Refresh",
"exportCsv": "Export CSV"
},
"errors": {
"load": "Unable to load package data.",
"more": "Unable to load more entries."
},
"sections": {
"overview": {
"title": "Package overview",
"description": "Your active package and the most important metrics.",
"empty": "No active package yet.",
"emptyBadge": "No active package",
"cards": {
"package": {
"label": "Active package",
"helper": "Currently assigned"
},
"used": {
"label": "Events used",
"helper": "Remaining: {{count}}"
},
"price": {
"label": "Price (net)"
},
"expires": {
"label": "Expires",
"helper": "Auto-renews if enabled"
}
}
}
},
"packages": {
"title": "Package history",
"description": "Overview of current and past packages.",
"empty": "No packages purchased yet.",
"card": {
"statusActive": "Active",
"statusInactive": "Inactive",
"used": "Used events",
"available": "Available",
"expires": "Expires"
}
}
}
}
,
@@ -2205,6 +2172,15 @@
"mobileBilling": {
"packageFallback": "Package",
"remainingEvents": "{{count}} events",
"openEvent": "Open event",
"usage": {
"events": "Events",
"guests": "Guests",
"photos": "Photos",
"value": "{{used}} / {{limit}}",
"limit": "Limit {{limit}}",
"remaining": "Remaining {{count}}"
},
"status": {
"completed": "Completed",
"pending": "Pending",

View File

@@ -1,13 +1,15 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CreditCard, Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react';
import { Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import {
createTenantBillingPortalSession,
getTenantPackagesOverview,
getTenantPaddleTransactions,
TenantPackageSummary,
@@ -15,7 +17,8 @@ import {
} from '../api';
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { adminPath } from '../constants';
import { ADMIN_EVENT_VIEW_PATH } from '../constants';
import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage';
export default function MobileBillingPage() {
const { t } = useTranslation('management');
@@ -27,8 +30,10 @@ export default function MobileBillingPage() {
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [portalBusy, setPortalBusy] = React.useState(false);
const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de';
const load = React.useCallback(async () => {
setLoading(true);
@@ -51,6 +56,35 @@ export default function MobileBillingPage() {
}
}, [t]);
const scrollToPackages = React.useCallback(() => {
packagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, []);
const openSupport = React.useCallback(() => {
if (typeof window !== 'undefined') {
window.location.href = `mailto:${supportEmail}`;
}
}, [supportEmail]);
const openPortal = React.useCallback(async () => {
if (portalBusy) {
return;
}
setPortalBusy(true);
try {
const { url } = await createTenantBillingPortalSession();
if (typeof window !== 'undefined') {
window.open(url, '_blank', 'noopener');
}
} catch (err) {
const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Paddle-Portal nicht öffnen.'));
toast.error(message);
} finally {
setPortalBusy(false);
}
}, [portalBusy, t]);
React.useEffect(() => {
void load();
}, [load]);
@@ -80,6 +114,7 @@ export default function MobileBillingPage() {
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
</MobileCard>
) : null}
@@ -90,6 +125,14 @@ export default function MobileBillingPage() {
{t('billing.sections.packages.title', 'Packages')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
</Text>
<CTAButton
label={portalBusy ? t('billing.actions.portalBusy', 'Öffne Portal...') : t('billing.actions.portal', 'Manage in Paddle')}
onPress={openPortal}
disabled={portalBusy}
/>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
{t('common.loading', 'Lädt...')}
@@ -97,7 +140,11 @@ export default function MobileBillingPage() {
) : (
<YStack space="$2">
{activePackage ? (
<PackageCard pkg={activePackage} label={t('billing.sections.packages.card.statusActive', 'Aktiv')} />
<PackageCard
pkg={activePackage}
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
isActive
/>
) : null}
{packages
.filter((pkg) => !activePackage || pkg.id !== activePackage.id)
@@ -115,14 +162,21 @@ export default function MobileBillingPage() {
{t('billing.sections.invoices.title', 'Invoices & Payments')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
{t('billing.sections.invoices.hint', 'Review transactions and download receipts.')}
</Text>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
{t('common.loading', 'Lädt...')}
</Text>
) : transactions.length === 0 ? (
<Text fontSize="$sm" color="#4b5563">
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
</Text>
<YStack space="$2">
<Text fontSize="$sm" color="#4b5563">
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
</Text>
<CTAButton label={t('billing.actions.openPackages', 'Open packages')} onPress={scrollToPackages} />
<CTAButton label={t('billing.actions.contactSupport', 'Contact support')} tone="ghost" onPress={openSupport} />
</YStack>
) : (
<YStack space="$1.5">
{transactions.slice(0, 8).map((trx) => (
@@ -169,6 +223,9 @@ export default function MobileBillingPage() {
{t('billing.sections.addOns.title', 'Add-ons')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
{t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')}
</Text>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
{t('common.loading', 'Lädt...')}
@@ -190,12 +247,13 @@ export default function MobileBillingPage() {
);
}
function PackageCard({ pkg, label }: { pkg: TenantPackageSummary; label?: string }) {
function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean }) {
const { t } = useTranslation('management');
const remaining = pkg.remaining_events ?? (pkg.package_limits?.max_events_per_year as number | undefined) ?? 0;
const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null;
const usageMetrics = buildPackageUsageMetrics(pkg);
return (
<MobileCard borderColor="#e5e7eb" space="$2">
<MobileCard borderColor={isActive ? '#2563eb' : '#e5e7eb'} borderWidth={isActive ? 2 : 1} backgroundColor={isActive ? '#eff6ff' : undefined} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{pkg.package_name ?? t('mobileBilling.packageFallback', 'Package')}
@@ -217,9 +275,13 @@ function PackageCard({ pkg, label }: { pkg: TenantPackageSummary; label?: string
{renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))}
{renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))}
</XStack>
<YStack space="$1.5" marginTop="$2">
<FeatureList pkg={pkg} />
</YStack>
{usageMetrics.length ? (
<YStack space="$2" marginTop="$2">
{usageMetrics.map((metric) => (
<UsageBar key={metric.key} metric={metric} />
))}
</YStack>
) : null}
</MobileCard>
);
}
@@ -231,43 +293,45 @@ function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, labe
return <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>;
}
function FeatureList({ pkg }: { pkg: TenantPackageSummary }) {
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
const { t } = useTranslation('management');
const limits = pkg.package_limits ?? {};
const features = (pkg as any).features as string[] | undefined;
const labelMap: Record<PackageUsageMetric['key'], string> = {
events: t('mobileBilling.usage.events', 'Events'),
guests: t('mobileBilling.usage.guests', 'Guests'),
photos: t('mobileBilling.usage.photos', 'Photos'),
};
const rows: Array<{ label: string; value: string }> = [];
if (limits.max_photos !== undefined && limits.max_photos !== null) {
rows.push({ label: t('billing.features.maxPhotos', 'Max photos'), value: String(limits.max_photos) });
}
if (limits.max_guests !== undefined && limits.max_guests !== null) {
rows.push({ label: t('billing.features.maxGuests', 'Max guests'), value: String(limits.max_guests) });
}
if (limits.gallery_days !== undefined && limits.gallery_days !== null) {
rows.push({ label: t('billing.features.galleryDays', 'Gallery days'), value: String(limits.gallery_days) });
}
if (limits.max_tasks !== undefined && limits.max_tasks !== null) {
rows.push({ label: t('billing.features.maxTasks', 'Max tasks'), value: String(limits.max_tasks) });
}
if (Array.isArray(features) && features.length) {
rows.push({ label: t('billing.features.featureList', 'Included features'), value: features.join(', ') });
if (!metric.limit) {
return null;
}
if (!rows.length) return null;
const hasUsage = metric.used !== null;
const valueText = hasUsage
? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit })
: t('mobileBilling.usage.limit', { limit: metric.limit });
const remainingText = metric.remaining !== null
? t('mobileBilling.usage.remaining', { count: metric.remaining })
: null;
const fill = usagePercent(metric);
return (
<YStack space="$1">
{rows.map((row) => (
<XStack key={row.label} alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color="#6b7280">
{row.label}
</Text>
<Text fontSize="$xs" color="#0f172a" fontWeight="700">
{row.value}
</Text>
</XStack>
))}
<YStack space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color="#6b7280">
{labelMap[metric.key]}
</Text>
<Text fontSize="$xs" color="#0f172a" fontWeight="700">
{valueText}
</Text>
</XStack>
<YStack height={6} borderRadius={999} backgroundColor="#e5e7eb" overflow="hidden">
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? '#2563eb' : '#94a3b8'} />
</YStack>
{remainingText ? (
<Text fontSize="$xs" color="#6b7280">
{remainingText}
</Text>
) : null}
</YStack>
);
}
@@ -286,6 +350,7 @@ function formatAmount(value: number | null | undefined, currency: string | null
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
const { t } = useTranslation('management');
const navigate = useNavigate();
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
@@ -296,6 +361,21 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
(addon.event?.name && typeof addon.event.name === 'string' && addon.event.name) ||
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
null;
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
const impactBadges = hasImpact ? (
<XStack space="$2" marginTop="$1.5" flexWrap="wrap">
{addon.extra_photos ? (
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
) : null}
{addon.extra_guests ? (
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
) : null}
{addon.extra_gallery_days ? (
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
) : null}
</XStack>
) : null;
return (
<MobileCard borderColor="#e5e7eb" padding="$3" space="$1.5">
@@ -305,28 +385,31 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
</Text>
<PillBadge tone={status.tone}>{status.text}</PillBadge>
</XStack>
{eventName ? (
eventPath ? (
<Pressable onPress={() => navigate(eventPath)}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color="#0f172a" fontWeight="600">
{eventName}
</Text>
<Text fontSize="$xs" color="#2563eb" fontWeight="700">
{t('mobileBilling.openEvent', 'Open event')}
</Text>
</XStack>
</Pressable>
) : (
<Text fontSize="$xs" color="#9ca3af">
{eventName}
</Text>
)
) : null}
{impactBadges}
<Text fontSize="$sm" color="#0f172a" marginTop="$1.5">
{formatAmount(addon.amount, addon.currency)}
</Text>
<Text fontSize="$xs" color="#6b7280">
{formatDate(addon.purchased_at)}
</Text>
{eventName ? (
<Text fontSize="$xs" color="#9ca3af">
{eventName}
</Text>
) : null}
<XStack space="$2" marginTop="$1">
{addon.extra_photos ? (
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
) : null}
{addon.extra_guests ? (
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
) : null}
{addon.extra_gallery_days ? (
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
) : null}
</XStack>
<Text fontSize="$sm" color="#0f172a" marginTop="$1">
{formatAmount(addon.amount, addon.currency)}
</Text>
</MobileCard>
);
}

View File

@@ -7,11 +7,12 @@ import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api';
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiValidationMessage } from '../lib/apiError';
import { getApiValidationMessage, isApiError } from '../lib/apiError';
import toast from 'react-hot-toast';
type FormState = {
@@ -46,6 +47,9 @@ export default function MobileEventFormPage() {
const [typesLoading, setTypesLoading] = React.useState(false);
const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false);
const [consentOpen, setConsentOpen] = React.useState(false);
const [consentBusy, setConsentBusy] = React.useState(false);
const [pendingPayload, setPendingPayload] = React.useState<Parameters<typeof createEvent>[0] | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
@@ -99,24 +103,24 @@ export default function MobileEventFormPage() {
async function handleSubmit() {
setSaving(true);
setError(null);
try {
if (isEdit && slug) {
const updated = await updateEvent(slug, {
name: form.name,
event_date: form.date || undefined,
event_type_id: form.eventTypeId ?? undefined,
status: form.published ? 'published' : 'draft',
settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
},
});
const nextSlug = resolveEventSlugAfterUpdate(slug, updated);
navigate(adminPath(`/mobile/events/${nextSlug}`));
} else {
const payload = {
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
try {
if (isEdit && slug) {
const updated = await updateEvent(slug, {
name: form.name,
event_date: form.date || undefined,
event_type_id: form.eventTypeId ?? undefined,
status: form.published ? 'published' : 'draft',
settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
},
});
const nextSlug = resolveEventSlugAfterUpdate(slug, updated);
navigate(adminPath(`/mobile/events/${nextSlug}`));
} else {
const payload = {
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
slug: `${Date.now()}`,
event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined,
@@ -126,10 +130,56 @@ export default function MobileEventFormPage() {
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
},
};
const { event } = await createEvent(payload as any);
} as Parameters<typeof createEvent>[0];
const { event } = await createEvent(payload);
navigate(adminPath(`/mobile/events/${event.slug}`));
}
} catch (err) {
if (isAuthError(err)) {
return;
}
if (!isEdit && isWaiverRequiredError(err)) {
const payload = {
name: form.name || t('eventForm.fields.name.fallback', 'Event'),
slug: `${Date.now()}`,
event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined,
status: (form.published ? 'published' : 'draft') as const,
settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
},
} as Parameters<typeof createEvent>[0];
setPendingPayload(payload);
setConsentOpen(true);
return;
}
const message = getApiValidationMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.'));
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
}
async function handleConsentConfirm(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
if (!pendingPayload) {
setConsentOpen(false);
return;
}
setConsentBusy(true);
try {
const { event } = await createEvent({
...pendingPayload,
accepted_waiver: consents.acceptedWaiver,
});
navigate(adminPath(`/mobile/events/${event.slug}`));
setConsentOpen(false);
setPendingPayload(null);
} catch (err) {
if (!isAuthError(err)) {
const message = getApiValidationMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.'));
@@ -137,7 +187,7 @@ export default function MobileEventFormPage() {
toast.error(message);
}
} finally {
setSaving(false);
setConsentBusy(false);
}
}
@@ -331,6 +381,37 @@ export default function MobileEventFormPage() {
onPress={() => handleSubmit()}
/>
</YStack>
<LegalConsentSheet
open={consentOpen}
onClose={() => {
if (consentBusy) return;
setConsentOpen(false);
setPendingPayload(null);
}}
onConfirm={handleConsentConfirm}
busy={consentBusy}
requireTerms={false}
requireWaiver
copy={{
title: t('events.eventStartConsent.title', 'Before your first event'),
description: t(
'events.eventStartConsent.description',
'Please confirm the immediate start of the digital service before creating your first event.',
),
checkboxWaiver: t(
'events.eventStartConsent.checkboxWaiver',
'I expressly request that the digital service begins now and understand my right of withdrawal expires once the contract has been fully performed.',
),
errorWaiver: t(
'events.eventStartConsent.errorWaiver',
'Please confirm the immediate start of the digital service and the early expiry of the right of withdrawal.',
),
confirm: t('events.eventStartConsent.confirm', 'Create event'),
cancel: t('events.eventStartConsent.cancel', 'Cancel'),
}}
t={t}
/>
</MobileShell>
);
}
@@ -364,6 +445,19 @@ function renderName(name: TenantEvent['name']): string {
return '';
}
function isWaiverRequiredError(error: unknown): boolean {
if (!isApiError(error)) {
return false;
}
const metaErrors = error.meta?.errors;
if (!metaErrors || typeof metaErrors !== 'object') {
return false;
}
return 'accepted_waiver' in metaErrors;
}
function toDateTimeLocal(value?: string | null): string {
if (!value) return '';

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import type { TenantPackageSummary } from '../../api';
import { buildPackageUsageMetrics, usagePercent } from '../billingUsage';
const basePackage: TenantPackageSummary = {
id: 1,
package_id: 1,
package_name: 'Pro',
active: true,
used_events: 2,
remaining_events: 3,
price: 120,
currency: 'EUR',
purchased_at: null,
expires_at: null,
package_limits: {
max_events_per_year: 5,
max_guests: 150,
max_photos: 1000,
},
branding_allowed: true,
watermark_allowed: true,
features: null,
};
describe('buildPackageUsageMetrics', () => {
it('builds usage metrics for event, guest, and photo limits', () => {
const metrics = buildPackageUsageMetrics(basePackage);
const keys = metrics.map((metric) => metric.key);
expect(keys).toEqual(['events', 'guests', 'photos']);
const eventMetric = metrics.find((metric) => metric.key === 'events');
expect(eventMetric?.used).toBe(2);
expect(eventMetric?.limit).toBe(5);
expect(eventMetric?.remaining).toBe(3);
});
it('filters metrics without limits', () => {
const metrics = buildPackageUsageMetrics({
...basePackage,
package_limits: { max_events_per_year: 0 },
});
expect(metrics).toHaveLength(0);
});
});
describe('usagePercent', () => {
it('calculates usage percent when usage is known', () => {
const metrics = buildPackageUsageMetrics(basePackage);
const eventMetric = metrics.find((metric) => metric.key === 'events');
expect(eventMetric).toBeTruthy();
expect(usagePercent(eventMetric!)).toBe(40);
});
it('defaults to full bar when usage is unknown', () => {
const metrics = buildPackageUsageMetrics({
...basePackage,
used_events: NaN,
remaining_events: null,
package_limits: { max_events_per_year: 10 },
});
expect(usagePercent(metrics[0])).toBe(100);
});
});

View File

@@ -0,0 +1,83 @@
import type { TenantPackageSummary } from '../api';
export type PackageUsageMetricKey = 'events' | 'guests' | 'photos';
export type PackageUsageMetric = {
key: PackageUsageMetricKey;
limit: number | null;
used: number | null;
remaining: number | null;
};
const toNumber = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
};
const resolveLimitValue = (limits: Record<string, unknown> | null, key: string): number | null => {
const value = limits ? toNumber(limits[key]) : null;
if (value === null || value <= 0) {
return null;
}
return value;
};
const resolveUsageValue = (value: unknown): number | null => {
const normalized = toNumber(value);
if (normalized === null || normalized < 0) {
return null;
}
return normalized;
};
const deriveUsedFromRemaining = (limit: number | null, remaining: number | null): number | null => {
if (limit === null || remaining === null) {
return null;
}
return Math.max(limit - remaining, 0);
};
export const buildPackageUsageMetrics = (pkg: TenantPackageSummary): PackageUsageMetric[] => {
const limits = pkg.package_limits ?? {};
const eventLimit = resolveLimitValue(limits, 'max_events_per_year');
const eventRemaining = resolveUsageValue(pkg.remaining_events);
const eventUsed = resolveUsageValue(pkg.used_events) ?? deriveUsedFromRemaining(eventLimit, eventRemaining);
const guestLimit = resolveLimitValue(limits, 'max_guests');
const guestRemaining = resolveUsageValue(limits['remaining_guests']);
const guestUsed = resolveUsageValue(limits['used_guests']) ?? deriveUsedFromRemaining(guestLimit, guestRemaining);
const photoLimit = resolveLimitValue(limits, 'max_photos');
const photoRemaining = resolveUsageValue(limits['remaining_photos']);
const photoUsed = resolveUsageValue(limits['used_photos']) ?? deriveUsedFromRemaining(photoLimit, photoRemaining);
return [
{ key: 'events', limit: eventLimit, used: eventUsed, remaining: eventRemaining },
{ key: 'guests', limit: guestLimit, used: guestUsed, remaining: guestRemaining },
{ key: 'photos', limit: photoLimit, used: photoUsed, remaining: photoRemaining },
].filter((metric) => metric.limit !== null);
};
export const usagePercent = (metric: PackageUsageMetric): number => {
if (!metric.limit || metric.limit <= 0) {
return 0;
}
if (metric.used === null || metric.used < 0) {
return 100;
}
return Math.min(100, Math.max(0, Math.round((metric.used / metric.limit) * 100)));
};

View File

@@ -11,11 +11,31 @@ type LegalConsentSheetProps = {
onClose: () => void;
onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => Promise<void> | void;
busy?: boolean;
requireTerms?: boolean;
requireWaiver?: boolean;
copy?: {
title?: string;
description?: string;
checkboxTerms?: string;
checkboxWaiver?: string;
errorTerms?: string;
errorWaiver?: string;
confirm?: string;
cancel?: string;
};
t: Translator;
};
export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requireWaiver = true, t }: LegalConsentSheetProps) {
export function LegalConsentSheet({
open,
onClose,
onConfirm,
busy = false,
requireTerms = true,
requireWaiver = true,
copy,
t,
}: LegalConsentSheetProps) {
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
@@ -29,25 +49,28 @@ export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requ
}, [open]);
async function handleConfirm() {
if (!acceptedTerms) {
setError(t('events.legalConsent.errorTerms', 'Please confirm the terms.'));
if (requireTerms && !acceptedTerms) {
setError(copy?.errorTerms ?? t('events.legalConsent.errorTerms', 'Please confirm the terms.'));
return;
}
if (requireWaiver && !acceptedWaiver) {
setError(t('events.legalConsent.errorWaiver', 'Please confirm the waiver.'));
setError(copy?.errorWaiver ?? t('events.legalConsent.errorWaiver', 'Please confirm the waiver.'));
return;
}
setError(null);
await onConfirm({ acceptedTerms, acceptedWaiver: requireWaiver ? acceptedWaiver : true });
await onConfirm({
acceptedTerms: requireTerms ? acceptedTerms : true,
acceptedWaiver: requireWaiver ? acceptedWaiver : true,
});
}
return (
<MobileSheet
open={open}
onClose={onClose}
title={t('events.legalConsent.title', 'Before purchase')}
title={copy?.title ?? t('events.legalConsent.title', 'Before purchase')}
footer={
<YStack space="$2">
{error ? (
@@ -55,29 +78,41 @@ export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requ
{error}
</Text>
) : null}
<CTAButton label={t('events.legalConsent.confirm', 'Continue to checkout')} onPress={handleConfirm} loading={busy} disabled={busy} />
<CTAButton label={t('events.legalConsent.cancel', 'Cancel')} tone="ghost" onPress={onClose} disabled={busy} />
<CTAButton
label={copy?.confirm ?? t('events.legalConsent.confirm', 'Continue to checkout')}
onPress={handleConfirm}
loading={busy}
disabled={busy}
/>
<CTAButton
label={copy?.cancel ?? t('events.legalConsent.cancel', 'Cancel')}
tone="ghost"
onPress={onClose}
disabled={busy}
/>
</YStack>
}
>
<YStack space="$2">
<Text fontSize="$sm" color="#111827">
{t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
{copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
</Text>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<input
type="checkbox"
checked={acceptedTerms}
onChange={(event) => setAcceptedTerms(event.target.checked)}
style={{ marginTop: 4, width: 16, height: 16 }}
/>
<Text fontSize="$sm" color="#111827">
{t(
'events.legalConsent.checkboxTerms',
'I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.',
)}
</Text>
</label>
{requireTerms ? (
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<input
type="checkbox"
checked={acceptedTerms}
onChange={(event) => setAcceptedTerms(event.target.checked)}
style={{ marginTop: 4, width: 16, height: 16 }}
/>
<Text fontSize="$sm" color="#111827">
{copy?.checkboxTerms ?? t(
'events.legalConsent.checkboxTerms',
'I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.',
)}
</Text>
</label>
) : null}
{requireWaiver ? (
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<input
@@ -87,7 +122,7 @@ export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requ
style={{ marginTop: 4, width: 16, height: 16 }}
/>
<Text fontSize="$sm" color="#111827">
{t(
{copy?.checkboxWaiver ?? t(
'events.legalConsent.checkboxWaiver',
'I expressly request immediate provision of the digital service and understand my right of withdrawal expires once fulfilled.',
)}

View File

@@ -73,16 +73,29 @@ export function CTAButton({
onPress,
tone = 'primary',
fullWidth = true,
disabled = false,
loading = false,
}: {
label: string;
onPress: () => void;
tone?: 'primary' | 'ghost';
fullWidth?: boolean;
disabled?: boolean;
loading?: boolean;
}) {
const theme = useTheme();
const isPrimary = tone === 'primary';
const isDisabled = disabled || loading;
return (
<Pressable onPress={onPress} style={{ width: fullWidth ? '100%' : undefined, flex: fullWidth ? undefined : 1 }}>
<Pressable
onPress={isDisabled ? undefined : onPress}
disabled={isDisabled}
style={{
width: fullWidth ? '100%' : undefined,
flex: fullWidth ? undefined : 1,
opacity: isDisabled ? 0.6 : 1,
}}
>
<XStack
height={56}
borderRadius={14}