Änderungen (relevant):
- Add‑on Checkout auf Transactions + Transaction‑ID speichern: app/Services/Addons/EventAddonCheckoutService.php
- Paket/Marketing Checkout auf Transactions: app/Services/Paddle/PaddleCheckoutService.php
- Gift‑Voucher Checkout: Customer anlegen/finden + Transactions: app/Services/GiftVouchers/
GiftVoucherCheckoutService.php
- Tests aktualisiert: tests/Feature/Tenant/EventAddonCheckoutTest.php, tests/Unit/PaddleCheckoutServiceTest.php,
tests/Unit/GiftVoucherCheckoutServiceTest.php
This commit is contained in:
@@ -305,6 +305,8 @@ export type TenantOnboardingStatus = {
|
||||
admin_app_opened_at?: string | null;
|
||||
primary_event_id?: number | string | null;
|
||||
selected_packages?: unknown;
|
||||
dismissed_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
branding_completed?: boolean;
|
||||
tasks_configured?: boolean;
|
||||
event_created?: boolean;
|
||||
|
||||
@@ -2217,6 +2217,24 @@
|
||||
"checkoutMissing": "Checkout konnte nicht gestartet werden.",
|
||||
"checkoutFailed": "Add-on Checkout fehlgeschlagen."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -2221,6 +2221,24 @@
|
||||
"checkoutMissing": "Checkout could not be started.",
|
||||
"checkoutFailed": "Add-on checkout failed."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { MobileSheet } from './Sheet';
|
||||
import { CTAButton } from './Primitives';
|
||||
|
||||
@@ -36,9 +37,24 @@ export function LegalConsentSheet({
|
||||
copy,
|
||||
t,
|
||||
}: LegalConsentSheetProps) {
|
||||
const theme = useTheme();
|
||||
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
||||
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const checkboxAccent = String(theme.primary?.val ?? '#2563eb');
|
||||
const checkboxBorder = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const checkboxSurface = String(theme.surface?.val ?? '#ffffff');
|
||||
const checkboxStyle = {
|
||||
marginTop: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
accentColor: checkboxAccent,
|
||||
backgroundColor: checkboxSurface,
|
||||
border: `1px solid ${checkboxBorder}`,
|
||||
borderRadius: 4,
|
||||
appearance: 'auto',
|
||||
WebkitAppearance: 'auto',
|
||||
} as const;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
@@ -103,7 +119,7 @@ export function LegalConsentSheet({
|
||||
type="checkbox"
|
||||
checked={acceptedTerms}
|
||||
onChange={(event) => setAcceptedTerms(event.target.checked)}
|
||||
style={{ marginTop: 4, width: 16, height: 16 }}
|
||||
style={checkboxStyle}
|
||||
/>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{copy?.checkboxTerms ?? t(
|
||||
@@ -119,7 +135,7 @@ export function LegalConsentSheet({
|
||||
type="checkbox"
|
||||
checked={acceptedWaiver}
|
||||
onChange={(event) => setAcceptedWaiver(event.target.checked)}
|
||||
style={{ marginTop: 4, width: 16, height: 16 }}
|
||||
style={checkboxStyle}
|
||||
/>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{copy?.checkboxWaiver ?? t(
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
vi.mock('@tamagui/core', () => ({
|
||||
useTheme: () => ({
|
||||
primary: { val: '#2563eb' },
|
||||
borderColor: { val: '#e5e7eb' },
|
||||
surface: { val: '#ffffff' },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
}));
|
||||
|
||||
import { LegalConsentSheet } from '../LegalConsentSheet';
|
||||
|
||||
describe('LegalConsentSheet', () => {
|
||||
it('renders the required consent checkboxes when open', () => {
|
||||
const { getAllByRole } = render(
|
||||
<LegalConsentSheet
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
t={(key, fallback) => fallback ?? key}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getAllByRole('checkbox')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveOnboardingRedirect } from './onboardingGuard';
|
||||
import {
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
ADMIN_WELCOME_EVENT_PATH,
|
||||
ADMIN_WELCOME_PACKAGES_PATH,
|
||||
ADMIN_WELCOME_SUMMARY_PATH,
|
||||
} from '../../constants';
|
||||
|
||||
@@ -15,6 +15,8 @@ describe('resolveOnboardingRedirect', () => {
|
||||
pathname: '/event-admin/mobile/dashboard',
|
||||
isWelcomePath: false,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: false,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -24,9 +26,11 @@ describe('resolveOnboardingRedirect', () => {
|
||||
hasEvents: false,
|
||||
hasActivePackage: false,
|
||||
selectedPackageId: null,
|
||||
pathname: ADMIN_WELCOME_PACKAGES_PATH,
|
||||
pathname: ADMIN_WELCOME_BASE_PATH,
|
||||
isWelcomePath: true,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: false,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -39,6 +43,8 @@ describe('resolveOnboardingRedirect', () => {
|
||||
pathname: '/event-admin/mobile/billing',
|
||||
isWelcomePath: false,
|
||||
isBillingPath: true,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: false,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -51,6 +57,8 @@ describe('resolveOnboardingRedirect', () => {
|
||||
pathname: '/event-admin/mobile/dashboard',
|
||||
isWelcomePath: false,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: false,
|
||||
});
|
||||
expect(result).toBe(ADMIN_WELCOME_EVENT_PATH);
|
||||
});
|
||||
@@ -63,11 +71,13 @@ describe('resolveOnboardingRedirect', () => {
|
||||
pathname: '/event-admin/mobile/dashboard',
|
||||
isWelcomePath: false,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: false,
|
||||
});
|
||||
expect(result).toBe(ADMIN_WELCOME_SUMMARY_PATH);
|
||||
});
|
||||
|
||||
it('redirects to packages when no selection exists', () => {
|
||||
it('redirects to landing when no selection exists', () => {
|
||||
const result = resolveOnboardingRedirect({
|
||||
hasEvents: false,
|
||||
hasActivePackage: false,
|
||||
@@ -75,8 +85,10 @@ describe('resolveOnboardingRedirect', () => {
|
||||
pathname: '/event-admin/mobile/dashboard',
|
||||
isWelcomePath: false,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: false,
|
||||
});
|
||||
expect(result).toBe(ADMIN_WELCOME_PACKAGES_PATH);
|
||||
expect(result).toBe(ADMIN_WELCOME_BASE_PATH);
|
||||
});
|
||||
|
||||
it('does not redirect when already on target', () => {
|
||||
@@ -84,9 +96,39 @@ describe('resolveOnboardingRedirect', () => {
|
||||
hasEvents: false,
|
||||
hasActivePackage: false,
|
||||
selectedPackageId: null,
|
||||
pathname: ADMIN_WELCOME_PACKAGES_PATH,
|
||||
pathname: ADMIN_WELCOME_BASE_PATH,
|
||||
isWelcomePath: false,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: false,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('does not redirect when onboarding is dismissed', () => {
|
||||
const result = resolveOnboardingRedirect({
|
||||
hasEvents: false,
|
||||
hasActivePackage: false,
|
||||
selectedPackageId: null,
|
||||
pathname: '/event-admin/mobile/dashboard',
|
||||
isWelcomePath: false,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: true,
|
||||
isOnboardingCompleted: false,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('does not redirect when onboarding is completed', () => {
|
||||
const result = resolveOnboardingRedirect({
|
||||
hasEvents: false,
|
||||
hasActivePackage: false,
|
||||
selectedPackageId: null,
|
||||
pathname: '/event-admin/mobile/dashboard',
|
||||
isWelcomePath: false,
|
||||
isBillingPath: false,
|
||||
isOnboardingDismissed: false,
|
||||
isOnboardingCompleted: true,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
ADMIN_WELCOME_EVENT_PATH,
|
||||
ADMIN_WELCOME_PACKAGES_PATH,
|
||||
ADMIN_WELCOME_SUMMARY_PATH,
|
||||
} from '../../constants';
|
||||
|
||||
@@ -11,6 +11,8 @@ type OnboardingRedirectInput = {
|
||||
pathname: string;
|
||||
isWelcomePath: boolean;
|
||||
isBillingPath: boolean;
|
||||
isOnboardingDismissed?: boolean;
|
||||
isOnboardingCompleted?: boolean;
|
||||
};
|
||||
|
||||
export function resolveOnboardingRedirect({
|
||||
@@ -20,11 +22,17 @@ export function resolveOnboardingRedirect({
|
||||
pathname,
|
||||
isWelcomePath,
|
||||
isBillingPath,
|
||||
isOnboardingDismissed,
|
||||
isOnboardingCompleted,
|
||||
}: OnboardingRedirectInput): string | null {
|
||||
if (hasEvents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isOnboardingDismissed || isOnboardingCompleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isWelcomePath || isBillingPath) {
|
||||
return null;
|
||||
}
|
||||
@@ -34,7 +42,7 @@ export function resolveOnboardingRedirect({
|
||||
? ADMIN_WELCOME_EVENT_PATH
|
||||
: shouldContinueSummary
|
||||
? ADMIN_WELCOME_SUMMARY_PATH
|
||||
: ADMIN_WELCOME_PACKAGES_PATH;
|
||||
: ADMIN_WELCOME_BASE_PATH;
|
||||
|
||||
if (pathname === target) {
|
||||
return null;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SizableText as Text } from '@tamagui/text';
|
||||
import { OnboardingShell } from '../components/OnboardingShell';
|
||||
import { MobileCard, CTAButton } from '../components/Primitives';
|
||||
import { ADMIN_HOME_PATH, ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_PACKAGES_PATH, ADMIN_WELCOME_SUMMARY_PATH, adminPath } from '../../constants';
|
||||
import { getTenantPackagesOverview } from '../../api';
|
||||
import { getTenantPackagesOverview, trackOnboarding } from '../../api';
|
||||
import { getSelectedPackageId } from '../lib/onboardingSelection';
|
||||
|
||||
export default function WelcomeEventPage() {
|
||||
@@ -24,6 +24,10 @@ export default function WelcomeEventPage() {
|
||||
|
||||
const hasActivePackage =
|
||||
Boolean(overview?.activePackage) || Boolean(overview?.packages?.some((pkg) => pkg.active));
|
||||
const handleSkip = React.useCallback(() => {
|
||||
void trackOnboarding('dismissed');
|
||||
navigate(ADMIN_HOME_PATH);
|
||||
}, [navigate]);
|
||||
|
||||
const backTarget = selectedId
|
||||
? ADMIN_WELCOME_SUMMARY_PATH
|
||||
@@ -40,7 +44,7 @@ export default function WelcomeEventPage() {
|
||||
'Fill in a few details, invite co-hosts, and open your guest gallery for the big day.',
|
||||
)}
|
||||
onBack={() => navigate(backTarget)}
|
||||
onSkip={() => navigate(ADMIN_HOME_PATH)}
|
||||
onSkip={handleSkip}
|
||||
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
|
||||
>
|
||||
<MobileCard space="$3">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { MobileCard, CTAButton, PillBadge } from '../components/Primitives';
|
||||
import { OnboardingShell } from '../components/OnboardingShell';
|
||||
import { getTenantPackagesOverview } from '../../api';
|
||||
import { getTenantPackagesOverview, trackOnboarding } from '../../api';
|
||||
import { useEventContext } from '../../context/EventContext';
|
||||
import {
|
||||
ADMIN_HOME_PATH,
|
||||
@@ -29,6 +29,10 @@ export default function WelcomeLandingPage() {
|
||||
|
||||
const hasActivePackage =
|
||||
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
|
||||
const handleSkip = React.useCallback(() => {
|
||||
void trackOnboarding('dismissed');
|
||||
navigate(ADMIN_HOME_PATH);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<OnboardingShell
|
||||
@@ -38,7 +42,7 @@ export default function WelcomeLandingPage() {
|
||||
'layout.subtitle',
|
||||
'Begin with an inspired introduction, secure your package, and craft the perfect guest gallery – all optimised for mobile hosts.',
|
||||
)}
|
||||
onSkip={() => navigate(ADMIN_HOME_PATH)}
|
||||
onSkip={handleSkip}
|
||||
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
|
||||
>
|
||||
<MobileCard space="$3">
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ADMIN_PUBLIC_LANDING_PATH,
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
} from './constants';
|
||||
import { getTenantPackagesOverview } from './api';
|
||||
import { fetchOnboardingStatus, getTenantPackagesOverview } from './api';
|
||||
import { getSelectedPackageId } from './mobile/lib/onboardingSelection';
|
||||
import { resolveOnboardingRedirect } from './mobile/lib/onboardingGuard';
|
||||
const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
|
||||
@@ -47,14 +47,15 @@ const MobileWelcomeSummaryPage = React.lazy(() => import('./mobile/welcome/Welco
|
||||
const MobileWelcomeEventPage = React.lazy(() => import('./mobile/welcome/WelcomeEventPage'));
|
||||
|
||||
function RequireAuth() {
|
||||
const { status } = useAuth();
|
||||
const { status, user } = useAuth();
|
||||
const location = useLocation();
|
||||
const { hasEvents, isLoading: eventsLoading } = useEventContext();
|
||||
const selectedPackageId = getSelectedPackageId();
|
||||
const isWelcomePath = location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH);
|
||||
const isBillingPath = location.pathname.startsWith(ADMIN_BILLING_PATH);
|
||||
const isTenantAdmin = Boolean(user && user.role !== 'member');
|
||||
const shouldCheckPackages =
|
||||
status === 'authenticated' && !eventsLoading && !hasEvents && !isWelcomePath && !isBillingPath;
|
||||
status === 'authenticated' && isTenantAdmin && !eventsLoading && !hasEvents && !isWelcomePath && !isBillingPath;
|
||||
|
||||
const { data: packagesData, isLoading: packagesLoading } = useQuery({
|
||||
queryKey: ['mobile', 'onboarding', 'packages-overview'],
|
||||
@@ -63,8 +64,18 @@ function RequireAuth() {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: onboardingStatus, isLoading: onboardingLoading } = useQuery({
|
||||
queryKey: ['mobile', 'onboarding', 'status'],
|
||||
queryFn: fetchOnboardingStatus,
|
||||
enabled: shouldCheckPackages,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const hasActivePackage =
|
||||
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
|
||||
const isOnboardingDismissed = Boolean(onboardingStatus?.steps?.dismissed_at);
|
||||
const isOnboardingCompleted = Boolean(onboardingStatus?.steps?.completed_at);
|
||||
const shouldBlockOnboarding = shouldCheckPackages && onboardingLoading;
|
||||
|
||||
const redirectTarget = resolveOnboardingRedirect({
|
||||
hasEvents,
|
||||
@@ -73,6 +84,8 @@ function RequireAuth() {
|
||||
pathname: location.pathname,
|
||||
isWelcomePath,
|
||||
isBillingPath,
|
||||
isOnboardingDismissed,
|
||||
isOnboardingCompleted,
|
||||
});
|
||||
|
||||
if (status === 'loading') {
|
||||
@@ -87,7 +100,7 @@ function RequireAuth() {
|
||||
return <Navigate to={ADMIN_LOGIN_START_PATH} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (!isWelcomePath && !isBillingPath && (eventsLoading || packagesLoading)) {
|
||||
if (!isWelcomePath && !isBillingPath && (eventsLoading || packagesLoading || shouldBlockOnboarding)) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
||||
Bitte warten ...
|
||||
|
||||
Reference in New Issue
Block a user