completed addon checkout in mobile event admin

This commit is contained in:
Codex Agent
2025-12-17 17:24:26 +01:00
parent 5f3e7ae8c8
commit ece38fc009
7 changed files with 277 additions and 58 deletions

View File

@@ -325,6 +325,16 @@
"success": {
"addonApplied": "Add-on angewendet. Limits aktualisieren sich in Kürze."
},
"legalConsent": {
"title": "Vor dem Kauf",
"description": "Bitte bestätige die rechtlichen Hinweise, bevor du ein Add-on kaufst.",
"checkboxTerms": "Ich habe die AGB, die Datenschutzerklärung und die Widerrufsbelehrung gelesen und akzeptiere sie.",
"checkboxWaiver": "Ich verlange ausdrücklich, dass mit der Bereitstellung der digitalen Leistung (Aktivierung meines Event-Add-ons) vor Ablauf der Widerrufsfrist begonnen wird. Mir ist bekannt, dass ich mein Widerrufsrecht verliere, sobald der Vertrag vollständig erfüllt ist.",
"errorTerms": "Bitte bestätige AGB, Datenschutzerklärung und Widerrufsbelehrung.",
"errorWaiver": "Bitte bestätige den sofortigen Leistungsbeginn und das vorzeitige Erlöschen des Widerrufsrechts.",
"confirm": "Weiter zum Checkout",
"cancel": "Abbrechen"
},
"placeholders": {
"untitled": "Unbenanntes Event"
},

View File

@@ -928,6 +928,16 @@
"success": {
"addonApplied": "Add-on applied. Limits will refresh shortly."
},
"legalConsent": {
"title": "Before purchase",
"description": "Please confirm the legal notes before buying an add-on.",
"checkboxTerms": "I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.",
"checkboxWaiver": "I expressly request that you begin providing the digital services (activation of my event add-on) before the withdrawal period has expired. I understand that I lose my right of withdrawal once the contract has been fully performed.",
"errorTerms": "Please confirm you accept the terms, privacy policy, and right of withdrawal.",
"errorWaiver": "Please confirm the immediate start of the digital service and the early expiry of the right of withdrawal.",
"confirm": "Continue to checkout",
"cancel": "Cancel"
},
"placeholders": {
"untitled": "Untitled event"
},

View File

