feat: event registration branding with vertical wizard layout

- Add registration_banner_url, registration_welcome_text, registration_logo_url
  columns to events table with migration
- Add uploadImage endpoint (POST .../upload-image) with form request validation
  for banner and logo images (jpg/png/webp, max 5MB)
- Include branding fields in EventResource and PublicRegistrationDataController
- Build registration settings UI in organizer event settings page with
  banner/logo upload and welcome text editor
- Redesign portal registration page: hero banner with gradient overlay,
  welcome text card, vertical step navigation (desktop) / horizontal chips
  (mobile), two-column form fields with density="comfortable"
- Update success page with event banner and consistent branding
- Seed welcome text for Echt Feesten 2026
- Add 9 PHPUnit tests covering image upload, branding fields in API responses

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 21:09:49 +02:00
parent 78cc19373e
commit 0d741550a8
16 changed files with 1225 additions and 672 deletions

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StoreEventRequest;
use App\Http\Requests\Api\V1\UpdateEventRequest;
use App\Http\Requests\Api\V1\UploadEventImageRequest;
use App\Http\Resources\Api\V1\EventResource;
use App\Models\Event;
use App\Models\Organisation;
@@ -15,6 +16,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
final class EventController extends Controller
{
@@ -126,6 +128,24 @@ final class EventController extends Controller
return EventResource::collection($children);
}
public function uploadImage(UploadEventImageRequest $request, Organisation $organisation, Event $event): JsonResponse
{
Gate::authorize('update', [$event, $organisation]);
$path = $request->file('image')->store(
"events/{$event->id}",
'public'
);
$field = $request->type === 'banner'
? 'registration_banner_url'
: 'registration_logo_url';
$event->update([$field => Storage::disk('public')->url($path)]);
return response()->json(['url' => $event->fresh()->{$field}]);
}
public function stats(Event $event): JsonResponse
{
Gate::authorize('view', $event);

View File

@@ -60,6 +60,9 @@ final class PublicRegistrationDataController extends Controller
'start_date' => $festivalEvent->start_date->toDateString(),
'end_date' => $festivalEvent->end_date->toDateString(),
'organisation_id' => $festivalEvent->organisation_id,
'registration_banner_url' => $festivalEvent->registration_banner_url,
'registration_welcome_text' => $festivalEvent->registration_welcome_text,
'registration_logo_url' => $festivalEvent->registration_logo_url,
],
'sections' => $sections->map(fn (FestivalSection $section) => [
'id' => $section->id,

View File

@@ -27,6 +27,7 @@ final class UpdateEventRequest extends FormRequest
'event_type' => ['sometimes', 'in:event,festival,series'],
'event_type_label' => ['nullable', 'string', 'max:50'],
'sub_event_label' => ['nullable', 'string', 'max:50'],
'registration_welcome_text' => ['nullable', 'string', 'max:1000'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
final class UploadEventImageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
'type' => ['required', 'in:banner,logo'],
];
}
}

View File

@@ -27,6 +27,9 @@ final class EventResource extends JsonResource
'event_type_label' => $this->event_type_label,
'sub_event_label' => $this->sub_event_label,
'is_recurring' => $this->is_recurring,
'registration_banner_url' => $this->registration_banner_url,
'registration_welcome_text' => $this->registration_welcome_text,
'registration_logo_url' => $this->registration_logo_url,
'is_festival' => $this->resource->isFestival(),
'is_sub_event' => $this->resource->isSubEvent(),
'is_flat_event' => $this->resource->isFlatEvent(),

View File

@@ -63,6 +63,9 @@ final class Event extends Model
'is_recurring',
'recurrence_rule',
'recurrence_exceptions',
'registration_banner_url',
'registration_welcome_text',
'registration_logo_url',
];
protected function casts(): array

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
$table->string('registration_banner_url')->nullable()->after('recurrence_exceptions');
$table->text('registration_welcome_text')->nullable()->after('registration_banner_url');
$table->string('registration_logo_url')->nullable()->after('registration_welcome_text');
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropColumn([
'registration_banner_url',
'registration_welcome_text',
'registration_logo_url',
]);
});
}
};

View File

