From 944433c77947dc55278622cb8ceb5d208be89b07 Mon Sep 17 00:00:00 2001 From: albanobattistella <34811668+albanobattistella@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:54:54 +0200 Subject: [PATCH 1/8] Update Italian translations (#1151) --- frontend/src/locales/it.po | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/locales/it.po b/frontend/src/locales/it.po index 9a2fa885d..8b490f8d2 100644 --- a/frontend/src/locales/it.po +++ b/frontend/src/locales/it.po @@ -8,7 +8,7 @@ msgstr "" "Language: it\n" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-03-15 10:37+0200\n" +"PO-Revision-Date: 2026-04-08 20:37+0200\n" "Last-Translator: Albano Battistella \n" "Language-Team: Italian\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -25,7 +25,7 @@ msgstr "- Clicca per annullare la pubblicazione" #: src/components/common/NoResultsSplash/index.tsx:14 msgid "'There\\'s nothing to show yet'" -msgstr "" +msgstr "'Non c'è\\ ancora niente da mostrare'." #: src/components/routes/admin/Attribution/index.tsx:146 msgid "(empty)" @@ -2438,7 +2438,7 @@ msgstr "Cliente" #: src/components/common/OrdersTable/index.tsx:159 msgid "Customer link copied to clipboard" -msgstr "" +msgstr "Link cliente copiato negli appunti" #: src/components/modals/RefundOrderModal/index.tsx:125 msgid "Customer will receive an email confirming the refund" @@ -2802,6 +2802,8 @@ msgid "" "Due to the high risk of spam, you must connect a Stripe account before you can send messages to attendees.\n" "This is to ensure that all event organizers are verified and accountable." msgstr "" +"A causa dell'elevato rischio di spam, è necessario collegare un account Stripe prima di poter inviare messaggi ai partecipanti.\n" +"Questo per garantire che tutti gli organizzatori dell'evento siano verificati e responsabili." #: src/components/common/ProductsTable/SortableProduct/index.tsx:488 msgid "Duplicate" @@ -4634,7 +4636,7 @@ msgstr "Iscriviti alla lista d'attesa" #: src/components/modals/JoinWaitlistModal/index.tsx:143 msgid "Join Waitlist for {productDisplayName}" -msgstr "" +msgstr "Iscriviti alla lista d'attesa per {productDisplayName}" #: src/components/common/JoinWaitlistButton/index.tsx:30 #: src/components/common/WaitlistTable/index.tsx:197 @@ -6764,6 +6766,8 @@ msgid "" "Provide additional context or instructions for this question. Use this field to add terms\n" "and conditions, guidelines, or any important information that attendees need to know before answering." msgstr "" +"Fornisci ulteriori informazioni o istruzioni per questa domanda. Utilizza questo campo per aggiungere termini\n" +"e condizioni, linee guida o qualsiasi altra informazione importante che i partecipanti debbano conoscere prima di rispondere." #: src/components/common/StatusToggle/index.tsx:97 msgid "Publish" From bc983fac6e7da4b9a2f83e9262a8c2a541999df8 Mon Sep 17 00:00:00 2001 From: Florian Meyer Date: Sat, 18 Apr 2026 12:14:58 +0200 Subject: [PATCH 2/8] Unhiding hidden categories in the event manage view @see #1126 (#1138) --- .../Handlers/Product/GetProductsHandler.php | 1 + .../GetProductCategoriesHandler.php | 1 + .../Domain/Product/ProductFilterService.php | 14 ++++++++++---- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php b/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php index 73ae5c746..09a01e057 100644 --- a/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php +++ b/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php @@ -28,6 +28,7 @@ public function handle(int $eventId, QueryParamsDTO $queryParamsDTO): LengthAwar $filteredProducts = $this->productFilterService->filter( productsCategories: $productPaginator->getCollection(), hideSoldOutProducts: false, + hideHiddenCategories: false, ); $productPaginator->setCollection($filteredProducts); diff --git a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php index 804b005a6..18fde07dc 100644 --- a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php +++ b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php @@ -51,6 +51,7 @@ public function handle(int $eventId): Collection return $this->productFilterService->filter( productsCategories: $categories, hideSoldOutProducts: false, + hideHiddenCategories: false, ); } } diff --git a/backend/app/Services/Domain/Product/ProductFilterService.php b/backend/app/Services/Domain/Product/ProductFilterService.php index a3b5ec086..36af1acc1 100644 --- a/backend/app/Services/Domain/Product/ProductFilterService.php +++ b/backend/app/Services/Domain/Product/ProductFilterService.php @@ -41,12 +41,14 @@ public function __construct( * @param Collection $productsCategories * @param PromoCodeDomainObject|null $promoCode * @param bool $hideSoldOutProducts + * @param bool $hideHiddenCategories * @return Collection */ public function filter( Collection $productsCategories, ?PromoCodeDomainObject $promoCode = null, bool $hideSoldOutProducts = true, + bool $hideHiddenCategories = true, ): Collection { if ($productsCategories->isEmpty()) { @@ -57,8 +59,9 @@ public function filter( ->flatMap(fn(ProductCategoryDomainObject $category) => $category->getProducts()); if ($products->isEmpty()) { - return $productsCategories - ->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden()); + return $hideHiddenCategories + ? $productsCategories->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden()) + : $productsCategories; } $eventId = $products->first()->getEventId(); @@ -73,8 +76,11 @@ public function filter( ->reject(fn(ProductDomainObject $product) => $this->filterProduct($product, $promoCode, $hideSoldOutProducts)) ->each(fn(ProductDomainObject $product) => $this->processProductPrices($product, $hideSoldOutProducts)); - return $productsCategories - ->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden()) + $filteredCategories = $hideHiddenCategories + ? $productsCategories->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden()) + : $productsCategories; + + return $filteredCategories ->each(fn(ProductCategoryDomainObject $category) => $category->setProducts( $filteredProducts->where( static fn(ProductDomainObject $product) => $product->getProductCategoryId() === $category->getId() From 914111e58e936bb27277d541b8fd83a6d215064e Mon Sep 17 00:00:00 2001 From: Right Jeong Date: Sat, 18 Apr 2026 19:18:46 +0900 Subject: [PATCH 3/8] i18n: wrap hardcoded 'at' in order summary email in __() (#1171) Co-authored-by: dalsoop Co-authored-by: Claude Opus 4.7 (1M context) --- backend/resources/views/emails/orders/summary.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/resources/views/emails/orders/summary.blade.php b/backend/resources/views/emails/orders/summary.blade.php index e40be22a5..ab596db48 100644 --- a/backend/resources/views/emails/orders/summary.blade.php +++ b/backend/resources/views/emails/orders/summary.blade.php @@ -37,7 +37,7 @@ # {{ __('Event Details') }} **{{ __('Event Name:') }}** {{ $event->getTitle() }}
-**{{ __('Date & Time:') }}** {{ (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('F j, Y') }} at {{ (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('g:i A') }} +**{{ __('Date & Time:') }}** {{ __(':date at :time', ['date' => (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('F j, Y'), 'time' => (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('g:i A')]) }}

From f30521e403bfb5ffe15a8d8b30e6939090caa7de Mon Sep 17 00:00:00 2001 From: Right Jeong Date: Sat, 18 Apr 2026 19:25:45 +0900 Subject: [PATCH 4/8] fix(LanguageSwitcher): add default case to prevent SSR crash on unhandled locale (#1170) Co-authored-by: dalsoop Co-authored-by: Claude Opus 4.7 (1M context) --- frontend/src/components/common/LanguageSwitcher/index.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/components/common/LanguageSwitcher/index.tsx b/frontend/src/components/common/LanguageSwitcher/index.tsx index da08edbe0..86293728d 100644 --- a/frontend/src/components/common/LanguageSwitcher/index.tsx +++ b/frontend/src/components/common/LanguageSwitcher/index.tsx @@ -40,6 +40,13 @@ export const LanguageSwitcher = () => { return t`Polish`; case "se": return t`Swedish`; + default: + // Defensive fallback: if a new locale is added to SupportedLocales + // but not handled here, return the locale code itself rather than + // undefined. An undefined label propagates into Mantine's Combobox + // `defaultOptionsFilter`, which calls `.toLowerCase()` on it and + // throws during SSR, 500-ing every auth page. + return locale; } }; From 3109cad047dcbc0000d6fd40d89e102977226619 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Sat, 18 Apr 2026 12:27:29 +0100 Subject: [PATCH 5/8] feat: add organizer tracking pixels with cookie consent (#1166) Co-authored-by: Claude Opus 4.6 (1M context) --- .../Enums/TrackingPixelProvider.php | 33 +++ .../OrganizerSettingDomainObjectAbstract.php | 28 ++ .../PartialUpdateOrganizerSettingsRequest.php | 53 ++++ backend/app/Models/OrganizerSetting.php | 1 + .../OrganizerSettingsPublicResource.php | 20 +- .../Organizer/OrganizerSettingsResource.php | 2 + .../DTO/PartialUpdateOrganizerSettingsDTO.php | 4 + .../PartialUpdateOrganizerSettingsHandler.php | 3 + ..._tracking_pixels_to_organizer_settings.php | 23 ++ backend/scripts/deploy/deploy.sh | 10 + backend/scripts/deploy/run-scheduler.sh | 9 + backend/scripts/deploy/run-webhook-worker.sh | 10 + backend/scripts/deploy/run-worker.sh | 10 + .../Enums/TrackingPixelProviderTest.php | 53 ++++ ...tialUpdateOrganizerSettingsRequestTest.php | 144 +++++++++ ...tialUpdateOrganizerSettingsHandlerTest.php | 145 +++++++++ frontend/src/App.tsx | 15 +- .../CookieConsentBanner.module.scss | 120 ++++++++ .../common/CookieConsentBanner/index.tsx | 73 +++++ .../src/components/layouts/Checkout/index.tsx | 43 +++ .../layouts/EventHomepage/index.tsx | 20 ++ .../layouts/OrganizerHomepage/index.tsx | 9 + .../Sections/TrackingPixelSettings/index.tsx | 267 +++++++++++++++++ .../routes/organizer/Settings/index.tsx | 9 +- .../OrderSummaryAndProducts/index.tsx | 1 + .../src/hooks/useOrganizerTrackingPixels.ts | 82 ++++++ frontend/src/locales/de.js | 2 +- frontend/src/locales/de.po | 278 ++++++++++-------- frontend/src/locales/en.js | 2 +- frontend/src/locales/en.po | 278 ++++++++++-------- frontend/src/locales/es.js | 2 +- frontend/src/locales/es.po | 278 ++++++++++-------- frontend/src/locales/fr.js | 2 +- frontend/src/locales/fr.po | 278 ++++++++++-------- frontend/src/locales/hu.js | 2 +- frontend/src/locales/hu.po | 278 ++++++++++-------- frontend/src/locales/it.js | 2 +- frontend/src/locales/it.po | 278 ++++++++++-------- frontend/src/locales/nl.js | 2 +- frontend/src/locales/nl.po | 278 ++++++++++-------- frontend/src/locales/pl.js | 2 +- frontend/src/locales/pl.po | 278 ++++++++++-------- frontend/src/locales/pt-br.js | 2 +- frontend/src/locales/pt-br.po | 278 ++++++++++-------- frontend/src/locales/pt.js | 2 +- frontend/src/locales/pt.po | 278 ++++++++++-------- frontend/src/locales/ru.js | 2 +- frontend/src/locales/ru.po | 278 ++++++++++-------- frontend/src/locales/se.js | 2 +- frontend/src/locales/se.po | 278 ++++++++++-------- frontend/src/locales/tr.js | 2 +- frontend/src/locales/tr.po | 278 ++++++++++-------- frontend/src/locales/vi.js | 2 +- frontend/src/locales/vi.po | 278 ++++++++++-------- frontend/src/locales/zh-cn.js | 2 +- frontend/src/locales/zh-cn.po | 278 ++++++++++-------- frontend/src/locales/zh-hk.js | 2 +- frontend/src/locales/zh-hk.po | 278 ++++++++++-------- frontend/src/types.ts | 12 +- frontend/src/utilites/config.ts | 4 +- .../src/utilites/trackingPixels/consent.ts | 69 +++++ frontend/src/utilites/trackingPixels/index.ts | 66 +++++ .../trackingPixels/plugins/facebookPixel.ts | 70 +++++ .../plugins/googleAnalytics4.ts | 62 ++++ .../plugins/googleTagManager.ts | 51 ++++ .../trackingPixels/plugins/tiktokPixel.ts | 72 +++++ frontend/src/utilites/trackingPixels/types.ts | 24 ++ 67 files changed, 4171 insertions(+), 1926 deletions(-) create mode 100644 backend/app/DomainObjects/Enums/TrackingPixelProvider.php create mode 100644 backend/database/migrations/2026_04_14_000000_add_tracking_pixels_to_organizer_settings.php create mode 100755 backend/scripts/deploy/deploy.sh create mode 100755 backend/scripts/deploy/run-scheduler.sh create mode 100755 backend/scripts/deploy/run-webhook-worker.sh create mode 100755 backend/scripts/deploy/run-worker.sh create mode 100644 backend/tests/Unit/DomainObjects/Enums/TrackingPixelProviderTest.php create mode 100644 backend/tests/Unit/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequestTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/OrganizerSettings/PartialUpdateOrganizerSettingsHandlerTest.php create mode 100644 frontend/src/components/common/CookieConsentBanner/CookieConsentBanner.module.scss create mode 100644 frontend/src/components/common/CookieConsentBanner/index.tsx create mode 100644 frontend/src/components/routes/organizer/Settings/Sections/TrackingPixelSettings/index.tsx create mode 100644 frontend/src/hooks/useOrganizerTrackingPixels.ts create mode 100644 frontend/src/utilites/trackingPixels/consent.ts create mode 100644 frontend/src/utilites/trackingPixels/index.ts create mode 100644 frontend/src/utilites/trackingPixels/plugins/facebookPixel.ts create mode 100644 frontend/src/utilites/trackingPixels/plugins/googleAnalytics4.ts create mode 100644 frontend/src/utilites/trackingPixels/plugins/googleTagManager.ts create mode 100644 frontend/src/utilites/trackingPixels/plugins/tiktokPixel.ts create mode 100644 frontend/src/utilites/trackingPixels/types.ts diff --git a/backend/app/DomainObjects/Enums/TrackingPixelProvider.php b/backend/app/DomainObjects/Enums/TrackingPixelProvider.php new file mode 100644 index 000000000..4513dba99 --- /dev/null +++ b/backend/app/DomainObjects/Enums/TrackingPixelProvider.php @@ -0,0 +1,33 @@ + '/^\d{9,20}$/', + self::GOOGLE_ANALYTICS_4 => '/^G-[a-zA-Z0-9]{6,20}$/', + self::GOOGLE_TAG_MANAGER => '/^GTM-[a-zA-Z0-9]{4,20}$/', + self::TIKTOK_PIXEL => '/^[a-zA-Z0-9]{6,30}$/', + }; + } + + public function pixelIdFormatDescription(): string + { + return match ($this) { + self::FACEBOOK_PIXEL => __('Must be 9-20 digits (e.g., 1234567890)'), + self::GOOGLE_ANALYTICS_4 => __('Must start with G- followed by 6-20 characters (e.g., G-XXXXXXXXXX)'), + self::GOOGLE_TAG_MANAGER => __('Must start with GTM- followed by 4-20 characters (e.g., GTM-XXXXXXX)'), + self::TIKTOK_PIXEL => __('Must be 6-30 alphanumeric characters'), + }; + } +} diff --git a/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php index 167a2dfda..b09f7c756 100644 --- a/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php @@ -29,6 +29,8 @@ abstract class OrganizerSettingDomainObjectAbstract extends \HiEvents\DomainObje final public const DEFAULT_SHOW_MARKETING_OPT_IN = 'default_show_marketing_opt_in'; final public const DEFAULT_PASS_PLATFORM_FEE_TO_BUYER = 'default_pass_platform_fee_to_buyer'; final public const DEFAULT_ALLOW_ATTENDEE_SELF_EDIT = 'default_allow_attendee_self_edit'; + final public const TRACKING_PIXELS = 'tracking_pixels'; + final public const TRACKING_CONSENT_ACKNOWLEDGED = 'tracking_consent_acknowledged'; protected int $id; protected int $organizer_id; @@ -49,6 +51,8 @@ abstract class OrganizerSettingDomainObjectAbstract extends \HiEvents\DomainObje protected bool $default_show_marketing_opt_in = true; protected bool $default_pass_platform_fee_to_buyer = false; protected bool $default_allow_attendee_self_edit = true; + protected array|string|null $tracking_pixels = null; + protected bool $tracking_consent_acknowledged = false; public function toArray(): array { @@ -72,6 +76,8 @@ public function toArray(): array 'default_show_marketing_opt_in' => $this->default_show_marketing_opt_in ?? null, 'default_pass_platform_fee_to_buyer' => $this->default_pass_platform_fee_to_buyer ?? null, 'default_allow_attendee_self_edit' => $this->default_allow_attendee_self_edit ?? null, + 'tracking_pixels' => $this->tracking_pixels ?? null, + 'tracking_consent_acknowledged' => $this->tracking_consent_acknowledged ?? null, ]; } @@ -284,4 +290,26 @@ public function getDefaultAllowAttendeeSelfEdit(): bool { return $this->default_allow_attendee_self_edit; } + + public function setTrackingPixels(array|string|null $tracking_pixels): self + { + $this->tracking_pixels = $tracking_pixels; + return $this; + } + + public function getTrackingPixels(): array|string|null + { + return $this->tracking_pixels; + } + + public function setTrackingConsentAcknowledged(bool $tracking_consent_acknowledged): self + { + $this->tracking_consent_acknowledged = $tracking_consent_acknowledged; + return $this; + } + + public function getTrackingConsentAcknowledged(): bool + { + return $this->tracking_consent_acknowledged; + } } diff --git a/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php index 4466966af..544143692 100644 --- a/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php +++ b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php @@ -5,12 +5,58 @@ use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; use HiEvents\DomainObjects\Enums\OrganizerHomepageVisibility; +use HiEvents\DomainObjects\Enums\TrackingPixelProvider; use HiEvents\Http\Request\BaseRequest; use HiEvents\Validators\Rules\RulesHelper; use Illuminate\Validation\Rule; class PartialUpdateOrganizerSettingsRequest extends BaseRequest { + public function after(): array + { + return [ + function ($validator) { + $pixels = $this->input('tracking_pixels', []); + if (!is_array($pixels)) { + return; + } + + $isSaasMode = config('app.saas_mode_enabled'); + + foreach ($pixels as $index => $pixel) { + $providerValue = $pixel['provider'] ?? null; + $pixelId = $pixel['pixel_id'] ?? ''; + $provider = TrackingPixelProvider::tryFrom($providerValue); + + if ($isSaasMode && $provider === TrackingPixelProvider::GOOGLE_TAG_MANAGER) { + $validator->errors()->add( + "tracking_pixels.{$index}.provider", + __('Google Tag Manager is not available on hosted plans for security reasons.') + ); + continue; + } + + if ($provider && $pixelId !== '') { + if (!preg_match($provider->pixelIdPattern(), $pixelId)) { + $validator->errors()->add( + "tracking_pixels.{$index}.pixel_id", + $provider->pixelIdFormatDescription() + ); + } + } + } + + $enabledPixels = collect($pixels)->filter(fn ($p) => !empty($p['enabled'])); + if ($enabledPixels->isNotEmpty() && !$this->input('tracking_consent_acknowledged')) { + $validator->errors()->add( + 'tracking_consent_acknowledged', + __('You must acknowledge your data controller responsibilities before enabling tracking pixels.') + ); + } + }, + ]; + } + public static function rules(): array { return [ @@ -73,6 +119,13 @@ public static function rules(): array // Password 'homepage_password' => ['sometimes', 'nullable', 'string', 'max:100'], + + // Tracking pixels + 'tracking_pixels' => ['sometimes', 'nullable', 'array', 'max:10'], + 'tracking_pixels.*.provider' => ['required', 'string', Rule::in(TrackingPixelProvider::valuesArray())], + 'tracking_pixels.*.pixel_id' => ['required', 'string', 'max:50', 'regex:/^[a-zA-Z0-9\-_]+$/'], + 'tracking_pixels.*.enabled' => ['required', 'boolean'], + 'tracking_consent_acknowledged' => ['sometimes', 'nullable', 'boolean'], ]; } } diff --git a/backend/app/Models/OrganizerSetting.php b/backend/app/Models/OrganizerSetting.php index 0d5311832..db72d958a 100644 --- a/backend/app/Models/OrganizerSetting.php +++ b/backend/app/Models/OrganizerSetting.php @@ -14,6 +14,7 @@ public function getCastMap(): array 'social_media_handles' => 'array', 'homepage_theme_settings' => 'array', 'location_details' => 'array', + 'tracking_pixels' => 'array', ]; } } diff --git a/backend/app/Resources/Organizer/OrganizerSettingsPublicResource.php b/backend/app/Resources/Organizer/OrganizerSettingsPublicResource.php index dd2c98080..80e0ab4c1 100644 --- a/backend/app/Resources/Organizer/OrganizerSettingsPublicResource.php +++ b/backend/app/Resources/Organizer/OrganizerSettingsPublicResource.php @@ -2,14 +2,30 @@ namespace HiEvents\Resources\Organizer; +use HiEvents\DomainObjects\Enums\TrackingPixelProvider; use HiEvents\DomainObjects\OrganizerSettingDomainObject; /** - * We can extend the OrganizerSettingsResource for now - * * @mixin OrganizerSettingDomainObject */ class OrganizerSettingsPublicResource extends OrganizerSettingsResource { + public function toArray($request): array + { + $data = parent::toArray($request); + unset( + $data['tracking_consent_acknowledged'], + $data['homepage_password'], + ); + + if (config('app.saas_mode_enabled') && !empty($data['tracking_pixels'])) { + $data['tracking_pixels'] = array_values(array_filter( + $data['tracking_pixels'], + fn($pixel) => ($pixel['provider'] ?? null) !== TrackingPixelProvider::GOOGLE_TAG_MANAGER->value, + )); + } + + return $data; + } } diff --git a/backend/app/Resources/Organizer/OrganizerSettingsResource.php b/backend/app/Resources/Organizer/OrganizerSettingsResource.php index c17ee47cf..439f9fe31 100644 --- a/backend/app/Resources/Organizer/OrganizerSettingsResource.php +++ b/backend/app/Resources/Organizer/OrganizerSettingsResource.php @@ -29,6 +29,8 @@ public function toArray($request): array 'seo_description' => $this->getSeoDescription(), 'allow_search_engine_indexing' => $this->getAllowSearchEngineIndexing(), 'location_details' => $this->getLocationDetails(), + 'tracking_pixels' => $this->getTrackingPixels(), + 'tracking_consent_acknowledged' => $this->getTrackingConsentAcknowledged(), ]; } } diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php index 6a19b734d..9bc1c9444 100644 --- a/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php @@ -69,6 +69,10 @@ public function __construct( // Password public readonly string|Optional|null $homepagePassword, + + // Tracking pixels + public readonly array|Optional|null $trackingPixels, + public readonly bool|Optional|null $trackingConsentAcknowledged, ) { } diff --git a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php index cfc25a65c..cc97506a7 100644 --- a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php @@ -95,6 +95,9 @@ public function handle(PartialUpdateOrganizerSettingsDTO $dto): OrganizerSetting 'allow_search_engine_indexing' => $dto->getProvided('allowSearchEngineIndexing', $organizerSettings->getAllowSearchEngineIndexing()), 'homepage_password' => $dto->getProvided('homepagePassword', $organizerSettings->getHomepagePassword()), + + 'tracking_pixels' => $dto->getProvided('trackingPixels', $organizerSettings->getTrackingPixels()), + 'tracking_consent_acknowledged' => $dto->getProvided('trackingConsentAcknowledged', $organizerSettings->getTrackingConsentAcknowledged()), ], [ 'organizer_id' => $dto->organizerId, 'id' => $organizerSettings->getId(), diff --git a/backend/database/migrations/2026_04_14_000000_add_tracking_pixels_to_organizer_settings.php b/backend/database/migrations/2026_04_14_000000_add_tracking_pixels_to_organizer_settings.php new file mode 100644 index 000000000..e036484f2 --- /dev/null +++ b/backend/database/migrations/2026_04_14_000000_add_tracking_pixels_to_organizer_settings.php @@ -0,0 +1,23 @@ +jsonb('tracking_pixels')->nullable(); + $table->boolean('tracking_consent_acknowledged')->default(false); + }); + } + + public function down(): void + { + Schema::table('organizer_settings', function (Blueprint $table) { + $table->dropColumn('tracking_pixels'); + $table->dropColumn('tracking_consent_acknowledged'); + }); + } +}; diff --git a/backend/scripts/deploy/deploy.sh b/backend/scripts/deploy/deploy.sh new file mode 100755 index 000000000..cd920ce61 --- /dev/null +++ b/backend/scripts/deploy/deploy.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e + +echo "Running migrations..." +php artisan migrate --force + +echo "Caching configuration..." +php artisan optimize + +echo "Deployment complete." diff --git a/backend/scripts/deploy/run-scheduler.sh b/backend/scripts/deploy/run-scheduler.sh new file mode 100755 index 000000000..606026e4e --- /dev/null +++ b/backend/scripts/deploy/run-scheduler.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -e + +echo "Starting scheduler..." + +while true; do + php artisan schedule:run --verbose --no-interaction & + sleep 60 +done diff --git a/backend/scripts/deploy/run-webhook-worker.sh b/backend/scripts/deploy/run-webhook-worker.sh new file mode 100755 index 000000000..fce0baecf --- /dev/null +++ b/backend/scripts/deploy/run-webhook-worker.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e + +echo "Starting webhook queue worker..." +php artisan queue:work \ + --sleep=3 \ + --tries=3 \ + --max-time=3600 \ + --memory=512 \ + --queue=webhooks diff --git a/backend/scripts/deploy/run-worker.sh b/backend/scripts/deploy/run-worker.sh new file mode 100755 index 000000000..dec31e8f3 --- /dev/null +++ b/backend/scripts/deploy/run-worker.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e + +echo "Starting queue worker (default, webhooks)..." +php artisan queue:work \ + --sleep=3 \ + --tries=3 \ + --max-time=3600 \ + --memory=512 \ + --queue=default,webhooks diff --git a/backend/tests/Unit/DomainObjects/Enums/TrackingPixelProviderTest.php b/backend/tests/Unit/DomainObjects/Enums/TrackingPixelProviderTest.php new file mode 100644 index 000000000..0aa09b767 --- /dev/null +++ b/backend/tests/Unit/DomainObjects/Enums/TrackingPixelProviderTest.php @@ -0,0 +1,53 @@ +assertMatchesRegularExpression($provider->pixelIdPattern(), $pixelId); + } + + #[DataProvider('invalidPixelIdProvider')] + public function testInvalidPixelIdsFailValidation(TrackingPixelProvider $provider, string $pixelId): void + { + $this->assertDoesNotMatchRegularExpression($provider->pixelIdPattern(), $pixelId); + } + + public static function validPixelIdProvider(): array + { + return [ + 'facebook 10 digits' => [TrackingPixelProvider::FACEBOOK_PIXEL, '1234567890'], + 'facebook 15 digits' => [TrackingPixelProvider::FACEBOOK_PIXEL, '123456789012345'], + 'ga4 standard' => [TrackingPixelProvider::GOOGLE_ANALYTICS_4, 'G-ABC1234567'], + 'ga4 short' => [TrackingPixelProvider::GOOGLE_ANALYTICS_4, 'G-ABCDEF'], + 'gtm standard' => [TrackingPixelProvider::GOOGLE_TAG_MANAGER, 'GTM-ABCDEF'], + 'gtm short' => [TrackingPixelProvider::GOOGLE_TAG_MANAGER, 'GTM-ABCD'], + 'tiktok standard' => [TrackingPixelProvider::TIKTOK_PIXEL, 'ABCDEF123456'], + 'tiktok short' => [TrackingPixelProvider::TIKTOK_PIXEL, 'ABC123'], + ]; + } + + public static function invalidPixelIdProvider(): array + { + return [ + 'facebook too short' => [TrackingPixelProvider::FACEBOOK_PIXEL, '12345678'], + 'facebook with letters' => [TrackingPixelProvider::FACEBOOK_PIXEL, '123456789a'], + 'facebook with script' => [TrackingPixelProvider::FACEBOOK_PIXEL, ''], + 'ga4 missing prefix' => [TrackingPixelProvider::GOOGLE_ANALYTICS_4, 'ABC1234567'], + 'ga4 wrong prefix' => [TrackingPixelProvider::GOOGLE_ANALYTICS_4, 'UA-1234567'], + 'ga4 with special chars' => [TrackingPixelProvider::GOOGLE_ANALYTICS_4, 'G-ABC