From 902e78cae9ceb9d190f4db6509199995555ba1d6 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 29 Dec 2025 19:31:26 +0100 Subject: [PATCH] Addon-Kauf im Event admin korrigiert. --- .../Addons/EventAddonWebhookService.php | 23 +++- .../js/admin/i18n/locales/de/management.json | 2 + .../js/admin/i18n/locales/en/management.json | 2 + .../admin/lib/__tests__/limitWarnings.test.ts | 50 ++++++++ resources/js/admin/lib/limitWarnings.ts | 13 ++- resources/js/admin/mobile/EventPhotosPage.tsx | 58 +++++----- .../mobile/__tests__/LimitWarnings.test.tsx | 107 ++++++++++++++++++ .../Feature/Tenant/EventAddonWebhookTest.php | 69 +++++++++++ 8 files changed, 295 insertions(+), 29 deletions(-) create mode 100644 resources/js/admin/lib/__tests__/limitWarnings.test.ts create mode 100644 resources/js/admin/mobile/__tests__/LimitWarnings.test.tsx diff --git a/app/Services/Addons/EventAddonWebhookService.php b/app/Services/Addons/EventAddonWebhookService.php index b537483..759abd6 100644 --- a/app/Services/Addons/EventAddonWebhookService.php +++ b/app/Services/Addons/EventAddonWebhookService.php @@ -59,7 +59,7 @@ class EventAddonWebhookService return true; // idempotent } - $increments = $this->catalog->resolveIncrements($addonKey); + $increments = $this->resolveAddonIncrements($addon, $addonKey); DB::transaction(function () use ($addon, $transactionId, $checkoutId, $data, $increments) { $addon->forceFill([ @@ -128,4 +128,25 @@ class EventAddonWebhookService return $metadata; } + + /** + * @return array + */ + private function resolveAddonIncrements(EventPackageAddon $addon, string $addonKey): array + { + $stored = Arr::get($addon->metadata ?? [], 'increments', []); + + if (is_array($stored) && $stored !== []) { + $filtered = collect($stored) + ->map(fn ($value) => is_numeric($value) ? (int) $value : 0) + ->filter(fn ($value) => $value > 0) + ->all(); + + if ($filtered !== []) { + return $filtered; + } + } + + return $this->catalog->resolveIncrements($addonKey); + } } diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 9845812..3fd6f1c 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -232,6 +232,8 @@ "guestsBlocked": "Gäste-Limit erreicht.", "guestsWarning": "{{remaining}} von {{limit}} Gästen verbleiben.", "galleryExpired": "Galerie abgelaufen. Verlängere die Laufzeit.", + "galleryWarningHour": "Galerie läuft in {{hours}} Stunde ab.", + "galleryWarningHours": "Galerie läuft in {{hours}} Stunden ab.", "galleryWarningDay": "Galerie läuft in {{days}} Tag ab.", "galleryWarningDays": "Galerie läuft in {{days}} Tagen ab.", "buyMorePhotos": "Mehr Fotos freischalten", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index f0ec75e..2901167 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -227,6 +227,8 @@ "guestsBlocked": "Guest limit reached.", "guestsWarning": "{{remaining}} of {{limit}} guests remaining.", "galleryExpired": "Gallery expired. Extend to keep it online.", + "galleryWarningHour": "Gallery expires in {{hours}} hour.", + "galleryWarningHours": "Gallery expires in {{hours}} hours.", "galleryWarningDay": "Gallery expires in {{days}} day.", "galleryWarningDays": "Gallery expires in {{days}} days.", "buyMorePhotos": "Buy more photos", diff --git a/resources/js/admin/lib/__tests__/limitWarnings.test.ts b/resources/js/admin/lib/__tests__/limitWarnings.test.ts new file mode 100644 index 0000000..4666da4 --- /dev/null +++ b/resources/js/admin/lib/__tests__/limitWarnings.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { buildLimitWarnings } from '../limitWarnings'; + +describe('buildLimitWarnings', () => { + it('renders gallery warning in hours when less than two days remain', () => { + const warnings = buildLimitWarnings( + { + photos: null, + guests: null, + gallery: { + state: 'warning', + expires_at: null, + days_remaining: 1.2, + warning_thresholds: [], + warning_triggered: null, + warning_sent_at: null, + expired_notified_at: null, + }, + can_upload_photos: true, + can_add_guests: true, + }, + (key, options) => (key === 'galleryWarningHours' ? `hours:${options?.hours}` : key), + ); + + expect(warnings[0]?.message).toBe('hours:29'); + }); + + it('renders gallery warning in days when two or more days remain', () => { + const warnings = buildLimitWarnings( + { + photos: null, + guests: null, + gallery: { + state: 'warning', + expires_at: null, + days_remaining: 2.1, + warning_thresholds: [], + warning_triggered: null, + warning_sent_at: null, + expired_notified_at: null, + }, + can_upload_photos: true, + can_add_guests: true, + }, + (key, options) => (key === 'galleryWarningDays' ? `days:${options?.days}` : key), + ); + + expect(warnings[0]?.message).toBe('days:3'); + }); +}); diff --git a/resources/js/admin/lib/limitWarnings.ts b/resources/js/admin/lib/limitWarnings.ts index 8ea15ef..4752049 100644 --- a/resources/js/admin/lib/limitWarnings.ts +++ b/resources/js/admin/lib/limitWarnings.ts @@ -104,12 +104,21 @@ export function buildLimitWarnings(limits: EventLimitSummary, t: TranslateFn): L } else if (limits.gallery.state === 'warning') { const days = limits.gallery.days_remaining ?? 0; const safeDays = Math.max(0, days); - const key = safeDays === 1 ? 'galleryWarningDay' : 'galleryWarningDays'; + const useHours = safeDays > 0 && safeDays < 2; + const roundedDays = Math.max(1, Math.ceil(safeDays)); + const roundedHours = Math.max(1, Math.ceil(safeDays * 24)); + const key = useHours + ? roundedHours === 1 + ? 'galleryWarningHour' + : 'galleryWarningHours' + : roundedDays === 1 + ? 'galleryWarningDay' + : 'galleryWarningDays'; warnings.push({ id: 'gallery-warning', scope: 'gallery', tone: 'warning', - message: t(key, { days: safeDays }), + message: t(key, useHours ? { hours: roundedHours } : { days: roundedDays }), }); } } diff --git a/resources/js/admin/mobile/EventPhotosPage.tsx b/resources/js/admin/mobile/EventPhotosPage.tsx index f63573c..587e24a 100644 --- a/resources/js/admin/mobile/EventPhotosPage.tsx +++ b/resources/js/admin/mobile/EventPhotosPage.tsx @@ -1381,6 +1381,8 @@ function translateLimits(t: (key: string, defaultValue?: string, options?: Recor guestsBlocked: 'Guest limit reached.', guestsWarning: '{{remaining}} of {{limit}} guests remaining.', galleryExpired: 'Gallery expired. Extend to keep it online.', + galleryWarningHour: 'Gallery expires in {{hours}} hour.', + galleryWarningHours: 'Gallery expires in {{hours}} hours.', galleryWarningDay: 'Gallery expires in {{days}} day.', galleryWarningDays: 'Gallery expires in {{days}} days.', buyMorePhotos: 'Buy more photos', @@ -1390,7 +1392,7 @@ function translateLimits(t: (key: string, defaultValue?: string, options?: Recor return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options); } -function LimitWarnings({ +export function LimitWarnings({ limits, addons, onCheckout, @@ -1420,32 +1422,40 @@ function LimitWarnings({ {warning.message} - {(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests') && addons.length ? ( - - ) : null} - onCheckout(warning.scope)} - loading={busyScope === warning.scope} - /> + {(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests') + && resolveAddonOptions(addons, warning.scope).length ? ( + + ) : ( + onCheckout(warning.scope)} + loading={busyScope === warning.scope} + /> + )} ))} ); } +function resolveAddonOptions(addons: EventAddonCatalogItem[], scope: 'photos' | 'gallery' | 'guests'): EventAddonCatalogItem[] { + const whitelist = scopeDefaults[scope]; + const filtered = addons.filter((addon) => addon.price_id && whitelist.includes(addon.key)); + return filtered.length ? filtered : addons.filter((addon) => addon.price_id); +} + function MobileAddonsPicker({ scope, addons, @@ -1459,11 +1469,7 @@ function MobileAddonsPicker({ onCheckout: (addonKey: string) => void; translate: LimitTranslator; }) { - const options = React.useMemo(() => { - const whitelist = scopeDefaults[scope]; - const filtered = addons.filter((addon) => addon.price_id && whitelist.includes(addon.key)); - return filtered.length ? filtered : addons.filter((addon) => addon.price_id); - }, [addons, scope]); + const options = React.useMemo(() => resolveAddonOptions(addons, scope), [addons, scope]); const [selected, setSelected] = React.useState(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope)); diff --git a/resources/js/admin/mobile/__tests__/LimitWarnings.test.tsx b/resources/js/admin/mobile/__tests__/LimitWarnings.test.tsx new file mode 100644 index 0000000..dd2de77 --- /dev/null +++ b/resources/js/admin/mobile/__tests__/LimitWarnings.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { LimitWarnings } from '../EventPhotosPage'; + +vi.mock('@tamagui/stacks', () => ({ + YStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/text', () => ({ + SizableText: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('@tamagui/core', () => ({ + useTheme: () => ({ + color: { val: '#111827' }, + gray: { val: '#4b5563' }, + borderColor: { val: '#e5e7eb' }, + blue3: { val: '#e8f1ff' }, + blue6: { val: '#bfdbfe' }, + red10: { val: '#b91c1c' }, + surface: { val: '#ffffff' }, + gray12: { val: '#0f172a' }, + }), +})); + +vi.mock('@tamagui/react-native-web-lite', () => ({ + Pressable: ({ children }: { children: React.ReactNode }) => , +})); + +vi.mock('../components/Primitives', () => ({ + MobileCard: ({ children }: { children: React.ReactNode }) =>
{children}
, + PillBadge: ({ children }: { children: React.ReactNode }) => {children}, + CTAButton: ({ + label, + onPress, + disabled, + loading, + }: { + label: string; + onPress?: () => void; + disabled?: boolean; + loading?: boolean; + }) => ( + + ), + SkeletonCard: () =>
, +})); + +vi.mock('../components/FormControls', () => ({ + MobileField: ({ children }: { children: React.ReactNode }) =>
{children}
, + MobileInput: (props: React.InputHTMLAttributes) => , + MobileSelect: ({ + children, + compact, + ...props + }: { + children: React.ReactNode; + compact?: boolean; + }) => , +})); + +describe('LimitWarnings', () => { + it('renders a single checkout button when add-on selection is available', () => { + const limits = { + photos: { + limit: 100, + used: 100, + remaining: 0, + percentage: 100, + state: 'limit_reached', + threshold_reached: null, + next_threshold: null, + thresholds: [], + }, + guests: null, + gallery: null, + can_upload_photos: false, + can_add_guests: true, + }; + + const addons = [ + { + key: 'extra_photos_500', + label: 'Extra photos', + price_id: 'pri_addon_photos', + }, + ]; + + const { getAllByRole } = render( + key} + textColor="#111827" + borderColor="#e5e7eb" + />, + ); + + expect(getAllByRole('button')).toHaveLength(1); + }); +}); diff --git a/tests/Feature/Tenant/EventAddonWebhookTest.php b/tests/Feature/Tenant/EventAddonWebhookTest.php index f1d0303..c4411d6 100644 --- a/tests/Feature/Tenant/EventAddonWebhookTest.php +++ b/tests/Feature/Tenant/EventAddonWebhookTest.php @@ -80,4 +80,73 @@ class EventAddonWebhookTest extends TenantTestCase Notification::assertSentTimes(AddonPurchaseReceipt::class, 1); } + + public function test_webhook_uses_stored_increments_over_catalog(): void + { + Notification::fake(); + + Config::set('package-addons.extra_guests', [ + 'label' => 'Guests 50', + 'increments' => ['extra_guests' => 50], + 'price_id' => 'pri_guests', + ]); + + $package = Package::factory()->endcustomer()->create([ + 'max_guests' => 50, + ]); + + $event = Event::factory()->for($this->tenant)->create([ + 'status' => 'published', + ]); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + '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_guests', + 'quantity' => 2, + 'extra_guests' => 400, + 'status' => 'pending', + 'metadata' => [ + 'addon_intent' => 'intent-456', + 'increments' => ['extra_guests' => 200], + ], + ]); + + $payload = [ + 'event_type' => 'transaction.completed', + 'data' => [ + 'id' => 'txn_addon_2', + 'custom_data' => [ + 'addon_intent' => 'intent-456', + 'addon_key' => 'extra_guests', + ], + ], + ]; + + $handler = app(EventAddonWebhookService::class); + + $handled = $handler->handle($payload); + + $this->assertTrue($handled); + + $addon->refresh(); + $eventPackage->refresh(); + + $this->assertSame('completed', $addon->status); + $this->assertSame('txn_addon_2', $addon->transaction_id); + $this->assertSame(400, $eventPackage->extra_guests); + + Notification::assertSentTimes(AddonPurchaseReceipt::class, 1); + } }