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