Files
ladill-events/app/Services/Qr/QrPayloadValidator.php
T
isaaccladandCursor d2ec31f7a5
Deploy Ladill Events / deploy (push) Successful in 28s
Fix post-registration 404, add speaker management, and simplify event creation.
Proxy registration transaction paths on Events, add a join button on the
confirmation page, introduce speaker roster management, and replace the QR
design wizard with merchant-style create/show flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 13:44:53 +00:00

734 lines
29 KiB
PHP

<?php
namespace App\Services\Qr;
use App\Models\QrCode;
use App\Support\Qr\QrDateFormatter;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\UploadedFile;
use RuntimeException;
class QrPayloadValidator
{
/**
* @param array<string, mixed> $input
* @return array{content: array<string, mixed>, destination_url: ?string}
*/
public function validateForCreate(string $type, array $input): array
{
if (! QrTypeCatalog::isValid($type)) {
throw new RuntimeException('Invalid QR type selected.');
}
return match ($type) {
QrCode::TYPE_URL => $this->validateUrl($input),
QrCode::TYPE_DOCUMENT => $this->validateDocument($input),
QrCode::TYPE_LINK_LIST => $this->validateLinkList($input),
QrCode::TYPE_VCARD => $this->validateVcard($input),
QrCode::TYPE_BUSINESS => $this->validateBusiness($input),
QrCode::TYPE_CHURCH => $this->validateChurch($input),
QrCode::TYPE_EVENT => $this->validateEvent($input),
QrCode::TYPE_ITINERARY => $this->validateItinerary($input),
QrCode::TYPE_IMAGE => ['content' => [], 'destination_url' => null],
QrCode::TYPE_MENU => $this->validateMenu($input),
QrCode::TYPE_SHOP => $this->validateShop($input),
QrCode::TYPE_APP => $this->validateApp($input),
QrCode::TYPE_BOOK => $this->validateBook($input),
QrCode::TYPE_WIFI => $this->validateWifi($input),
default => throw new RuntimeException('Unsupported QR type.'),
};
}
/**
* @param array<string, mixed> $input
* @return array{content: array<string, mixed>, destination_url: ?string}
*/
public function validateForUpdate(QrCode $qrCode, array $input): array
{
$merged = array_merge($qrCode->content(), $input);
if ($qrCode->isUrlType() && empty($merged['destination_url']) && ! empty($merged['url'])) {
$merged['destination_url'] = $merged['url'];
}
return $this->validateForCreate($qrCode->type, $merged);
}
/** @param array<string, mixed> $input */
private function validateDocument(array $input): array
{
$allowDownload = filter_var($input['allow_download'] ?? true, FILTER_VALIDATE_BOOL);
return ['content' => ['allow_download' => $allowDownload], 'destination_url' => null];
}
/** @param array<string, mixed> $input */
private function validateUrl(array $input): array
{
$url = $this->requireUrl($input['destination_url'] ?? null, 'Enter a valid destination URL.');
return ['content' => ['url' => $url], 'destination_url' => $url];
}
/** @param array<string, mixed> $input */
private function validateLinkList(array $input): array
{
$links = $this->normalizeLinks($input['links'] ?? []);
if ($links === []) {
throw new RuntimeException('Add at least one link.');
}
return ['content' => ['links' => $links], 'destination_url' => null];
}
/** @param array<string, mixed> $input */
private function validateVcard(array $input): array
{
$first = trim((string) ($input['first_name'] ?? ''));
$last = trim((string) ($input['last_name'] ?? ''));
if ($first === '' && $last === '') {
throw new RuntimeException('Enter a first or last name for the vCard.');
}
$social = [];
foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) {
$raw = trim((string) (($input['social'] ?? [])[$platform] ?? ''));
if ($raw !== '') {
$social[$platform] = static::normalizeSocialUrl($platform, $raw);
}
}
return [
'content' => [
'first_name' => $first,
'last_name' => $last,
'phone' => trim((string) ($input['phone'] ?? '')),
'email' => trim((string) ($input['email'] ?? '')),
'company' => trim((string) ($input['company'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'address' => trim((string) ($input['address'] ?? '')),
'note' => trim((string) ($input['note'] ?? '')),
'avatar_path' => $input['avatar_path'] ?? null,
'social' => $social,
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateBusiness(array $input): array
{
$name = trim((string) ($input['name'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter a business name.');
}
$social = [];
foreach (['linkedin', 'twitter', 'instagram', 'facebook', 'tiktok', 'youtube', 'whatsapp', 'snapchat'] as $platform) {
$raw = trim((string) (($input['social'] ?? [])[$platform] ?? ''));
if ($raw !== '') {
$social[$platform] = static::normalizeSocialUrl($platform, $raw);
}
}
return [
'content' => [
'name' => $name,
'tagline' => trim((string) ($input['tagline'] ?? '')),
'phone' => trim((string) ($input['phone'] ?? '')),
'email' => trim((string) ($input['email'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'address' => trim((string) ($input['address'] ?? '')),
'hours' => trim((string) ($input['hours'] ?? '')),
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
'social' => $social,
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateChurch(array $input): array
{
$name = trim((string) ($input['name'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter the church name.');
}
$orgTypes = ['church', 'school', 'mosque', 'ngo', 'club'];
$orgType = in_array($input['org_type'] ?? 'church', $orgTypes) ? $input['org_type'] : 'church';
// Normalise legacy lowercase slugs → display strings
$legacyMap = ['offering' => 'Offering', 'tithe' => 'Tithe', 'donation' => 'Donation', 'harvest' => 'Harvest'];
$collectionTypes = array_values(array_unique(array_filter(
array_map(fn ($t) => $legacyMap[strtolower(trim((string) $t))] ?? ucwords(trim((string) $t)), (array) ($input['collection_types'] ?? [])),
fn ($t) => $t !== '' && strlen($t) <= 60
)));
if (empty($collectionTypes)) {
$collectionTypes = ['Offering', 'Tithe', 'Donation', 'Harvest'];
}
return [
'content' => [
'name' => $name,
'denomination' => trim((string) ($input['denomination'] ?? '')),
'description' => trim((string) ($input['description'] ?? '')),
'phone' => trim((string) ($input['phone'] ?? '')),
'email' => trim((string) ($input['email'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'address' => trim((string) ($input['address'] ?? '')),
'service_times' => trim((string) ($input['service_times'] ?? '')),
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#1a3a5c',
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
'org_type' => $orgType,
'accepts_payment' => filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL),
'currency' => 'GHS',
'collection_types' => $collectionTypes,
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateEvent(array $input): array
{
$name = trim((string) ($input['name'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter the event name.');
}
// Ticket tiers: [{ name, price, capacity }]
$tiers = [];
foreach ((array) ($input['tiers'] ?? []) as $tier) {
if (! is_array($tier)) {
continue;
}
$tierName = trim((string) ($tier['name'] ?? ''));
if ($tierName === '') {
continue;
}
$price = round((float) ($tier['price'] ?? 0), 2);
$capacity = (int) ($tier['capacity'] ?? 0);
$tiers[] = [
'name' => mb_substr($tierName, 0, 80),
'price' => max(0, $price),
'capacity' => max(0, $capacity), // 0 = unlimited
];
}
if (empty($tiers)) {
$tiers = [['name' => 'General Admission', 'price' => 0.0, 'capacity' => 0]];
}
// Extra registration/badge fields the organiser wants captured.
$badgeFields = [];
foreach ((array) ($input['badge_fields'] ?? []) as $field) {
$label = trim((string) (is_array($field) ? ($field['label'] ?? '') : $field));
if ($label !== '' && strlen($label) <= 60) {
$badgeFields[] = mb_substr($label, 0, 60);
}
}
// Mode: sell tickets, collect cash contributions (weddings, non-profits),
// or a free event (registration only — no tickets, no contributions).
$mode = in_array($input['mode'] ?? 'ticketing', ['ticketing', 'contributions', 'free'], true)
? ($input['mode'] ?? 'ticketing')
: 'ticketing';
// Contribution categories: ['Wedding Gift', 'Donation', …]
$categories = [];
foreach ((array) ($input['contribution_categories'] ?? []) as $cat) {
$label = trim((string) (is_array($cat) ? ($cat['name'] ?? '') : $cat));
if ($label !== '') {
$categories[] = mb_substr($label, 0, 80);
}
}
if ($mode === 'contributions' && empty($categories)) {
$categories = ['Contribution'];
}
// Free events collect neither tickets nor contributions — a single free
// registration. Forced deterministically so switching from a paid setup
// can never leave a chargeable tier behind.
if ($mode === 'free') {
$tiers = [['name' => 'Registration', 'price' => 0.0, 'capacity' => 0]];
$categories = [];
}
$format = in_array($input['format'] ?? 'in_person', ['in_person', 'virtual', 'hybrid'], true)
? ($input['format'] ?? 'in_person')
: 'in_person';
$virtualSessions = $this->normalizeVirtualSessions($input['virtual_sessions'] ?? [], $format);
$speakers = $this->normalizeSpeakers($input['speakers'] ?? null, $input);
// Contributions always take payment; ticketing only for paid tiers; free never.
$acceptsPayment = match ($mode) {
'contributions' => true,
'free' => false,
default => $this->eventHasPaidTier($tiers),
};
return [
'content' => [
'name' => mb_substr($name, 0, 120),
'tagline' => trim((string) ($input['tagline'] ?? '')),
'description' => trim((string) ($input['description'] ?? '')),
'location' => trim((string) ($input['location'] ?? '')),
'starts_at' => QrDateFormatter::normalize((string) ($input['starts_at'] ?? '')),
'ends_at' => QrDateFormatter::normalize((string) ($input['ends_at'] ?? '')),
'organizer' => trim((string) ($input['organizer'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#4f46e5',
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
'currency' => 'GHS',
'mode' => $mode,
'tiers' => array_values($tiers),
'contribution_categories' => array_values($categories),
'badge_fields' => array_values($badgeFields),
'badge_size' => in_array($input['badge_size'] ?? '4x3', ['4x3', '4x6', 'cr80'], true) ? ($input['badge_size'] ?? '4x3') : '4x3',
'accepts_payment' => $acceptsPayment,
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null,
'format' => $format,
'virtual_sessions' => $virtualSessions,
'speakers' => $speakers,
],
'destination_url' => null,
];
}
/** @param mixed $sessions */
private function normalizeVirtualSessions(mixed $sessions, string $format): array
{
if ($format === 'in_person') {
return [];
}
if (! is_array($sessions)) {
return [];
}
$normalized = [];
foreach ($sessions as $session) {
if (! is_array($session)) {
continue;
}
$title = trim((string) ($session['title'] ?? ''));
if ($title === '') {
continue;
}
$id = trim((string) ($session['id'] ?? ''));
$roomType = in_array($session['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
? (string) $session['meet_room_type']
: 'town_hall';
$itemRefs = [];
foreach ((array) ($session['programme_item_refs'] ?? []) as $ref) {
$ref = trim((string) $ref);
if ($ref !== '') {
$itemRefs[] = mb_substr($ref, 0, 80);
}
}
$normalized[] = [
'id' => $id !== '' ? mb_substr($id, 0, 64) : null,
'title' => mb_substr($title, 0, 120),
'meet_room_type' => $roomType,
'scheduled_at' => QrDateFormatter::normalize((string) ($session['scheduled_at'] ?? '')),
'duration_minutes' => max(15, min(480, (int) ($session['duration_minutes'] ?? 60))),
'meet_room_uuid' => ($uuid = trim((string) ($session['meet_room_uuid'] ?? ''))) !== '' ? $uuid : null,
'join_url' => ($url = trim((string) ($session['join_url'] ?? ''))) !== '' ? $url : null,
'programme_item_refs' => array_values($itemRefs),
];
}
return array_values($normalized);
}
/** @return list<array{name: string, email: string, role: string, bio: string}> */
private function normalizeSpeakers(mixed $speakers, array $input): array
{
$rows = is_array($speakers) ? $speakers : (array) ($input['speakers'] ?? []);
$normalized = [];
foreach ($rows as $speaker) {
if (! is_array($speaker)) {
continue;
}
$name = trim((string) ($speaker['name'] ?? ''));
if ($name === '') {
continue;
}
$email = trim((string) ($speaker['email'] ?? ''));
$normalized[] = [
'name' => mb_substr($name, 0, 120),
'email' => filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : '',
'role' => mb_substr(trim((string) ($speaker['role'] ?? '')), 0, 80),
'bio' => mb_substr(trim((string) ($speaker['bio'] ?? '')), 0, 500),
];
}
return array_values($normalized);
}
/** @param array<int, array{price: float}> $tiers */
private function eventHasPaidTier(array $tiers): bool
{
foreach ($tiers as $tier) {
if ((float) ($tier['price'] ?? 0) > 0) {
return true;
}
}
return false;
}
/** @param array<string, mixed> $input */
private function validateItinerary(array $input): array
{
$title = trim((string) ($input['title'] ?? ''));
if ($title === '') {
throw new RuntimeException('Enter the itinerary title.');
}
// Days: [{ label, date, items: [{ time, title, description, location, host }] }]
$days = [];
foreach ((array) ($input['days'] ?? []) as $dayIndex => $day) {
if (! is_array($day)) {
continue;
}
$items = [];
foreach ((array) ($day['items'] ?? []) as $itemIndex => $item) {
if (! is_array($item)) {
continue;
}
$itemTitle = trim((string) ($item['title'] ?? ''));
if ($itemTitle === '') {
continue;
}
$items[] = [
'ref' => 'day'.$dayIndex.'-item'.$itemIndex,
'time' => mb_substr(trim((string) ($item['time'] ?? '')), 0, 40),
'title' => mb_substr($itemTitle, 0, 140),
'description' => mb_substr(trim((string) ($item['description'] ?? '')), 0, 400),
'location' => mb_substr(trim((string) ($item['location'] ?? '')), 0, 120),
'host' => mb_substr(trim((string) ($item['host'] ?? '')), 0, 120),
];
}
if (empty($items) && trim((string) ($day['label'] ?? '')) === '') {
continue;
}
$days[] = [
'label' => mb_substr(trim((string) ($day['label'] ?? '')), 0, 80),
'date' => QrDateFormatter::normalize((string) ($day['date'] ?? '')),
'items' => array_values($items),
];
}
if (empty($days)) {
throw new RuntimeException('Add at least one programme item.');
}
return [
'content' => [
'title' => mb_substr($title, 0, 120),
'subtitle' => trim((string) ($input['subtitle'] ?? '')),
'description' => trim((string) ($input['description'] ?? '')),
'event_date' => QrDateFormatter::normalize((string) ($input['event_date'] ?? '')),
'location' => trim((string) ($input['location'] ?? '')),
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#b45309',
'cover_path' => $input['cover_path'] ?? null,
'days' => array_values($days),
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateMenu(array $input): array
{
$title = trim((string) ($input['menu_title'] ?? 'Menu'));
$sections = $this->normalizeMenuSections($input['sections'] ?? []);
$acceptsPayment = filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL);
$shippingType = in_array($input['shipping_type'] ?? 'none', ['none', 'flat'], true)
? (string) ($input['shipping_type'] ?? 'none')
: 'none';
$shippingFee = $shippingType === 'flat' ? round(max(0, (float) ($input['shipping_fee'] ?? 0)), 2) : 0.0;
$freeShippingAbove = round(max(0, (float) ($input['free_shipping_above'] ?? 0)), 2);
if ($sections === []) {
throw new RuntimeException('Add at least one menu section with items.');
}
return [
'content' => [
'title' => $title,
'sections' => $sections,
'accepts_payment' => $acceptsPayment,
'shipping_type' => $shippingType,
'shipping_fee' => $shippingFee,
'free_shipping_above' => $freeShippingAbove,
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateShop(array $input): array
{
$title = trim((string) ($input['shop_title'] ?? $input['menu_title'] ?? 'Shop'));
$currency = trim((string) ($input['currency'] ?? 'GHS'));
$sections = $this->normalizeMenuSections($input['sections'] ?? []);
$acceptsPayment = filter_var($input['accepts_payment'] ?? false, FILTER_VALIDATE_BOOL);
$shippingType = in_array($input['shipping_type'] ?? 'none', ['none', 'flat'], true)
? (string) ($input['shipping_type'] ?? 'none')
: 'none';
$shippingFee = $shippingType === 'flat' ? round(max(0, (float) ($input['shipping_fee'] ?? 0)), 2) : 0.0;
$freeShippingAbove = round(max(0, (float) ($input['free_shipping_above'] ?? 0)), 2);
if ($sections === []) {
throw new RuntimeException('Add at least one category with products.');
}
return [
'content' => [
'title' => $title,
'currency' => $currency,
'sections' => $sections,
'accepts_payment' => $acceptsPayment,
'shipping_type' => $shippingType,
'shipping_fee' => $shippingFee,
'free_shipping_above' => $freeShippingAbove,
'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null),
'logo_path' => $input['logo_path'] ?? null,
'cover_path' => $input['cover_path'] ?? null,
],
'destination_url' => null,
];
}
private function normalizeBrandColor(mixed $value): ?string
{
if ($value === null) {
return null;
}
$hex = trim((string) $value);
return preg_match('/^#[0-9A-Fa-f]{6}$/', $hex) ? $hex : null;
}
/** @param array<string, mixed> $input */
private function validateApp(array $input): array
{
$name = trim((string) ($input['app_name'] ?? ''));
$ios = trim((string) ($input['ios_url'] ?? ''));
$android = trim((string) ($input['android_url'] ?? ''));
$web = trim((string) ($input['web_url'] ?? ''));
if ($name === '') {
throw new RuntimeException('Enter an app name.');
}
if ($ios === '' && $android === '' && $web === '') {
throw new RuntimeException('Add at least one app store or website link.');
}
foreach (['ios' => $ios, 'android' => $android, 'web' => $web] as $label => $url) {
if ($url !== '' && ! filter_var($url, FILTER_VALIDATE_URL)) {
throw new RuntimeException("Enter a valid {$label} URL.");
}
}
return [
'content' => [
'name' => $name,
'ios_url' => $ios,
'android_url' => $android,
'web_url' => $web,
'icon_path' => $input['icon_path'] ?? null,
],
'destination_url' => $web ?: ($ios ?: $android),
];
}
/** @param array<string, mixed> $input */
private function validateBook(array $input): array
{
$title = trim((string) ($input['book_title'] ?? ''));
$author = trim((string) ($input['author'] ?? ''));
$price = (float) ($input['price_ghs'] ?? 0);
if ($title === '') {
throw new RuntimeException('Enter the book title.');
}
if ($author === '') {
throw new RuntimeException('Enter the author name.');
}
if ($price <= 0) {
throw new RuntimeException('Enter a price greater than zero.');
}
return [
'content' => [
'book_title' => $title,
'author' => $author,
'description' => trim((string) ($input['description'] ?? '')),
'price_ghs' => round($price, 2),
'cover_path' => $input['cover_path'] ?? null,
'file_path' => $input['file_path'] ?? null,
'file_type' => $input['file_type'] ?? null,
'file_size' => (int) ($input['file_size'] ?? 0),
],
'destination_url' => null,
];
}
/** @param array<string, mixed> $input */
private function validateWifi(array $input): array
{
$ssid = trim((string) ($input['ssid'] ?? ''));
if ($ssid === '') {
throw new RuntimeException('Enter a WiFi network name (SSID).');
}
$encryption = strtoupper(trim((string) ($input['encryption'] ?? 'WPA')));
if (! in_array($encryption, ['WPA', 'WEP', 'NOPASS'], true)) {
$encryption = 'WPA';
}
return [
'content' => [
'ssid' => $ssid,
'password' => (string) ($input['password'] ?? ''),
'encryption' => $encryption,
'hidden' => filter_var($input['hidden'] ?? false, FILTER_VALIDATE_BOOL),
],
'destination_url' => null,
];
}
public static function normalizeSocialUrl(string $platform, string $value): string
{
// Already a full URL — leave as-is
if (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) {
return $value;
}
$handle = ltrim($value, '@');
return match ($platform) {
'linkedin' => 'https://linkedin.com/in/' . $handle,
'twitter' => 'https://x.com/' . $handle,
'instagram' => 'https://instagram.com/' . $handle,
'facebook' => 'https://facebook.com/' . $handle,
'tiktok' => 'https://tiktok.com/@' . $handle,
'youtube' => 'https://youtube.com/@' . $handle,
'snapchat' => 'https://snapchat.com/add/' . $handle,
'whatsapp' => 'https://wa.me/' . preg_replace('/\D+/', '', $value),
default => $value,
};
}
private function requireUrl(mixed $value, string $message): string
{
$url = trim((string) $value);
if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
throw new RuntimeException($message);
}
return $url;
}
/** @return list<array{title: string, url: string}> */
private function normalizeLinks(mixed $links): array
{
if (! is_array($links)) {
return [];
}
$normalized = [];
foreach ($links as $link) {
if (! is_array($link)) {
continue;
}
$title = trim((string) ($link['title'] ?? ''));
$url = trim((string) ($link['url'] ?? ''));
if ($title === '' || $url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
continue;
}
$normalized[] = ['title' => $title, 'url' => $url];
}
return $normalized;
}
/**
* Normalize menu/shop sections. Preserves existing image_path on items so that
* the manager can inject freshly uploaded paths after validation.
*
* @return list<array{name: string, items: list<array{name: string, description: string, price: string, image_path: ?string}>}>
*/
private function normalizeMenuSections(mixed $sections): array
{
if (! is_array($sections)) {
return [];
}
$normalized = [];
foreach ($sections as $section) {
if (! is_array($section)) {
continue;
}
$name = trim((string) ($section['name'] ?? ''));
$items = [];
foreach (($section['items'] ?? []) as $item) {
if (! is_array($item)) {
continue;
}
$itemName = trim((string) ($item['name'] ?? ''));
if ($itemName === '') {
continue;
}
$items[] = [
'name' => $itemName,
'description' => trim((string) ($item['description'] ?? '')),
'price' => trim((string) ($item['price'] ?? '')),
'image_path' => ($item['image_path'] ?? null) ?: null,
];
}
if ($name !== '' && $items !== []) {
$normalized[] = ['name' => $name, 'items' => $items];
}
}
return $normalized;
}
public function validateImageUpload(?UploadedFile $file): void
{
if (! $file instanceof UploadedFile) {
throw new RuntimeException('Upload at least one image.');
}
$mime = $file->getMimeType() ?: '';
if (! str_starts_with($mime, 'image/')) {
throw new RuntimeException('Only image files are supported.');
}
}
}