From ece38fc00925b354207e0b7b9165fb0079da3006 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 17 Dec 2025 17:24:26 +0100 Subject: [PATCH] completed addon checkout in mobile event admin --- .../Addons/EventAddonCheckoutService.php | 13 +- .../js/admin/i18n/locales/de/management.json | 10 ++ .../js/admin/i18n/locales/en/management.json | 10 ++ resources/js/admin/mobile/EventPhotosPage.tsx | 116 +++++++++++------- resources/js/admin/mobile/EventRecapPage.tsx | 33 ++++- resources/js/admin/mobile/EventTasksPage.tsx | 53 ++++++-- .../mobile/components/LegalConsentSheet.tsx | 100 +++++++++++++++ 7 files changed, 277 insertions(+), 58 deletions(-) create mode 100644 resources/js/admin/mobile/components/LegalConsentSheet.tsx diff --git a/app/Services/Addons/EventAddonCheckoutService.php b/app/Services/Addons/EventAddonCheckoutService.php index 8b52f91..0d6dc80 100644 --- a/app/Services/Addons/EventAddonCheckoutService.php +++ b/app/Services/Addons/EventAddonCheckoutService.php @@ -5,17 +5,20 @@ namespace App\Services\Addons; use App\Models\Event; use App\Models\EventPackageAddon; use App\Models\Tenant; +use App\Services\Paddle\PaddleCustomerService; use App\Services\Paddle\PaddleClient; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Throwable; class EventAddonCheckoutService { public function __construct( private readonly EventAddonCatalog $catalog, private readonly PaddleClient $paddle, + private readonly PaddleCustomerService $customers, ) {} /** @@ -29,6 +32,14 @@ class EventAddonCheckoutService $acceptedWaiver = (bool) ($payload['accepted_waiver'] ?? false); $acceptedTerms = (bool) ($payload['accepted_terms'] ?? false); + try { + $customerId = $this->customers->ensureCustomerId($tenant); + } catch (Throwable $exception) { + throw ValidationException::withMessages([ + 'customer' => __('Konnte Paddle-Kundenkonto nicht anlegen: :message', ['message' => $exception->getMessage()]), + ]); + } + if (! $addonKey || ! $this->catalog->find($addonKey)) { throw ValidationException::withMessages([ 'addon_key' => __('Unbekanntes Add-on.'), @@ -68,7 +79,7 @@ class EventAddonCheckoutService ]; $requestPayload = array_filter([ - 'customer_id' => $tenant->paddle_customer_id, + 'customer_id' => $customerId, 'items' => [ [ 'price_id' => $priceId, diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 83a01f6..1b91e2a 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -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" }, diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 8fc97cc..aefc6c1 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -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" }, diff --git a/resources/js/admin/mobile/EventPhotosPage.tsx b/resources/js/admin/mobile/EventPhotosPage.tsx index f32884b..9849f57 100644 --- a/resources/js/admin/mobile/EventPhotosPage.tsx +++ b/resources/js/admin/mobile/EventPhotosPage.tsx @@ -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([]); const [eventAddons, setEventAddons] = React.useState([]); const [busyScope, setBusyScope] = React.useState(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 ( { void handleCheckout(scopeOrKey, slug, catalogAddons, setBusyScope, t); }} + onCheckout={startAddonCheckout} busyScope={busyScope} translate={translateLimits(t)} textColor={text} @@ -465,6 +525,18 @@ export default function MobileEventPhotosPage() { ) : null} + + { + if (consentBusy) return; + setConsentOpen(false); + setConsentTarget(null); + }} + onConfirm={confirmAddonCheckout} + busy={consentBusy} + t={t} + /> ); } @@ -648,45 +720,3 @@ function EventAddonList({ addons, textColor, mutedColor }: { addons: EventAddonS ); } - -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 { - 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); - } -} diff --git a/resources/js/admin/mobile/EventRecapPage.tsx b/resources/js/admin/mobile/EventRecapPage.tsx index 344be0f..ad74b4d 100644 --- a/resources/js/admin/mobile/EventRecapPage.tsx +++ b/resources/js/admin/mobile/EventRecapPage.tsx @@ -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(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() { { - void checkoutAddon(); + startAddonCheckout(); }} loading={checkoutBusy} /> @@ -338,6 +354,17 @@ export default function MobileEventRecapPage() { ) : null} + { + if (consentBusy) return; + setConsentOpen(false); + setConsentAddonKey(null); + }} + onConfirm={confirmAddonCheckout} + busy={consentBusy} + t={t} + /> ); } diff --git a/resources/js/admin/mobile/EventTasksPage.tsx b/resources/js/admin/mobile/EventTasksPage.tsx index 84680a5..16f2266 100644 --- a/resources/js/admin/mobile/EventTasksPage.tsx +++ b/resources/js/admin/mobile/EventTasksPage.tsx @@ -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() { setShowTaskSheet(true)}> } /> setShowCollectionSheet(true)}> } /> @@ -502,6 +508,8 @@ export default function MobileEventTasksPage() { startEdit(task)}> {task.title} @@ -522,6 +530,7 @@ export default function MobileEventTasksPage() { detachTask(task.id)}> + } paddingVertical="$2" @@ -556,6 +565,8 @@ export default function MobileEventTasksPage() { {(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => ( {task.title} @@ -569,11 +580,14 @@ export default function MobileEventTasksPage() { ) : null } iconAfter={ - quickAssign(task.id)}> - - {assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')} - - + + quickAssign(task.id)}> + + {assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')} + + + + } paddingVertical="$2" paddingHorizontal="$3" @@ -609,6 +623,8 @@ export default function MobileEventTasksPage() { {(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => ( {collection.name} @@ -622,11 +638,14 @@ export default function MobileEventTasksPage() { ) : null } iconAfter={ - importCollection(collection.id)}> - - {t('events.tasks.import', 'Import')} - - + + importCollection(collection.id)}> + + {t('events.tasks.import', 'Import')} + + + + } paddingVertical="$2" paddingHorizontal="$3" @@ -738,6 +757,8 @@ export default function MobileEventTasksPage() { {emotions.map((em) => ( @@ -756,6 +777,7 @@ export default function MobileEventTasksPage() { removeEmotion(em.id)}> + } /> @@ -829,6 +851,8 @@ export default function MobileEventTasksPage() { > {t('events.tasks.addTask', 'Aufgabe hinzufügen')} @@ -840,8 +864,11 @@ export default function MobileEventTasksPage() { }} paddingVertical="$2" paddingHorizontal="$3" + iconAfter={} /> {t('events.tasks.bulkAdd', 'Bulk add')} @@ -853,8 +880,11 @@ export default function MobileEventTasksPage() { }} paddingVertical="$2" paddingHorizontal="$3" + iconAfter={} /> {t('events.tasks.manageEmotions', 'Manage emotions')} @@ -871,6 +901,7 @@ export default function MobileEventTasksPage() { }} paddingVertical="$2" paddingHorizontal="$3" + iconAfter={} /> diff --git a/resources/js/admin/mobile/components/LegalConsentSheet.tsx b/resources/js/admin/mobile/components/LegalConsentSheet.tsx new file mode 100644 index 0000000..7c43731 --- /dev/null +++ b/resources/js/admin/mobile/components/LegalConsentSheet.tsx @@ -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; + 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(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 ( + + {error ? ( + + {error} + + ) : null} + + + + } + > + + + {t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')} + + + {requireWaiver ? ( + + ) : null} + + + ); +}