diff --git a/app/Http/Controllers/Qr/AccountController.php b/app/Http/Controllers/Qr/AccountController.php index 333d75c..04bb101 100644 --- a/app/Http/Controllers/Qr/AccountController.php +++ b/app/Http/Controllers/Qr/AccountController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Qr; use App\Http\Controllers\Controller; use App\Models\QrSetting; use App\Services\Billing\BillingClient; +use App\Support\AccountBranding; use App\Support\Qr\QrCornerStyleCatalog; use App\Support\Qr\QrFrameStyleCatalog; use App\Support\Qr\QrModuleStyleCatalog; @@ -112,11 +113,25 @@ class AccountController extends Controller 'default_style.gradient_color1' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'], 'default_style.gradient_color2' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'], 'default_style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'], + 'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'], + 'remove_logo' => ['nullable', 'boolean'], ]); $eventDefaults = $data['event_defaults'] ?? []; $eventDefaults['registration_open'] = $request->boolean('event_defaults.registration_open'); + $settings = $account->getOrCreateQrSetting(); + $logoPath = $settings->logo_path; + + if ($request->boolean('remove_logo')) { + AccountBranding::deleteStoredLogo($settings); + $logoPath = null; + } + + if ($request->hasFile('logo')) { + $logoPath = AccountBranding::storeLogo($settings, $request->file('logo')); + } + QrSetting::updateOrCreate( ['user_id' => $account->id], [ @@ -127,6 +142,7 @@ class AccountController extends Controller 'notify_payouts' => $request->boolean('notify_payouts'), 'event_defaults' => QrSetting::sanitizeEventDefaults($eventDefaults), 'default_style' => QrSetting::sanitizeDefaultStyle($data['default_style'] ?? null), + 'logo_path' => $logoPath, ], ); diff --git a/app/Models/QrSetting.php b/app/Models/QrSetting.php index 7bbbcc1..631e8e7 100644 --- a/app/Models/QrSetting.php +++ b/app/Models/QrSetting.php @@ -20,6 +20,7 @@ class QrSetting extends Model 'default_type', 'default_style', 'event_defaults', + 'logo_path', ]; protected $casts = [ diff --git a/app/Services/Events/EventEmailService.php b/app/Services/Events/EventEmailService.php index 918da60..02826f2 100644 --- a/app/Services/Events/EventEmailService.php +++ b/app/Services/Events/EventEmailService.php @@ -2,6 +2,7 @@ namespace App\Services\Events; +use App\Support\AccountBranding; use Illuminate\Support\Facades\View; class EventEmailService @@ -129,6 +130,10 @@ class EventEmailService ?string $replyToEmail = null, ?string $replyToName = null, ): bool { + $brandingSettings = AccountBranding::forOwnerRef($ownerPublicId); + $viewData['logoUrl'] = AccountBranding::emailLogoUrl($brandingSettings); + $viewData['companyName'] = AccountBranding::companyName($brandingSettings); + $html = View::make($view, $viewData)->render(); $text = strip_tags(str_replace(['
', '
', '
'], "\n", $html)); diff --git a/app/Support/AccountBranding.php b/app/Support/AccountBranding.php new file mode 100644 index 0000000..31643e3 --- /dev/null +++ b/app/Support/AccountBranding.php @@ -0,0 +1,90 @@ +getOrCreateQrSetting(); + } + + public static function forOwnerRef(string $ownerPublicId): ?QrSetting + { + $user = User::query()->where('public_id', $ownerPublicId)->first(); + + return $user ? $user->getOrCreateQrSetting() : null; + } + + public static function companyName(?QrSetting $settings, ?User $user = null): string + { + if ($user) { + return (string) ($user->name ?: 'Company'); + } + + if ($settings?->user) { + return (string) ($settings->user->name ?: 'Company'); + } + + if ($settings?->user_id) { + $owner = User::query()->find($settings->user_id); + + return (string) ($owner?->name ?: 'Company'); + } + + return 'Company'; + } + + public static function hasCustomLogo(?QrSetting $settings): bool + { + return $settings !== null + && $settings->logo_path !== null + && Storage::disk('public')->exists($settings->logo_path); + } + + public static function emailLogoUrl(?QrSetting $settings): ?string + { + if (! self::hasCustomLogo($settings)) { + return null; + } + + $ext = strtolower(pathinfo((string) $settings->logo_path, PATHINFO_EXTENSION)); + if ($ext === 'svg') { + return null; + } + + $version = $settings->updated_at?->getTimestamp() ?? time(); + + return Storage::disk('public')->url($settings->logo_path).'?v='.$version; + } + + public static function logoUrl(?QrSetting $settings): ?string + { + if (! self::hasCustomLogo($settings)) { + return null; + } + + $version = $settings->updated_at?->getTimestamp() ?? time(); + + return Storage::disk('public')->url($settings->logo_path).'?v='.$version; + } + + public static function storeLogo(QrSetting $settings, UploadedFile $file): string + { + self::deleteStoredLogo($settings); + + return $file->store('events/accounts/'.$settings->user_id, 'public'); + } + + public static function deleteStoredLogo(QrSetting $settings): void + { + if ($settings->logo_path) { + Storage::disk('public')->delete($settings->logo_path); + } + } +} diff --git a/database/migrations/2026_07_12_200000_add_logo_path_to_qr_settings_table.php b/database/migrations/2026_07_12_200000_add_logo_path_to_qr_settings_table.php new file mode 100644 index 0000000..1edb9c6 --- /dev/null +++ b/database/migrations/2026_07_12_200000_add_logo_path_to_qr_settings_table.php @@ -0,0 +1,22 @@ +string('logo_path')->nullable()->after('event_defaults'); + }); + } + + public function down(): void + { + Schema::table('qr_settings', function (Blueprint $table) { + $table->dropColumn('logo_path'); + }); + } +}; diff --git a/resources/views/email/customer-branded.blade.php b/resources/views/email/customer-branded.blade.php new file mode 100644 index 0000000..81411c5 --- /dev/null +++ b/resources/views/email/customer-branded.blade.php @@ -0,0 +1,22 @@ + + + + + + + +
+
+ @if (! empty($logoUrl)) + {{ $companyName }} + @else +
{{ $companyName }}
+ @endif +
+
+ {!! $bodyHtml !!} +
+

{{ $companyName }}

+
+ + diff --git a/resources/views/mail/notifications/event-join-programme.blade.php b/resources/views/mail/notifications/event-join-programme.blade.php index c7634b2..b5266f0 100644 --- a/resources/views/mail/notifications/event-join-programme.blade.php +++ b/resources/views/mail/notifications/event-join-programme.blade.php @@ -1,11 +1,11 @@ @extends('mail.notifications.layout') @section('email-header') - @include('mail.partials.brand-header', ['brand' => 'events']) + @include('mail.partials.brand-header', ['brand' => 'events', 'logoUrl' => $logoUrl ?? null, 'companyName' => $companyName ?? null]) @endsection @section('email-footer') - @include('mail.partials.brand-footer', ['brand' => 'events']) + @include('mail.partials.brand-footer', ['brand' => 'events', 'companyName' => $companyName ?? null]) @endsection @section('content') diff --git a/resources/views/mail/notifications/event-join.blade.php b/resources/views/mail/notifications/event-join.blade.php index b08493d..37a9788 100644 --- a/resources/views/mail/notifications/event-join.blade.php +++ b/resources/views/mail/notifications/event-join.blade.php @@ -1,11 +1,11 @@ @extends('mail.notifications.layout') @section('email-header') - @include('mail.partials.brand-header', ['brand' => 'events']) + @include('mail.partials.brand-header', ['brand' => 'events', 'logoUrl' => $logoUrl ?? null, 'companyName' => $companyName ?? null]) @endsection @section('email-footer') - @include('mail.partials.brand-footer', ['brand' => 'events']) + @include('mail.partials.brand-footer', ['brand' => 'events', 'companyName' => $companyName ?? null]) @endsection @section('content') diff --git a/resources/views/mail/notifications/event-programme.blade.php b/resources/views/mail/notifications/event-programme.blade.php index ba5db16..6221da6 100644 --- a/resources/views/mail/notifications/event-programme.blade.php +++ b/resources/views/mail/notifications/event-programme.blade.php @@ -1,11 +1,11 @@ @extends('mail.notifications.layout') @section('email-header') - @include('mail.partials.brand-header', ['brand' => 'events']) + @include('mail.partials.brand-header', ['brand' => 'events', 'logoUrl' => $logoUrl ?? null, 'companyName' => $companyName ?? null]) @endsection @section('email-footer') - @include('mail.partials.brand-footer', ['brand' => 'events']) + @include('mail.partials.brand-footer', ['brand' => 'events', 'companyName' => $companyName ?? null]) @endsection @section('content') diff --git a/resources/views/mail/notifications/event-registration-confirmed.blade.php b/resources/views/mail/notifications/event-registration-confirmed.blade.php index 0cb1d13..da2d7aa 100644 --- a/resources/views/mail/notifications/event-registration-confirmed.blade.php +++ b/resources/views/mail/notifications/event-registration-confirmed.blade.php @@ -1,11 +1,11 @@ @extends('mail.notifications.layout') @section('email-header') - @include('mail.partials.brand-header', ['brand' => 'events']) + @include('mail.partials.brand-header', ['brand' => 'events', 'logoUrl' => $logoUrl ?? null, 'companyName' => $companyName ?? null]) @endsection @section('email-footer') - @include('mail.partials.brand-footer', ['brand' => 'events']) + @include('mail.partials.brand-footer', ['brand' => 'events', 'companyName' => $companyName ?? null]) @endsection @section('content') diff --git a/resources/views/mail/notifications/event-speaker-invite.blade.php b/resources/views/mail/notifications/event-speaker-invite.blade.php index 41c0e14..4fb33e7 100644 --- a/resources/views/mail/notifications/event-speaker-invite.blade.php +++ b/resources/views/mail/notifications/event-speaker-invite.blade.php @@ -1,11 +1,11 @@ @extends('mail.notifications.layout') @section('email-header') - @include('mail.partials.brand-header', ['brand' => 'events']) + @include('mail.partials.brand-header', ['brand' => 'events', 'logoUrl' => $logoUrl ?? null, 'companyName' => $companyName ?? null]) @endsection @section('email-footer') - @include('mail.partials.brand-footer', ['brand' => 'events']) + @include('mail.partials.brand-footer', ['brand' => 'events', 'companyName' => $companyName ?? null]) @endsection @section('content') diff --git a/resources/views/mail/partials/brand-footer.blade.php b/resources/views/mail/partials/brand-footer.blade.php index 6f6b66a..850bb2d 100644 --- a/resources/views/mail/partials/brand-footer.blade.php +++ b/resources/views/mail/partials/brand-footer.blade.php @@ -1,20 +1,24 @@ -@php - $brandKey = $brand ?? 'ladill'; - $brandConfig = config("mail_brands.brands.{$brandKey}", config('mail_brands.brands.ladill')); - $appBase = rtrim((string) ($brandConfig['app_url'] ?? config('app.url')), '/'); - $dashboardPath = $brandConfig['dashboard_path'] ?? '/'; - $accountBase = rtrim((string) ($brandConfig['account_url'] ?? config('mail_brands.account_url', 'https://account.ladill.com')), '/'); - $supportUrl = $brandConfig['support_url'] ?? $accountBase.'/support-tickets'; - $homeLabel = $brandConfig['home_label'] ?? parse_url($appBase, PHP_URL_HOST) ?: 'ladill.com'; -@endphp - - - +@if (! empty($companyName)) + +@else + @php + $brandKey = $brand ?? 'ladill'; + $brandConfig = config("mail_brands.brands.{$brandKey}", config('mail_brands.brands.ladill')); + $appBase = rtrim((string) ($brandConfig['app_url'] ?? config('app.url')), '/'); + $dashboardPath = $brandConfig['dashboard_path'] ?? '/'; + $accountBase = rtrim((string) ($brandConfig['account_url'] ?? config('mail_brands.account_url', 'https://account.ladill.com')), '/'); + $supportUrl = $brandConfig['support_url'] ?? $accountBase.'/support-tickets'; + $homeLabel = $brandConfig['home_label'] ?? parse_url($appBase, PHP_URL_HOST) ?: 'ladill.com'; + @endphp + + + +@endif diff --git a/resources/views/mail/partials/brand-header.blade.php b/resources/views/mail/partials/brand-header.blade.php index cef6b4c..e66ae1e 100644 --- a/resources/views/mail/partials/brand-header.blade.php +++ b/resources/views/mail/partials/brand-header.blade.php @@ -1,5 +1,11 @@ -@php - $brandKey = $brand ?? 'ladill'; - $brandConfig = config("mail_brands.brands.{$brandKey}", config('mail_brands.brands.ladill')); -@endphp -{{ $brandConfig['name'] }} +@if (! empty($logoUrl)) + {{ $companyName ?? '' }} +@elseif (! empty($companyName)) +
{{ $companyName }}
+@else + @php + $brandKey = $brand ?? 'ladill'; + $brandConfig = config("mail_brands.brands.{$brandKey}", config('mail_brands.brands.ladill')); + @endphp + {{ $brandConfig['name'] }} +@endif diff --git a/resources/views/qr/account/settings.blade.php b/resources/views/qr/account/settings.blade.php index 5ea8081..1432327 100644 --- a/resources/views/qr/account/settings.blade.php +++ b/resources/views/qr/account/settings.blade.php @@ -14,7 +14,7 @@
{{ session('success') }}
@endif -
+ @csrf @method('PUT') @@ -71,6 +71,28 @@ +
+

Company branding

+

Your logo appears in customer emails (registration confirmations, join links, and programme shares).

+ + @if (\App\Support\AccountBranding::hasCustomLogo($settings)) +
+ Company logo +
+ + @endif + +
+ + + @error('logo')

{{ $message }}

@enderror +
+
+

New event defaults

Pre-fill the create flow so every new event starts with your usual setup.

diff --git a/tests/Feature/EventEmailBrandingTest.php b/tests/Feature/EventEmailBrandingTest.php new file mode 100644 index 0000000..2a44649 --- /dev/null +++ b/tests/Feature/EventEmailBrandingTest.php @@ -0,0 +1,153 @@ +withoutMiddleware(EnsurePlatformSession::class); + Http::preventStrayRequests(); + Storage::fake('public'); + } + + public function test_can_upload_logo_on_settings_update(): void + { + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Branded Events Co', + 'email' => 'brand+'.uniqid().'@example.com', + ]); + + $this->actingAs($user) + ->put(route('account.settings.update'), [ + 'notify_registrations' => '1', + 'notify_payouts' => '1', + 'product_updates' => '1', + 'low_balance_alerts' => '1', + 'event_defaults' => [ + 'registration_open' => '1', + ], + 'logo' => UploadedFile::fake()->image('company-logo.png', 200, 80), + ]) + ->assertRedirect(route('account.settings')); + + $settings = QrSetting::where('user_id', $user->id)->first(); + + $this->assertNotNull($settings); + $this->assertNotNull($settings->logo_path); + Storage::disk('public')->assertExists($settings->logo_path); + } + + public function test_registration_email_contains_logo_url_when_set(): void + { + $user = User::create([ + 'public_id' => 'usr-brand-logo-001', + 'name' => 'Branded Events Co', + 'email' => 'brand-logo@example.com', + ]); + + $logoPath = 'events/accounts/'.$user->id.'/company-logo.png'; + Storage::disk('public')->put($logoPath, 'fake-logo'); + + QrSetting::create([ + 'user_id' => $user->id, + 'logo_path' => $logoPath, + ]); + + $this->seedBirdCredentials($user); + + $capturedHtml = null; + Http::fake([ + '*/api/smtp/send' => function ($request) use (&$capturedHtml) { + $capturedHtml = $request['html']; + + return Http::response(['success' => true, 'message_id' => 'x']); + }, + ]); + + $sent = app(EventEmailService::class)->sendRegistrationConfirmation( + $user->public_id, + 'ada@example.com', + 'Demo event', + 'BADGE123', + 'https://meet.test/join', + 'Ada Lovelace', + $user->email, + $user->name, + ); + + $this->assertTrue($sent); + $this->assertNotNull($capturedHtml); + $this->assertStringContainsString('/storage/'.$logoPath, $capturedHtml); + $this->assertStringNotContainsString('ladillevents-logo-email.png', $capturedHtml); + } + + public function test_registration_email_uses_company_name_without_logo(): void + { + $user = User::create([ + 'public_id' => 'usr-brand-text-001', + 'name' => 'CAPBuSS Events', + 'email' => 'brand-text@example.com', + ]); + + $this->seedBirdCredentials($user); + + $capturedHtml = null; + Http::fake([ + '*/api/smtp/send' => function ($request) use (&$capturedHtml) { + $capturedHtml = $request['html']; + + return Http::response(['success' => true, 'message_id' => 'x']); + }, + ]); + + $sent = app(EventEmailService::class)->sendRegistrationConfirmation( + $user->public_id, + 'ada@example.com', + 'Demo event', + 'BADGE123', + null, + 'Ada Lovelace', + $user->email, + $user->name, + ); + + $this->assertTrue($sent); + $this->assertNotNull($capturedHtml); + $this->assertStringContainsString('CAPBuSS Events', $capturedHtml); + $this->assertStringNotContainsString('ladillevents-logo-email.png', $capturedHtml); + $this->assertStringNotContainsString('Ladill Technologies', $capturedHtml); + } + + private function seedBirdCredentials(User $user): void + { + $plain = 'lsk_live_'.str_repeat('f', 32); + + MessagingCredential::create([ + 'owner_ref' => $user->public_id, + 'bird_api_key_encrypted' => Crypt::encryptString($plain), + 'bird_api_key_prefix' => substr($plain, 0, 16), + 'bird_from_email' => 'events@example.test', + 'bird_from_name' => 'Events', + 'bird_status' => MessagingCredential::STATUS_VALID, + 'bird_validated_at' => now(), + ]); + } +}