Scaffolded from the Ladill mini/events extraction pattern: multi-file transfers, retention controls, public landing pages, analytics, and Gitea deploy workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
664 lines
26 KiB
PHP
664 lines
26 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),
|
|
QrCode::TYPE_PAYMENT => $this->validatePayment($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 = [];
|
|
}
|
|
|
|
// 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<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 $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<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.');
|
|
}
|
|
}
|
|
|
|
/** @param array<string, mixed> $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,
|
|
];
|
|
}
|
|
}
|