$input * @return array{content: array, 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), QrCode::TYPE_PAYMENT => $this->validatePayment($input), default => throw new RuntimeException('Unsupported QR type.'), }; } /** * @param array $input * @return array{content: array, 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 $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 $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 $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 $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 $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 $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 $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 = []; } // 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, ], 'destination_url' => null, ]; } /** @param array $tiers */ private function eventHasPaidTier(array $tiers): bool { foreach ($tiers as $tier) { if ((float) ($tier['price'] ?? 0) > 0) { return true; } } return false; } /** @param array $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 $day) { if (! is_array($day)) { continue; } $items = []; foreach ((array) ($day['items'] ?? []) as $item) { if (! is_array($item)) { continue; } $itemTitle = trim((string) ($item['title'] ?? '')); if ($itemTitle === '') { continue; } $items[] = [ '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 $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 $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 $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 $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 $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 */ 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}> */ 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.'); } } /** @param array $input */ private function validatePayment(array $input): array { $businessName = trim((string) ($input['business_name'] ?? '')); if ($businessName === '') { throw new RuntimeException('Enter your business or display name.'); } return [ 'content' => [ 'business_name' => mb_substr($businessName, 0, 120), 'branch_label' => mb_substr(trim((string) ($input['branch_label'] ?? '')), 0, 80) ?: null, 'currency' => strtoupper(trim((string) ($input['currency'] ?? 'GHS'))) ?: 'GHS', ], 'destination_url' => null, ]; } }