@@ -30,6 +30,7 @@ import { useTheme } from '@tamagui/core';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { adminPath } from '../constants';
import { scopeDefaults, selectAddonKeyForScope } from './addons';
import { LegalConsentSheet } from './components/LegalConsentSheet';
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
@@ -59,6 +60,9 @@ export default function MobileEventPhotosPage() {
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
const [busyScope, setBusyScope] = React.useState<string | null>(null);
const [consentOpen, setConsentOpen] = React.useState(false);
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
const [consentBusy, setConsentBusy] = React.useState(false);
const theme = useTheme();
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563');
@@ -209,6 +213,62 @@ export default function MobileEventPhotosPage() {
}
}
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
const scope =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? scopeOrKey
: scopeOrKey.includes('gallery')
? 'gallery'
: scopeOrKey.includes('guest')
? 'guests'
: 'photos';
const addonKey =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? selectAddonKeyForScope(catalogAddons, scope)
: scopeOrKey;
return { scope, addonKey };
}
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
if (!slug) return;
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
setConsentTarget({ scope, addonKey });
setConsentOpen(true);
}
async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
if (!slug || !consentTarget) return;
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/photos`)}` : '';
const successUrl = `${currentUrl}?addon_success=1`;
setBusyScope(consentTarget.scope);
setConsentBusy(true);
try {
const checkout = await createEventAddonCheckout(slug, {
addon_key: consentTarget.addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
accepted_terms: consents.acceptedTerms,
accepted_waiver: consents.acceptedWaiver,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast.error(t('events.errors.checkoutMissing', 'Checkout could not be started.'));
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on checkout failed.')));
} finally {
setConsentBusy(false);
setConsentOpen(false);
setConsentTarget(null);
setBusyScope(null);
}
}
return (
<MobileShell
activeTab="uploads"
@@ -270,7 +330,7 @@ export default function MobileEventPhotosPage() {
<LimitWarnings
limits={limits}
addons={catalogAddons}
onCheckout={(scopeOrKey) => { void handleCheckout(scopeOrKey, slug, catalogAddons, setBusyScope, t); }}
onCheckout={startAddonCheckout}
busyScope={busyScope}
translate={translateLimits(t)}
textColor={text}
@@ -465,6 +525,18 @@ export default function MobileEventPhotosPage() {
<EventAddonList addons={eventAddons} textColor={text} mutedColor={muted} />
</YStack>
) : null}
<LegalConsentSheet
open={consentOpen}
onClose={() => {
if (consentBusy) return;
setConsentOpen(false);
setConsentTarget(null);
}}
onConfirm={confirmAddonCheckout}
busy={consentBusy}
t={t}
/>
</MobileShell>
);
}
@@ -648,45 +720,3 @@ function EventAddonList({ addons, textColor, mutedColor }: { addons: EventAddonS
</YStack>
);
}
async function handleCheckout(
scopeOrKey: 'photos' | 'gallery' | 'guests' | string,
slug: string | null,
addons: EventAddonCatalogItem[],
setBusyScope: (scope: string | null) => void,
t: (key: string, defaultValue?: string) => string,
): Promise<void> {
if (!slug) return;
const scope =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? scopeOrKey
: scopeOrKey.includes('gallery')
? 'gallery'
: scopeOrKey.includes('guest')
? 'guests'
: 'photos';
const addonKey =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? selectAddonKeyForScope(addons, scope)
: scopeOrKey;
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/photos`)}` : '';
const successUrl = `${currentUrl}?addon_success=1`;
setBusyScope(scope);
try {
const checkout = await createEventAddonCheckout(slug, {
addon_key: addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast.error(t('mobileBilling.checkoutUnavailable', 'Checkout unavailable right now.'));
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('mobileBilling.checkoutFailed', 'Checkout failed.')));
} finally {
setBusyScope(null);
}
}

View File

