Add event addon purchase success page with confetti
This commit is contained in:
@@ -4,10 +4,13 @@ namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\EventAddonCheckoutRequest;
|
||||
use App\Http\Requests\Tenant\EventAddonPurchaseLookupRequest;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Services\Addons\EventAddonCheckoutService;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EventAddonController extends Controller
|
||||
{
|
||||
@@ -48,4 +51,100 @@ class EventAddonController extends Controller
|
||||
'expires_at' => $checkout['expires_at'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function purchase(EventAddonPurchaseLookupRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return ApiError::response(
|
||||
'event_not_found',
|
||||
'Event not accessible',
|
||||
__('Das Event konnte nicht gefunden werden.'),
|
||||
404,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
$addonIntent = trim((string) ($validated['addon_intent'] ?? ''));
|
||||
$checkoutId = trim((string) ($validated['checkout_id'] ?? ''));
|
||||
$addonKey = trim((string) ($validated['addon_key'] ?? ''));
|
||||
|
||||
$baseQuery = EventPackageAddon::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('event_id', $event->id)
|
||||
->with(['event:id,name,slug']);
|
||||
|
||||
$addon = null;
|
||||
|
||||
if ($addonIntent !== '') {
|
||||
$addon = (clone $baseQuery)
|
||||
->where('metadata->addon_intent', $addonIntent)
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $addon && $checkoutId !== '') {
|
||||
$addon = (clone $baseQuery)
|
||||
->where('checkout_id', $checkoutId)
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $addon && $addonKey !== '') {
|
||||
$addon = (clone $baseQuery)
|
||||
->where('addon_key', $addonKey)
|
||||
->orderByRaw("case status when 'completed' then 0 when 'pending' then 1 else 2 end")
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $addon) {
|
||||
$addon = (clone $baseQuery)
|
||||
->orderByRaw("case status when 'completed' then 0 when 'pending' then 1 else 2 end")
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $addon) {
|
||||
return ApiError::response(
|
||||
'addon_not_found',
|
||||
'Add-on purchase not found',
|
||||
__('Der Add-on Kauf wurde nicht gefunden.'),
|
||||
404,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$label = Arr::get($addon->metadata ?? [], 'label') ?? $addon->addon_key;
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'id' => $addon->id,
|
||||
'addon_key' => $addon->addon_key,
|
||||
'label' => $label,
|
||||
'quantity' => (int) ($addon->quantity ?? 1),
|
||||
'status' => $addon->status,
|
||||
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
|
||||
'currency' => $addon->currency,
|
||||
'extra_photos' => (int) $addon->extra_photos,
|
||||
'extra_guests' => (int) $addon->extra_guests,
|
||||
'extra_gallery_days' => (int) $addon->extra_gallery_days,
|
||||
'purchased_at' => $addon->purchased_at?->toIso8601String(),
|
||||
'receipt_url' => Arr::get($addon->receipt_payload, 'receipt_url'),
|
||||
'checkout_id' => $addon->checkout_id,
|
||||
'transaction_id' => $addon->transaction_id,
|
||||
'created_at' => $addon->created_at?->toIso8601String(),
|
||||
'addon_intent' => Arr::get($addon->metadata ?? [], 'addon_intent'),
|
||||
'event' => $addon->event ? [
|
||||
'id' => $addon->event->id,
|
||||
'slug' => $addon->event->slug,
|
||||
'name' => $addon->event->name,
|
||||
] : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Http/Requests/Tenant/EventAddonPurchaseLookupRequest.php
Normal file
22
app/Http/Requests/Tenant/EventAddonPurchaseLookupRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class EventAddonPurchaseLookupRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'addon_intent' => ['nullable', 'string', 'max:191'],
|
||||
'checkout_id' => ['nullable', 'string', 'max:191'],
|
||||
'addon_key' => ['nullable', 'string', 'max:191'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,8 @@ class EventAddonCheckoutService
|
||||
$addonMetadata = $this->resolveAddonMetadata($addon);
|
||||
$entitlements = $this->resolveAddonEntitlements($addonKey, $addonMetadata);
|
||||
$price = $this->catalog->resolvePrice($addonKey);
|
||||
$successUrl = $this->appendQueryParam($payload['success_url'] ?? null, 'addon_intent', $addonIntent);
|
||||
$cancelUrl = $this->appendQueryParam($payload['cancel_url'] ?? null, 'addon_intent', $addonIntent);
|
||||
|
||||
$providerMetadata = array_filter([
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
@@ -98,13 +100,13 @@ class EventAddonCheckoutService
|
||||
'legal_version' => $this->resolveLegalVersion(),
|
||||
'accepted_terms' => $acceptedTerms ? '1' : '0',
|
||||
'accepted_waiver' => $acceptedWaiver ? '1' : '0',
|
||||
'success_url' => $payload['success_url'] ?? null,
|
||||
'cancel_url' => $payload['cancel_url'] ?? null,
|
||||
'success_url' => $successUrl,
|
||||
'cancel_url' => $cancelUrl,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$response = $this->checkout->createVariantCheckout($variantId, $providerMetadata, [
|
||||
'success_url' => $payload['success_url'] ?? null,
|
||||
'return_url' => $payload['cancel_url'] ?? null,
|
||||
'success_url' => $successUrl,
|
||||
'return_url' => $cancelUrl,
|
||||
'customer_email' => $tenant->contact_email ?? $tenant->user?->email,
|
||||
]);
|
||||
|
||||
@@ -227,7 +229,9 @@ class EventAddonCheckoutService
|
||||
]);
|
||||
|
||||
$successUrl = $payload['success_url'] ?? null;
|
||||
$successUrl = $this->appendQueryParam($successUrl, 'addon_intent', $addonIntent);
|
||||
$cancelUrl = $payload['cancel_url'] ?? $successUrl;
|
||||
$cancelUrl = $this->appendQueryParam($cancelUrl, 'addon_intent', $addonIntent);
|
||||
$paypalReturnUrl = route('paypal.addon.return', absolute: true);
|
||||
|
||||
try {
|
||||
@@ -312,6 +316,23 @@ class EventAddonCheckoutService
|
||||
return config('app.legal_version', now()->toDateString());
|
||||
}
|
||||
|
||||
protected function appendQueryParam(?string $url, string $key, string $value): ?string
|
||||
{
|
||||
if (! is_string($url) || trim($url) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$base, $fragment] = array_pad(explode('#', $url, 2), 2, null);
|
||||
$separator = str_contains($base, '?') ? '&' : '?';
|
||||
$next = $base.$separator.rawurlencode($key).'='.rawurlencode($value);
|
||||
|
||||
if ($fragment === null) {
|
||||
return $next;
|
||||
}
|
||||
|
||||
return $next.'#'.$fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $addon
|
||||
*/
|
||||
|
||||
@@ -667,6 +667,13 @@ export type TenantAddonHistoryEntry = {
|
||||
receipt_url?: string | null;
|
||||
};
|
||||
|
||||
export type EventAddonPurchaseSummary = TenantAddonHistoryEntry & {
|
||||
checkout_id: string | null;
|
||||
transaction_id: string | null;
|
||||
created_at: string | null;
|
||||
addon_intent?: string | null;
|
||||
};
|
||||
|
||||
export type TenantBillingAddonScope = {
|
||||
type: 'tenant' | 'event';
|
||||
event: TenantAddonEventSummary | null;
|
||||
@@ -1435,6 +1442,18 @@ function normalizeTenantAddonHistoryEntry(entry: JsonValue): TenantAddonHistoryE
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEventAddonPurchaseSummary(entry: JsonValue): EventAddonPurchaseSummary {
|
||||
const base = normalizeTenantAddonHistoryEntry(entry);
|
||||
|
||||
return {
|
||||
...base,
|
||||
checkout_id: typeof entry.checkout_id === 'string' ? entry.checkout_id : null,
|
||||
transaction_id: typeof entry.transaction_id === 'string' ? entry.transaction_id : null,
|
||||
created_at: typeof entry.created_at === 'string' ? entry.created_at : null,
|
||||
addon_intent: typeof entry.addon_intent === 'string' ? entry.addon_intent : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTask(task: JsonValue): TenantTask {
|
||||
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
|
||||
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
|
||||
@@ -2013,6 +2032,46 @@ export async function createEventAddonCheckout(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getEventAddonPurchase(
|
||||
eventSlug: string,
|
||||
options?: {
|
||||
addonIntent?: string;
|
||||
checkoutId?: string;
|
||||
addonKey?: string;
|
||||
}
|
||||
): Promise<EventAddonPurchaseSummary | null> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options?.addonIntent) {
|
||||
params.set('addon_intent', options.addonIntent);
|
||||
}
|
||||
|
||||
if (options?.checkoutId) {
|
||||
params.set('checkout_id', options.checkoutId);
|
||||
}
|
||||
|
||||
if (options?.addonKey) {
|
||||
params.set('addon_key', options.addonKey);
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
const response = await authorizedFetch(
|
||||
`${eventEndpoint(eventSlug)}/addons/purchase${query ? `?${query}` : ''}`
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to load add-on purchase');
|
||||
|
||||
if (!payload.data || typeof payload.data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeEventAddonPurchaseSummary(payload.data);
|
||||
}
|
||||
|
||||
export async function getAddonCatalog(): Promise<EventAddonCatalogItem[]> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/addons/catalog');
|
||||
const data = await jsonOrThrow<{ data?: EventAddonCatalogItem[] }>(response, 'Failed to load add-ons');
|
||||
|
||||
@@ -36,6 +36,8 @@ export const ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH = (slug: string): string =>
|
||||
adminPath(`/mobile/events/${encodeURIComponent(slug)}/guest-notifications`);
|
||||
export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/recap`);
|
||||
export const ADMIN_EVENT_ADDONS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/addons`);
|
||||
export const ADMIN_EVENT_ADDON_SUCCESS_PATH = (slug: string): string =>
|
||||
adminPath(`/mobile/events/${encodeURIComponent(slug)}/addons/success`);
|
||||
export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/branding`);
|
||||
export const ADMIN_EVENT_LIVE_SHOW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show`);
|
||||
export const ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH = (slug: string): string =>
|
||||
|
||||
@@ -518,6 +518,30 @@
|
||||
"gallery": "Galerie-Laufzeit",
|
||||
"bundle": "Bundles",
|
||||
"feature": "Feature-Freischaltungen"
|
||||
},
|
||||
"success": {
|
||||
"title": "Add-on-Kauf",
|
||||
"loadFailed": "Kaufergebnis konnte nicht geladen werden.",
|
||||
"notFound": "Dieser Kauf wird noch bestätigt.",
|
||||
"statusUnknown": "Unbekannt",
|
||||
"completedTitle": "Kauf abgeschlossen",
|
||||
"completedBody": "Dein Event-Add-on ist jetzt aktiv, die Limits aktualisieren sich in Kürze.",
|
||||
"pendingTitle": "Kauf in Bearbeitung",
|
||||
"pendingBody": "Deine Zahlung wird noch bestätigt.",
|
||||
"failedTitle": "Kauf fehlgeschlagen",
|
||||
"failedBody": "Die Zahlung konnte nicht abgeschlossen werden. Du kannst es erneut versuchen.",
|
||||
"summaryTitle": "Kaufübersicht",
|
||||
"addonType": "Add-on",
|
||||
"amount": "Betrag",
|
||||
"quantity": "Menge",
|
||||
"purchasedAt": "Gekauft am",
|
||||
"checkoutId": "Checkout-ID",
|
||||
"transactionId": "Transaktions-ID",
|
||||
"nextTitle": "Was jetzt?",
|
||||
"backToAddons": "Zurück zum Add-on-Manager",
|
||||
"openControlRoom": "Control Room öffnen",
|
||||
"openBilling": "Billing öffnen",
|
||||
"backToEvent": "Zurück zum Event"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
|
||||
@@ -514,6 +514,30 @@
|
||||
"gallery": "Gallery runtime",
|
||||
"bundle": "Bundles",
|
||||
"feature": "Feature unlocks"
|
||||
},
|
||||
"success": {
|
||||
"title": "Add-on purchase",
|
||||
"loadFailed": "Purchase result could not be loaded.",
|
||||
"notFound": "We are still confirming this purchase.",
|
||||
"statusUnknown": "Unknown",
|
||||
"completedTitle": "Purchase completed",
|
||||
"completedBody": "Your event add-on is now active and limits update shortly.",
|
||||
"pendingTitle": "Purchase in progress",
|
||||
"pendingBody": "We are still processing your payment confirmation.",
|
||||
"failedTitle": "Purchase failed",
|
||||
"failedBody": "The payment could not be completed. You can try again.",
|
||||
"summaryTitle": "Purchase summary",
|
||||
"addonType": "Add-on",
|
||||
"amount": "Amount",
|
||||
"quantity": "Quantity",
|
||||
"purchasedAt": "Purchased at",
|
||||
"checkoutId": "Checkout ID",
|
||||
"transactionId": "Transaction ID",
|
||||
"nextTitle": "What next?",
|
||||
"backToAddons": "Back to add-on manager",
|
||||
"openControlRoom": "Open control room",
|
||||
"openBilling": "Open billing",
|
||||
"backToEvent": "Back to event"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
|
||||
306
resources/js/admin/mobile/EventAddonSuccessPage.tsx
Normal file
306
resources/js/admin/mobile/EventAddonSuccessPage.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle2, Clock3, AlertTriangle, ReceiptText, Sparkles } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
|
||||
import { EventAddonPurchaseSummary, getEvent, getEventAddonPurchase, TenantEvent } from '../api';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { adminPath, ADMIN_BILLING_PATH, ADMIN_EVENT_ADDONS_PATH, ADMIN_EVENT_CONTROL_ROOM_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { CTAButton, MobileCard, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { resolveEventDisplayName } from '../lib/events';
|
||||
|
||||
function formatAmount(value: number | null, currency: string | null): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const resolvedCurrency = currency ?? 'EUR';
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: resolvedCurrency }).format(value);
|
||||
} catch {
|
||||
return `${value} ${resolvedCurrency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return date.toLocaleString(undefined, {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
async function launchConfetti(): Promise<void> {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
|
||||
if (prefersReducedMotion) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { default: confetti } = await import('canvas-confetti');
|
||||
|
||||
confetti({
|
||||
particleCount: 80,
|
||||
spread: 70,
|
||||
origin: { x: 0.5, y: 0.3 },
|
||||
ticks: 180,
|
||||
scalar: 0.95,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
confetti({
|
||||
particleCount: 50,
|
||||
spread: 90,
|
||||
origin: { x: 0.5, y: 0.2 },
|
||||
ticks: 140,
|
||||
scalar: 0.8,
|
||||
});
|
||||
}, 260);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export default function MobileEventAddonSuccessPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, text, muted, border, successText, warningText, danger, primary } = useAdminTheme();
|
||||
const back = useBackNavigation(slug ? ADMIN_EVENT_ADDONS_PATH(slug) : adminPath('/mobile/events'));
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [purchase, setPurchase] = React.useState<EventAddonPurchaseSummary | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const confettiTriggeredRef = React.useRef(false);
|
||||
|
||||
const query = React.useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
return {
|
||||
addonIntent: params.get('addon_intent') ?? undefined,
|
||||
checkoutId: params.get('checkout_id') ?? undefined,
|
||||
addonKey: params.get('addon_key') ?? undefined,
|
||||
};
|
||||
}, [location.search]);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const [eventData, purchaseData] = await Promise.all([
|
||||
getEvent(slug),
|
||||
getEventAddonPurchase(slug, {
|
||||
addonIntent: query.addonIntent,
|
||||
checkoutId: query.checkoutId,
|
||||
addonKey: query.addonKey,
|
||||
}),
|
||||
]);
|
||||
|
||||
setEvent(eventData);
|
||||
setPurchase(purchaseData);
|
||||
|
||||
if (!purchaseData) {
|
||||
setError(t('events.addons.success.notFound', 'We are still confirming this purchase.'));
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, t('events.addons.success.loadFailed', 'Purchase result could not be loaded.')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query.addonIntent, query.addonKey, query.checkoutId, slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!purchase || purchase.status !== 'completed' || confettiTriggeredRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
confettiTriggeredRef.current = true;
|
||||
void launchConfetti();
|
||||
}, [purchase]);
|
||||
|
||||
const statusTone = purchase?.status === 'completed' ? 'success' : purchase?.status === 'pending' ? 'warning' : 'danger';
|
||||
const statusText = purchase
|
||||
? t(`mobileBilling.status.${purchase.status}`, purchase.status)
|
||||
: t('events.addons.success.statusUnknown', 'Unknown');
|
||||
|
||||
const StatusIcon = purchase?.status === 'completed' ? CheckCircle2 : purchase?.status === 'pending' ? Clock3 : AlertTriangle;
|
||||
const statusColor = purchase?.status === 'completed' ? successText : purchase?.status === 'pending' ? warningText : danger;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<MobileShell title={t('events.addons.success.title', 'Add-on purchase')} activeTab="home" onBack={back}>
|
||||
<YStack gap="$3">
|
||||
<SkeletonCard height={130} />
|
||||
<SkeletonCard height={210} />
|
||||
<SkeletonCard height={170} />
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell title={t('events.addons.success.title', 'Add-on purchase')} activeTab="home" onBack={back}>
|
||||
<YStack gap="$3">
|
||||
<MobileCard gap="$2" borderColor={statusColor}>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<StatusIcon size={18} color={statusColor} />
|
||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||
{purchase?.status === 'completed'
|
||||
? t('events.addons.success.completedTitle', 'Purchase completed')
|
||||
: purchase?.status === 'pending'
|
||||
? t('events.addons.success.pendingTitle', 'Purchase in progress')
|
||||
: t('events.addons.success.failedTitle', 'Purchase failed')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{purchase?.status === 'completed'
|
||||
? t('events.addons.success.completedBody', 'Your event add-on is now active and limits update shortly.')
|
||||
: purchase?.status === 'pending'
|
||||
? t('events.addons.success.pendingBody', 'We are still processing your payment confirmation.')
|
||||
: t('events.addons.success.failedBody', 'The payment could not be completed. You can try again.')}
|
||||
</Text>
|
||||
<PillBadge tone={statusTone}>{statusText}</PillBadge>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard gap="$2">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.addons.event', 'Event')}
|
||||
</Text>
|
||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||
{resolveEventDisplayName(event)}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard gap="$2">
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<ReceiptText size={16} color={primary} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.addons.success.summaryTitle', 'Purchase summary')}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
{error ? (
|
||||
<Text fontSize="$sm" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{purchase ? (
|
||||
<YStack gap="$1.5">
|
||||
<SummaryRow label={t('events.addons.success.addonType', 'Add-on')} value={purchase.label ?? purchase.addon_key} />
|
||||
<SummaryRow label={t('events.addons.success.amount', 'Amount')} value={formatAmount(purchase.amount, purchase.currency)} />
|
||||
<SummaryRow label={t('events.addons.success.quantity', 'Quantity')} value={String(purchase.quantity)} />
|
||||
<SummaryRow
|
||||
label={t('events.addons.success.purchasedAt', 'Purchased at')}
|
||||
value={formatDateTime(purchase.purchased_at ?? purchase.created_at)}
|
||||
/>
|
||||
{purchase.checkout_id ? (
|
||||
<SummaryRow label={t('events.addons.success.checkoutId', 'Checkout ID')} value={purchase.checkout_id} />
|
||||
) : null}
|
||||
{purchase.transaction_id ? (
|
||||
<SummaryRow label={t('events.addons.success.transactionId', 'Transaction ID')} value={purchase.transaction_id} />
|
||||
) : null}
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{purchase ? (
|
||||
<XStack gap="$2" flexWrap="wrap" marginTop="$1">
|
||||
{purchase.extra_photos > 0 ? (
|
||||
<PillBadge tone="muted">
|
||||
{t('mobileBilling.extra.photos', '+{{count}} photos', { count: purchase.extra_photos })}
|
||||
</PillBadge>
|
||||
) : null}
|
||||
{purchase.extra_guests > 0 ? (
|
||||
<PillBadge tone="muted">
|
||||
{t('mobileBilling.extra.guests', '+{{count}} guests', { count: purchase.extra_guests })}
|
||||
</PillBadge>
|
||||
) : null}
|
||||
{purchase.extra_gallery_days > 0 ? (
|
||||
<PillBadge tone="muted">
|
||||
{t('mobileBilling.extra.days', '+{{count}} days', { count: purchase.extra_gallery_days })}
|
||||
</PillBadge>
|
||||
) : null}
|
||||
</XStack>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard gap="$2">
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Sparkles size={16} color={primary} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.addons.success.nextTitle', 'What next?')}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<CTAButton
|
||||
label={t('events.addons.success.backToAddons', 'Back to add-on manager')}
|
||||
onPress={() => navigate(slug ? ADMIN_EVENT_ADDONS_PATH(slug) : adminPath('/mobile/events'))}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('events.addons.success.openControlRoom', 'Open control room')}
|
||||
tone="ghost"
|
||||
onPress={() => navigate(slug ? ADMIN_EVENT_CONTROL_ROOM_PATH(slug) : adminPath('/mobile/events'))}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('events.addons.success.openBilling', 'Open billing')}
|
||||
tone="ghost"
|
||||
onPress={() => navigate(ADMIN_BILLING_PATH)}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('events.addons.success.backToEvent', 'Back to event')}
|
||||
tone="ghost"
|
||||
onPress={() => navigate(slug ? ADMIN_EVENT_VIEW_PATH(slug) : adminPath('/mobile/events'))}
|
||||
/>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||
const { textStrong, muted, border } = useAdminTheme();
|
||||
|
||||
return (
|
||||
<XStack alignItems="center" justifyContent="space-between" gap="$2" borderBottomWidth={1} borderColor={border} paddingBottom="$1.5">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700" textAlign="right" flexShrink={1}>
|
||||
{value}
|
||||
</Text>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '../api';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { adminPath, ADMIN_EVENT_VIEW_PATH } from '../constants';
|
||||
import { adminPath, ADMIN_EVENT_ADDON_SUCCESS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
@@ -312,11 +312,13 @@ export default function MobileEventAddonsPage() {
|
||||
|
||||
const pagePath = adminPath(`/mobile/events/${slug}/addons`);
|
||||
const currentUrl = `${window.location.origin}${pagePath}`;
|
||||
const successPath = `${ADMIN_EVENT_ADDON_SUCCESS_PATH(slug)}?addon_key=${encodeURIComponent(pendingAddonKey)}`;
|
||||
const successUrl = `${window.location.origin}${successPath}`;
|
||||
|
||||
try {
|
||||
const checkout = await createEventAddonCheckout(slug, {
|
||||
addon_key: pendingAddonKey,
|
||||
success_url: `${currentUrl}?addon_success=1`,
|
||||
success_url: successUrl,
|
||||
cancel_url: currentUrl,
|
||||
accepted_terms: consents.acceptedTerms,
|
||||
accepted_waiver: consents.acceptedWaiver,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
const fixtures = vi.hoisted(() => ({
|
||||
event: {
|
||||
id: 77,
|
||||
name: { de: 'Sommerfest', en: 'Summer Fest' },
|
||||
slug: 'summer-fest',
|
||||
status: 'published',
|
||||
event_date: '2026-07-01T10:00:00Z',
|
||||
settings: {},
|
||||
},
|
||||
purchase: {
|
||||
id: 901,
|
||||
addon_key: 'extra_photos_500',
|
||||
label: 'Extra photos 500',
|
||||
quantity: 1,
|
||||
status: 'completed',
|
||||
amount: 12,
|
||||
currency: 'EUR',
|
||||
extra_photos: 500,
|
||||
extra_guests: 0,
|
||||
extra_gallery_days: 0,
|
||||
purchased_at: '2026-02-07T12:00:00Z',
|
||||
receipt_url: null,
|
||||
checkout_id: 'ORDER-123',
|
||||
transaction_id: 'TX-999',
|
||||
created_at: '2026-02-07T11:59:00Z',
|
||||
addon_intent: 'intent_abc',
|
||||
event: { id: 77, slug: 'summer-fest', name: { de: 'Sommerfest', en: 'Summer Fest' } },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useParams: () => ({ slug: fixtures.event.slug }),
|
||||
useLocation: () => ({ pathname: '/event-admin/mobile/events/summer-fest/addons/success', search: '?addon_intent=intent_abc' }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>, options?: Record<string, unknown>) => {
|
||||
let value = typeof fallback === 'string' ? fallback : key;
|
||||
if (options) {
|
||||
Object.entries(options).forEach(([optionKey, optionValue]) => {
|
||||
value = value.replaceAll(`{{${optionKey}}}`, String(optionValue));
|
||||
});
|
||||
}
|
||||
return value;
|
||||
},
|
||||
}),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: () => undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useBackNavigation', () => ({
|
||||
useBackNavigation: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/apiError', () => ({
|
||||
getApiErrorMessage: (_err: unknown, fallback: string) => fallback,
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/events', () => ({
|
||||
resolveEventDisplayName: (event: any) => event?.name?.de ?? event?.name ?? 'Event',
|
||||
}));
|
||||
|
||||
vi.mock('../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
text: '#111827',
|
||||
muted: '#6b7280',
|
||||
subtle: '#94a3b8',
|
||||
border: '#e5e7eb',
|
||||
primary: '#2563eb',
|
||||
successText: '#16a34a',
|
||||
warningText: '#f59e0b',
|
||||
danger: '#dc2626',
|
||||
surface: '#ffffff',
|
||||
surfaceMuted: '#f9fafb',
|
||||
backdrop: '#111827',
|
||||
accentSoft: '#eef2ff',
|
||||
textStrong: '#0f172a',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/MobileShell', () => ({
|
||||
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Primitives', () => ({
|
||||
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>{label}</button>
|
||||
),
|
||||
PillBadge: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
SkeletonCard: () => <div>Loading...</div>,
|
||||
}));
|
||||
|
||||
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('../../api', () => ({
|
||||
getEvent: vi.fn().mockResolvedValue(fixtures.event),
|
||||
getEventAddonPurchase: vi.fn().mockResolvedValue(fixtures.purchase),
|
||||
}));
|
||||
|
||||
import MobileEventAddonSuccessPage from '../EventAddonSuccessPage';
|
||||
import { getEventAddonPurchase } from '../../api';
|
||||
|
||||
describe('MobileEventAddonSuccessPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockReturnValue({
|
||||
matches: true,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('loads purchase by addon intent and renders summary details', async () => {
|
||||
const purchaseMock = vi.mocked(getEventAddonPurchase);
|
||||
|
||||
render(<MobileEventAddonSuccessPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(purchaseMock).toHaveBeenCalledWith(fixtures.event.slug, {
|
||||
addonIntent: 'intent_abc',
|
||||
checkoutId: undefined,
|
||||
addonKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Purchase summary')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sommerfest')).toBeInTheDocument();
|
||||
expect(screen.getByText('Extra photos 500')).toBeInTheDocument();
|
||||
expect(screen.getByText('ORDER-123')).toBeInTheDocument();
|
||||
expect(screen.getByText('TX-999')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback error state when purchase is not found', async () => {
|
||||
vi.mocked(getEventAddonPurchase).mockResolvedValue(null as any);
|
||||
|
||||
render(<MobileEventAddonSuccessPage />);
|
||||
|
||||
expect(await screen.findByText('We are still confirming this purchase.')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open billing' }));
|
||||
expect(navigateMock).toHaveBeenCalledWith('/event-admin/mobile/billing');
|
||||
});
|
||||
});
|
||||
@@ -186,7 +186,7 @@ describe('MobileEventAddonsPage', () => {
|
||||
await waitFor(() => {
|
||||
expect(checkoutMock).toHaveBeenCalledWith(fixtures.event.slug, {
|
||||
addon_key: 'extra_photos_500',
|
||||
success_url: `${window.location.origin}/event-admin/mobile/events/demo-event/addons?addon_success=1`,
|
||||
success_url: `${window.location.origin}/event-admin/mobile/events/demo-event/addons/success?addon_key=extra_photos_500`,
|
||||
cancel_url: `${window.location.origin}/event-admin/mobile/events/demo-event/addons`,
|
||||
accepted_terms: true,
|
||||
accepted_waiver: true,
|
||||
|
||||
@@ -32,6 +32,7 @@ const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPag
|
||||
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
|
||||
const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage'));
|
||||
const MobileEventAddonsPage = React.lazy(() => import('./mobile/EventAddonsPage'));
|
||||
const MobileEventAddonSuccessPage = React.lazy(() => import('./mobile/EventAddonSuccessPage'));
|
||||
const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalyticsPage'));
|
||||
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
|
||||
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
|
||||
@@ -201,6 +202,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'events/:slug/members', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/members`} /> },
|
||||
{ path: 'events/:slug/tasks', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/tasks`} /> },
|
||||
{ path: 'events/:slug/addons', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/addons`} /> },
|
||||
{ path: 'events/:slug/addons/success', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/addons/success`} /> },
|
||||
{ path: 'events/:slug/invites', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/qr`} /> },
|
||||
{ path: 'events/:slug/branding', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/branding`} /> },
|
||||
{ path: 'events/:slug/photobooth', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/photobooth`} /> },
|
||||
@@ -219,6 +221,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'mobile/events/:slug/live-show/settings', element: <RequireAdminAccess><MobileEventLiveShowSettingsPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/addons', element: <RequireAdminAccess><MobileEventAddonsPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/addons/success', element: <RequireAdminAccess><MobileEventAddonSuccessPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/analytics', element: <RequireAdminAccess><MobileEventAnalyticsPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
|
||||
|
||||
@@ -302,6 +302,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::get('guest-notifications', [EventGuestNotificationController::class, 'index'])->name('tenant.events.guest-notifications.index');
|
||||
Route::post('guest-notifications', [EventGuestNotificationController::class, 'store'])->name('tenant.events.guest-notifications.store');
|
||||
Route::post('addons/checkout', [EventAddonController::class, 'checkout'])->name('tenant.events.addons.checkout');
|
||||
Route::get('addons/purchase', [EventAddonController::class, 'purchase'])->name('tenant.events.addons.purchase');
|
||||
|
||||
Route::prefix('live-show')->group(function () {
|
||||
Route::get('link', [LiveShowLinkController::class, 'show'])->name('tenant.events.live-show.link');
|
||||
|
||||
@@ -67,6 +67,8 @@ class EventAddonCheckoutTest extends TenantTestCase
|
||||
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [
|
||||
'addon_key' => 'extra_photos_small',
|
||||
'quantity' => 2,
|
||||
'success_url' => 'https://app.fotospiel.test/event-admin/mobile/events/test-event/addons/success?addon_key=extra_photos_small',
|
||||
'cancel_url' => 'https://app.fotospiel.test/event-admin/mobile/events/test-event/addons',
|
||||
'accepted_terms' => true,
|
||||
'accepted_waiver' => true,
|
||||
]);
|
||||
@@ -87,6 +89,9 @@ class EventAddonCheckoutTest extends TenantTestCase
|
||||
$this->assertSame('Extra photos (500)', $addon->metadata['label'] ?? null);
|
||||
$this->assertSame(500, $addon->metadata['increments']['extra_photos'] ?? null);
|
||||
$this->assertNull($addon->metadata['price_eur'] ?? null);
|
||||
$this->assertNotEmpty($addon->metadata['addon_intent'] ?? null);
|
||||
$this->assertStringContainsString('addon_intent=', (string) ($addon->metadata['success_url'] ?? ''));
|
||||
$this->assertStringContainsString('addon_intent=', (string) ($addon->metadata['cancel_url'] ?? ''));
|
||||
}
|
||||
|
||||
public function test_paypal_checkout_creates_pending_addon_record(): void
|
||||
@@ -132,6 +137,8 @@ class EventAddonCheckoutTest extends TenantTestCase
|
||||
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [
|
||||
'addon_key' => 'extra_photos_small',
|
||||
'quantity' => 2,
|
||||
'success_url' => 'https://app.fotospiel.test/event-admin/mobile/events/test-event/addons/success?addon_key=extra_photos_small',
|
||||
'cancel_url' => 'https://app.fotospiel.test/event-admin/mobile/events/test-event/addons',
|
||||
'accepted_terms' => true,
|
||||
'accepted_waiver' => true,
|
||||
]);
|
||||
@@ -154,6 +161,9 @@ class EventAddonCheckoutTest extends TenantTestCase
|
||||
$this->assertSame(1000, $addon->extra_photos);
|
||||
$this->assertSame('Extra photos (500)', $addon->metadata['label'] ?? null);
|
||||
$this->assertSame(12.5, $addon->metadata['price_eur'] ?? null);
|
||||
$this->assertNotEmpty($addon->metadata['addon_intent'] ?? null);
|
||||
$this->assertStringContainsString('addon_intent=', (string) ($addon->metadata['paypal_success_url'] ?? ''));
|
||||
$this->assertStringContainsString('addon_intent=', (string) ($addon->metadata['paypal_cancel_url'] ?? ''));
|
||||
}
|
||||
|
||||
public function test_ai_styling_checkout_persists_feature_entitlement_metadata(): void
|
||||
|
||||
87
tests/Feature/Tenant/EventAddonPurchaseLookupTest.php
Normal file
87
tests/Feature/Tenant/EventAddonPurchaseLookupTest.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
|
||||
class EventAddonPurchaseLookupTest extends TenantTestCase
|
||||
{
|
||||
public function test_tenant_can_load_event_addon_purchase_by_intent(): void
|
||||
{
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'max_photos' => 100,
|
||||
'max_guests' => 50,
|
||||
'gallery_days' => 7,
|
||||
]);
|
||||
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'summer-party',
|
||||
'name' => ['de' => 'Sommerparty', 'en' => 'Summer Party'],
|
||||
]);
|
||||
|
||||
$eventPackage = EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now()->subDay(),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 0,
|
||||
'gallery_expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
$addon = EventPackageAddon::create([
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'addon_key' => 'extra_photos_500',
|
||||
'quantity' => 1,
|
||||
'extra_photos' => 500,
|
||||
'extra_guests' => 0,
|
||||
'extra_gallery_days' => 0,
|
||||
'checkout_id' => 'ORDER-ADDON-123',
|
||||
'transaction_id' => 'TX-ABC',
|
||||
'status' => 'completed',
|
||||
'amount' => 12.00,
|
||||
'currency' => 'EUR',
|
||||
'metadata' => [
|
||||
'label' => 'Extra photos 500',
|
||||
'addon_intent' => 'intent_123',
|
||||
],
|
||||
'purchased_at' => now(),
|
||||
'receipt_payload' => ['receipt_url' => 'https://receipt.test/addon'],
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/addons/purchase?addon_intent=intent_123");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('data.id', $addon->id);
|
||||
$response->assertJsonPath('data.addon_key', 'extra_photos_500');
|
||||
$response->assertJsonPath('data.label', 'Extra photos 500');
|
||||
$response->assertJsonPath('data.checkout_id', 'ORDER-ADDON-123');
|
||||
$response->assertJsonPath('data.transaction_id', 'TX-ABC');
|
||||
$response->assertJsonPath('data.status', 'completed');
|
||||
$response->assertJsonPath('data.amount', 12);
|
||||
$response->assertJsonPath('data.currency', 'EUR');
|
||||
$response->assertJsonPath('data.extra_photos', 500);
|
||||
$response->assertJsonPath('data.receipt_url', 'https://receipt.test/addon');
|
||||
$response->assertJsonPath('data.addon_intent', 'intent_123');
|
||||
$response->assertJsonPath('data.event.slug', 'summer-party');
|
||||
}
|
||||
|
||||
public function test_tenant_cannot_load_purchase_for_event_of_other_tenant(): void
|
||||
{
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
$otherEvent = Event::factory()->for($otherTenant)->create([
|
||||
'slug' => 'foreign-event',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$otherEvent->slug}/addons/purchase?addon_key=extra_photos_500");
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonPath('error.code', 'event_not_found');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user