@@ -175,6 +175,7 @@ class DevSeeder extends Seeder
'event_type' => 'festival',
'event_type_label' => 'Festival',
'sub_event_label' => 'Programmaonderdeel',
'registration_welcome_text' => 'Wij zoeken enthousiaste vrijwilligers voor Echt Feesten 2026! Word onderdeel van ons team en beleef het festival van de andere kant. Gratis toegang, maaltijden, en een onvergetelijke ervaring.',
]);
$vrijdag = Event::create([

View File

@@ -77,6 +77,7 @@ Route::middleware('auth:sanctum')->group(function () {
->only(['index', 'show', 'store', 'update', 'destroy']);
Route::get('organisations/{organisation}/events/{event}/children', [EventController::class, 'children']);
Route::post('organisations/{organisation}/events/{event}/transition', [EventController::class, 'transition']);
Route::post('organisations/{organisation}/events/{event}/upload-image', [EventController::class, 'uploadImage']);
// Organisation-scoped resources
Route::prefix('organisations/{organisation}')->group(function () {

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class EventImageUploadTest extends TestCase
{
use RefreshDatabase;
private Organisation $organisation;
private User $orgAdmin;
private Event $event;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->organisation = Organisation::factory()->create();
$this->orgAdmin = User::factory()->create();
$this->orgAdmin->assignRole('org_admin');
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
$this->event = Event::factory()->create([
'organisation_id' => $this->organisation->id,
]);
Storage::fake('public');
}
public function test_upload_banner_jpg(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/upload-image",
[
'image' => UploadedFile::fake()->image('banner.jpg', 1200, 400),
'type' => 'banner',
]
);
$response->assertOk()
->assertJsonStructure(['url']);
$this->event->refresh();
$this->assertNotNull($this->event->registration_banner_url);
}
public function test_upload_logo_png(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/upload-image",
[
'image' => UploadedFile::fake()->image('logo.png', 200, 200),
'type' => 'logo',
]
);
$response->assertOk()
->assertJsonStructure(['url']);
$this->event->refresh();
$this->assertNotNull($this->event->registration_logo_url);
}
public function test_upload_rejects_invalid_file_type(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/upload-image",
[
'image' => UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'),
'type' => 'banner',
]
);
$response->assertUnprocessable();
}
public function test_upload_rejects_file_too_large(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/upload-image",
[
'image' => UploadedFile::fake()->image('large.jpg')->size(6000),
'type' => 'banner',
]
);
$response->assertUnprocessable();
}
public function test_upload_requires_authentication(): void
{
$response = $this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/upload-image",
[
'image' => UploadedFile::fake()->image('banner.jpg'),
'type' => 'banner',
]
);
$response->assertUnauthorized();
}
public function test_upload_rejects_wrong_organisation(): void
{
$otherOrg = Organisation::factory()->create();
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/organisations/{$otherOrg->id}/events/{$this->event->id}/upload-image",
[
'image' => UploadedFile::fake()->image('banner.jpg'),
'type' => 'banner',
]
);
$response->assertForbidden();
}
public function test_registration_data_includes_branding_fields(): void
{
$event = Event::factory()->create([
'organisation_id' => $this->organisation->id,
'status' => 'registration_open',
'slug' => 'branding-test-event',
'registration_welcome_text' => 'Welcome to our event!',
'registration_banner_url' => 'https://example.com/banner.jpg',
'registration_logo_url' => 'https://example.com/logo.png',
]);
$response = $this->getJson('/api/v1/public/events/branding-test-event/registration-data');
$response->assertOk()
->assertJsonPath('data.event.registration_welcome_text', 'Welcome to our event!')
->assertJsonPath('data.event.registration_banner_url', 'https://example.com/banner.jpg')
->assertJsonPath('data.event.registration_logo_url', 'https://example.com/logo.png');
}
public function test_registration_data_includes_null_branding_fields(): void
{
Event::factory()->create([
'organisation_id' => $this->organisation->id,
'status' => 'registration_open',
'slug' => 'no-branding-event',
]);
$response = $this->getJson('/api/v1/public/events/no-branding-event/registration-data');
$response->assertOk()
->assertJsonPath('data.event.registration_welcome_text', null)
->assertJsonPath('data.event.registration_banner_url', null)
->assertJsonPath('data.event.registration_logo_url', null);
}
public function test_event_update_saves_welcome_text(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->putJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}",
[
'registration_welcome_text' => 'We zoeken vrijwilligers!',
]
);
$response->assertOk();
$this->event->refresh();
$this->assertEquals('We zoeken vrijwilligers!', $this->event->registration_welcome_text);
}
}