@@ -26,6 +26,7 @@ import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { adminPath } from '../constants';
import { selectAddonKeyForScope } from './addons';
import { LegalConsentSheet } from './components/LegalConsentSheet';
export default function MobileEventRecapPage() {
const { slug } = useParams<{ slug?: string }>();
@@ -42,6 +43,9 @@ export default function MobileEventRecapPage() {
const [busy, setBusy] = React.useState(false);
const [archiveBusy, setArchiveBusy] = React.useState(false);
const [checkoutBusy, setCheckoutBusy] = React.useState(false);
const [consentOpen, setConsentOpen] = React.useState(false);
const [consentBusy, setConsentBusy] = React.useState(false);
const [consentAddonKey, setConsentAddonKey] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
if (!slug) return;
@@ -131,18 +135,27 @@ export default function MobileEventRecapPage() {
}
}
async function checkoutAddon() {
function startAddonCheckout() {
if (!slug) return;
const addonKey = selectAddonKeyForScope(addons, 'gallery');
setConsentAddonKey(addonKey);
setConsentOpen(true);
}
async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
if (!slug || !consentAddonKey) return;
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/recap`)}` : '';
const successUrl = `${currentUrl}?addon_success=1`;
setCheckoutBusy(true);
setConsentBusy(true);
try {
const checkout = await createEventAddonCheckout(slug, {
addon_key: addonKey,
addon_key: consentAddonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
accepted_terms: consents.acceptedTerms,
accepted_waiver: consents.acceptedWaiver,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
@@ -155,6 +168,9 @@ export default function MobileEventRecapPage() {
}
} finally {
setCheckoutBusy(false);
setConsentBusy(false);
setConsentOpen(false);
setConsentAddonKey(null);
}
}
@@ -285,7 +301,7 @@ export default function MobileEventRecapPage() {
<CTAButton
label={t('events.recap.extendGallery', 'Galerie verlängern')}
onPress={() => {
void checkoutAddon();
startAddonCheckout();
}}
loading={checkoutBusy}
/>
@@ -338,6 +354,17 @@ export default function MobileEventRecapPage() {
</MobileCard>
</YStack>
) : null}
<LegalConsentSheet
open={consentOpen}
onClose={() => {
if (consentBusy) return;
setConsentOpen(false);
setConsentAddonKey(null);
}}
onConfirm={confirmAddonCheckout}
busy={consentBusy}
t={t}
/>
</MobileShell>
);
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown } from 'lucide-react';
import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { ListItem } from '@tamagui/list-item';
@@ -408,6 +408,8 @@ export default function MobileEventTasksPage() {
<YStack borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
<Pressable onPress={() => setShowTaskSheet(true)}>
<ListItem
hoverTheme
pressTheme
title={
<XStack alignItems="center" space="$2">
<YStack
@@ -432,11 +434,14 @@ export default function MobileEventTasksPage() {
}
paddingVertical="$2"
paddingHorizontal="$3"
iconAfter={<ChevronRight size={16} color={muted} />}
/>
</Pressable>
<InlineSeparator />
<Pressable onPress={() => setShowCollectionSheet(true)}>
<ListItem
hoverTheme
pressTheme
title={
<XStack alignItems="center" space="$2">
<YStack
@@ -461,6 +466,7 @@ export default function MobileEventTasksPage() {
}
paddingVertical="$2"
paddingHorizontal="$3"
iconAfter={<ChevronRight size={16} color={muted} />}
/>
</Pressable>
</YStack>
@@ -502,6 +508,8 @@ export default function MobileEventTasksPage() {
<React.Fragment key={task.id}>
<Pressable onPress={() => startEdit(task)}>
<ListItem
hoverTheme
pressTheme
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{task.title}
@@ -522,6 +530,7 @@ export default function MobileEventTasksPage() {
<Pressable disabled={busyId === task.id} onPress={() => detachTask(task.id)}>
<Trash2 size={14} color={danger} />
</Pressable>
<ChevronRight size={14} color={subtle} />
</XStack>
}
paddingVertical="$2"
@@ -556,6 +565,8 @@ export default function MobileEventTasksPage() {
{(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => (
<React.Fragment key={`lib-${task.id}`}>
<ListItem
hoverTheme
pressTheme
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{task.title}
@@ -569,11 +580,14 @@ export default function MobileEventTasksPage() {
) : null
}
iconAfter={
<Pressable onPress={() => quickAssign(task.id)}>
<Text fontSize={12} fontWeight="600" color={primary}>
{assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')}
</Text>
</Pressable>
<XStack space="$1.5" alignItems="center">
<Pressable onPress={() => quickAssign(task.id)}>
<Text fontSize={12} fontWeight="600" color={primary}>
{assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')}
</Text>
</Pressable>
<ChevronRight size={14} color={subtle} />
</XStack>
}
paddingVertical="$2"
paddingHorizontal="$3"
@@ -609,6 +623,8 @@ export default function MobileEventTasksPage() {
{(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => (
<React.Fragment key={collection.id}>
<ListItem
hoverTheme
pressTheme
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{collection.name}
@@ -622,11 +638,14 @@ export default function MobileEventTasksPage() {
) : null
}
iconAfter={
<Pressable onPress={() => importCollection(collection.id)}>
<Text fontSize={12} fontWeight="600" color={primary}>
{t('events.tasks.import', 'Import')}
</Text>
</Pressable>
<XStack space="$1.5" alignItems="center">
<Pressable onPress={() => importCollection(collection.id)}>
<Text fontSize={12} fontWeight="600" color={primary}>
{t('events.tasks.import', 'Import')}
</Text>
</Pressable>
<ChevronRight size={14} color={subtle} />
</XStack>
}
paddingVertical="$2"
paddingHorizontal="$3"
@@ -738,6 +757,8 @@ export default function MobileEventTasksPage() {
{emotions.map((em) => (
<ListItem
key={`emo-${em.id}`}
hoverTheme
pressTheme
title={
<XStack alignItems="center" space="$2">
<Tag label={em.name ?? ''} color={em.color ?? border} />
@@ -756,6 +777,7 @@ export default function MobileEventTasksPage() {
<Pressable onPress={() => removeEmotion(em.id)}>
<Trash2 size={14} color={danger} />
</Pressable>
<ChevronRight size={14} color={subtle} />
</XStack>
}
/>
@@ -829,6 +851,8 @@ export default function MobileEventTasksPage() {
>
<YStack space="$1">
<ListItem
hoverTheme
pressTheme
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.addTask', 'Aufgabe hinzufügen')}
@@ -840,8 +864,11 @@ export default function MobileEventTasksPage() {
}}
paddingVertical="$2"
paddingHorizontal="$3"
iconAfter={<ChevronRight size={14} color={subtle} />}
/>
<ListItem
hoverTheme
pressTheme
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.bulkAdd', 'Bulk add')}
@@ -853,8 +880,11 @@ export default function MobileEventTasksPage() {
}}
paddingVertical="$2"
paddingHorizontal="$3"
iconAfter={<ChevronRight size={14} color={subtle} />}
/>
<ListItem
hoverTheme
pressTheme
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.manageEmotions', 'Manage emotions')}
@@ -871,6 +901,7 @@ export default function MobileEventTasksPage() {
}}
paddingVertical="$2"
paddingHorizontal="$3"
iconAfter={<ChevronRight size={14} color={subtle} />}
/>
</YStack>
</MobileSheet>

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { MobileSheet } from './Sheet';
import { CTAButton } from './Primitives';
type Translator = (key: string, defaultValue?: string) => string;
type LegalConsentSheetProps = {
open: boolean;
onClose: () => void;
onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => Promise<void> | void;
busy?: boolean;
requireWaiver?: boolean;
t: Translator;
};
export function LegalConsentSheet({ open, onClose, onConfirm, busy = false, requireWaiver = true, t }: LegalConsentSheetProps) {
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (open) {
setAcceptedTerms(false);
setAcceptedWaiver(false);
setError(null);
}
}, [open]);
async function handleConfirm() {
if (!acceptedTerms) {
setError(t('events.legalConsent.errorTerms', 'Please confirm the terms.'));
return;
}
if (requireWaiver && !acceptedWaiver) {
setError(t('events.legalConsent.errorWaiver', 'Please confirm the waiver.'));
return;
}
setError(null);
await onConfirm({ acceptedTerms, acceptedWaiver: requireWaiver ? acceptedWaiver : true });
}
return (
<MobileSheet
open={open}
onClose={onClose}
title={t('events.legalConsent.title', 'Before purchase')}
footer={
<YStack space="$2">
{error ? (
<Text fontSize="$sm" color="#b91c1c">
{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} />
</YStack>
}
>
<YStack space="$2">
<Text fontSize="$sm" color="#111827">
{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>
{requireWaiver ? (
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<input
type="checkbox"
checked={acceptedWaiver}
onChange={(event) => setAcceptedWaiver(event.target.checked)}
style={{ marginTop: 4, width: 16, height: 16 }}
/>
<Text fontSize="$sm" color="#111827">
{t(
'events.legalConsent.checkboxWaiver',
'I expressly request immediate provision of the digital service and understand my right of withdrawal expires once fulfilled.',
)}
</Text>
</label>
) : null}
</YStack>
</MobileSheet>
);
}