From d83a314beeba8c6ca9c6ba12c8eff0c6d70fae43 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 16 Jul 2026 20:56:52 +0000 Subject: [PATCH] Add Portfolio and Bookshop QR types with tailored landing pages. Portfolio showcases freelancer work projects; Bookshop lists author titles with multi-platform purchase links. Includes create/edit forms, public assets, and tests. --- .../Controllers/Public/QrScanController.php | 45 ++++ app/Models/QrCode.php | 4 + app/Services/Afia/AfiaService.php | 2 +- app/Services/Qr/QrCodeManagerService.php | 96 ++++++++ app/Services/Qr/QrPayloadValidator.php | 139 ++++++++++++ app/Support/Qr/QrTypeCatalog.php | 14 ++ public/images/qr-icons/bookshop.svg | 1 + public/images/qr-icons/portfolio.svg | 1 + resources/views/public/qr/landing.blade.php | 207 ++++++++++++++++++ .../partials/type-fields-create.blade.php | 187 ++++++++++++++++ .../partials/type-fields-edit.blade.php | 153 +++++++++++++ routes/web.php | 6 + tests/Feature/PortfolioBookshopQrTest.php | 135 ++++++++++++ 13 files changed, 989 insertions(+), 1 deletion(-) create mode 100644 public/images/qr-icons/bookshop.svg create mode 100644 public/images/qr-icons/portfolio.svg create mode 100644 tests/Feature/PortfolioBookshopQrTest.php diff --git a/app/Http/Controllers/Public/QrScanController.php b/app/Http/Controllers/Public/QrScanController.php index db8d519..6e16d2a 100644 --- a/app/Http/Controllers/Public/QrScanController.php +++ b/app/Http/Controllers/Public/QrScanController.php @@ -329,6 +329,51 @@ class QrScanController extends Controller return Storage::disk('qr')->response($path); } + public function portfolioAvatar(string $shortCode): StreamedResponse + { + return $this->serveContentImage($shortCode, QrCode::TYPE_PORTFOLIO, 'avatar_path'); + } + + public function portfolioCover(string $shortCode): StreamedResponse + { + return $this->serveContentImage($shortCode, QrCode::TYPE_PORTFOLIO, 'cover_path'); + } + + public function portfolioProjectImage(string $shortCode, int $index): StreamedResponse + { + return $this->serveListImage($shortCode, QrCode::TYPE_PORTFOLIO, 'projects', $index, 'image_path'); + } + + public function bookshopAvatar(string $shortCode): StreamedResponse + { + return $this->serveContentImage($shortCode, QrCode::TYPE_BOOKSHOP, 'avatar_path'); + } + + public function bookshopCover(string $shortCode): StreamedResponse + { + return $this->serveContentImage($shortCode, QrCode::TYPE_BOOKSHOP, 'cover_path'); + } + + public function bookshopBookCover(string $shortCode, int $index): StreamedResponse + { + return $this->serveListImage($shortCode, QrCode::TYPE_BOOKSHOP, 'books', $index, 'cover_path'); + } + + private function serveListImage(string $shortCode, string $type, string $listKey, int $index, string $pathKey): StreamedResponse + { + $qrCode = QrCode::query() + ->where('short_code', $shortCode) + ->where('is_active', true) + ->firstOrFail(); + + abort_unless($qrCode->type === $type, 404); + + $path = $qrCode->content()[$listKey][$index][$pathKey] ?? null; + abort_unless($path && Storage::disk('qr')->exists($path), 404); + + return Storage::disk('qr')->response($path); + } + private function escapeVcard(string $value): string { return str_replace(["\n", "\r", ',', ';'], [' ', ' ', '\\,', '\\;'], $value); diff --git a/app/Models/QrCode.php b/app/Models/QrCode.php index c71ca9d..cdeaad5 100644 --- a/app/Models/QrCode.php +++ b/app/Models/QrCode.php @@ -23,6 +23,8 @@ class QrCode extends Model public const TYPE_SHOP = 'shop'; public const TYPE_APP = 'app'; public const TYPE_BOOK = 'book'; + public const TYPE_PORTFOLIO = 'portfolio'; + public const TYPE_BOOKSHOP = 'bookshop'; public const TYPE_WIFI = 'wifi'; public const TYPE_COUPON = 'coupon'; public const TYPE_EVENT = 'event'; @@ -200,6 +202,8 @@ class QrCode extends Model self::TYPE_SHOP, self::TYPE_APP, self::TYPE_BOOK, + self::TYPE_PORTFOLIO, + self::TYPE_BOOKSHOP, self::TYPE_WIFI, self::TYPE_EVENT, self::TYPE_ITINERARY, diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php index e8c9b54..e57e1ae 100644 --- a/app/Services/Afia/AfiaService.php +++ b/app/Services/Afia/AfiaService.php @@ -103,7 +103,7 @@ class AfiaService What Ladill QR Plus does and where things live: - Overview (Dashboard): recent codes, active code count, scans in the last 30 days, wallet balance. - - My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, WiFi, App download. + - My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, Portfolio (freelancer work showcase), Bookshop (author books with buy links), WiFi, App download. - Creating a code: pick a type, set content, customize style (colors, logo, frame), then download PNG/SVG/PDF. - Dynamic codes: destination can change after printing — the printed QR always points to ladill.com/q/{code}. - Campaigns: group QR codes for a promo or launch (sidebar → Campaigns), attach/detach codes, and see combined scan totals. diff --git a/app/Services/Qr/QrCodeManagerService.php b/app/Services/Qr/QrCodeManagerService.php index 87ce022..1d48075 100644 --- a/app/Services/Qr/QrCodeManagerService.php +++ b/app/Services/Qr/QrCodeManagerService.php @@ -103,6 +103,38 @@ class QrCodeManagerService } } + if ($type === QrCode::TYPE_PORTFOLIO) { + if (($data['portfolio_avatar'] ?? null) instanceof UploadedFile) { + $content['avatar_path'] = $this->storeMenuBrandImage($user, $data['portfolio_avatar'], 'portfolio-avatars'); + } + if (($data['portfolio_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($user, $data['portfolio_cover'], 'portfolio-covers'); + } + $content['projects'] = $this->injectListImages( + $user, + $content['projects'] ?? [], + $data['project_images'] ?? [], + 'portfolio-projects', + 'image_path', + ); + } + + if ($type === QrCode::TYPE_BOOKSHOP) { + if (($data['bookshop_avatar'] ?? null) instanceof UploadedFile) { + $content['avatar_path'] = $this->storeMenuBrandImage($user, $data['bookshop_avatar'], 'bookshop-avatars'); + } + if (($data['bookshop_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($user, $data['bookshop_cover'], 'bookshop-covers'); + } + $content['books'] = $this->injectListImages( + $user, + $content['books'] ?? [], + $data['book_covers'] ?? [], + 'bookshop-books', + 'cover_path', + ); + } + if ($type === QrCode::TYPE_CHURCH) { if (($data['church_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'church-logos'); @@ -245,6 +277,38 @@ class QrCodeManagerService } } + if ($qrCode->type === QrCode::TYPE_PORTFOLIO) { + if (($data['portfolio_avatar'] ?? null) instanceof UploadedFile) { + $content['avatar_path'] = $this->storeMenuBrandImage($qrCode->user, $data['portfolio_avatar'], 'portfolio-avatars'); + } + if (($data['portfolio_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['portfolio_cover'], 'portfolio-covers'); + } + $content['projects'] = $this->injectListImages( + $qrCode->user, + $content['projects'] ?? [], + $data['project_images'] ?? [], + 'portfolio-projects', + 'image_path', + ); + } + + if ($qrCode->type === QrCode::TYPE_BOOKSHOP) { + if (($data['bookshop_avatar'] ?? null) instanceof UploadedFile) { + $content['avatar_path'] = $this->storeMenuBrandImage($qrCode->user, $data['bookshop_avatar'], 'bookshop-avatars'); + } + if (($data['bookshop_cover'] ?? null) instanceof UploadedFile) { + $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['bookshop_cover'], 'bookshop-covers'); + } + $content['books'] = $this->injectListImages( + $qrCode->user, + $content['books'] ?? [], + $data['book_covers'] ?? [], + 'bookshop-books', + 'cover_path', + ); + } + if ($qrCode->type === QrCode::TYPE_CHURCH) { if (($data['church_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_logo'], 'church-logos'); @@ -354,6 +418,8 @@ class QrCodeManagerService QrCode::TYPE_SHOP => ['shop_title', 'sections', 'currency', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'], QrCode::TYPE_APP => ['app_name', 'ios_url', 'android_url', 'web_url', 'app_icon'], QrCode::TYPE_BOOK => ['book_title', 'author', 'description', 'price_ghs'], + QrCode::TYPE_PORTFOLIO => ['name', 'role', 'bio', 'email', 'phone', 'website', 'brand_color', 'projects', 'social'], + QrCode::TYPE_BOOKSHOP => ['author_name', 'tagline', 'bio', 'email', 'website', 'brand_color', 'books', 'social'], QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'], default => [], }; @@ -480,6 +546,36 @@ class QrCodeManagerService return $path; } + /** + * Inject uploaded list images (project images / book covers) into a flat list. + * + * @param array $items + * @param array $uploads + * @return array + */ + private function injectListImages(User $user, array $items, array $uploads, string $subdir, string $pathKey): array + { + if ($uploads === []) { + return $items; + } + + foreach ($uploads as $index => $file) { + if (! ($file instanceof UploadedFile) || ! isset($items[$index])) { + continue; + } + $mime = $file->getMimeType() ?: ''; + if (! str_starts_with($mime, 'image/')) { + continue; + } + $ext = $file->getClientOriginalExtension() ?: 'jpg'; + $path = $user->id.'/'.$subdir.'/'.Str::uuid()->toString().'.'.$ext; + $file->storeAs('', $path, 'qr'); + $items[$index][$pathKey] = $path; + } + + return $items; + } + private function storeBookCover(User $user, UploadedFile $file): string { $mime = $file->getMimeType() ?: ''; diff --git a/app/Services/Qr/QrPayloadValidator.php b/app/Services/Qr/QrPayloadValidator.php index d7a1f85..2727818 100644 --- a/app/Services/Qr/QrPayloadValidator.php +++ b/app/Services/Qr/QrPayloadValidator.php @@ -34,6 +34,8 @@ class QrPayloadValidator QrCode::TYPE_SHOP => $this->validateShop($input), QrCode::TYPE_APP => $this->validateApp($input), QrCode::TYPE_BOOK => $this->validateBook($input), + QrCode::TYPE_PORTFOLIO => $this->validatePortfolio($input), + QrCode::TYPE_BOOKSHOP => $this->validateBookshop($input), QrCode::TYPE_WIFI => $this->validateWifi($input), default => throw new RuntimeException('Unsupported QR type.'), }; @@ -509,6 +511,143 @@ class QrPayloadValidator ]; } + /** @param array $input */ + private function validatePortfolio(array $input): array + { + $name = trim((string) ($input['name'] ?? '')); + if ($name === '') { + throw new RuntimeException('Enter your name.'); + } + + $projects = []; + foreach ((array) ($input['projects'] ?? []) as $row) { + if (! is_array($row)) { + continue; + } + $title = trim((string) ($row['title'] ?? '')); + if ($title === '') { + continue; + } + $url = trim((string) ($row['url'] ?? '')); + if ($url !== '' && ! filter_var($url, FILTER_VALIDATE_URL)) { + throw new RuntimeException("Enter a valid project URL for “{$title}”."); + } + $tags = array_values(array_filter(array_map( + fn ($t) => trim((string) $t), + preg_split('/[,|]/', (string) ($row['tags'] ?? '')) ?: [] + ))); + $projects[] = [ + 'title' => $title, + 'description' => trim((string) ($row['description'] ?? '')), + 'url' => $url, + 'tags' => $tags, + 'image_path' => $row['image_path'] ?? null, + ]; + } + + if ($projects === []) { + throw new RuntimeException('Add at least one project to your portfolio.'); + } + + return [ + 'content' => [ + 'name' => $name, + 'role' => trim((string) ($input['role'] ?? '')), + 'bio' => trim((string) ($input['bio'] ?? '')), + 'email' => trim((string) ($input['email'] ?? '')), + 'phone' => trim((string) ($input['phone'] ?? '')), + 'website' => trim((string) ($input['website'] ?? '')), + 'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#4f46e5', + 'avatar_path' => $input['avatar_path'] ?? null, + 'cover_path' => $input['cover_path'] ?? null, + 'social' => $this->normalizeSocialInput($input), + 'projects' => $projects, + ], + 'destination_url' => null, + ]; + } + + /** @param array $input */ + private function validateBookshop(array $input): array + { + $author = trim((string) ($input['author_name'] ?? '')); + if ($author === '') { + throw new RuntimeException('Enter the author name.'); + } + + $books = []; + foreach ((array) ($input['books'] ?? []) as $row) { + if (! is_array($row)) { + continue; + } + $title = trim((string) ($row['title'] ?? '')); + if ($title === '') { + continue; + } + $buyLinks = []; + foreach ((array) ($row['buy_links'] ?? []) as $link) { + if (! is_array($link)) { + continue; + } + $label = trim((string) ($link['label'] ?? '')); + $url = trim((string) ($link['url'] ?? '')); + if ($url === '') { + continue; + } + if (! filter_var($url, FILTER_VALIDATE_URL)) { + throw new RuntimeException("Enter a valid purchase URL for “{$title}”."); + } + $buyLinks[] = [ + 'label' => $label !== '' ? $label : 'Buy', + 'url' => $url, + ]; + } + if ($buyLinks === []) { + throw new RuntimeException("Add at least one purchase link for “{$title}”."); + } + $books[] = [ + 'title' => $title, + 'description' => trim((string) ($row['description'] ?? '')), + 'cover_path' => $row['cover_path'] ?? null, + 'buy_links' => $buyLinks, + ]; + } + + if ($books === []) { + throw new RuntimeException('Add at least one book to the bookshop.'); + } + + return [ + 'content' => [ + 'author_name' => $author, + 'tagline' => trim((string) ($input['tagline'] ?? '')), + 'bio' => trim((string) ($input['bio'] ?? '')), + 'email' => trim((string) ($input['email'] ?? '')), + 'website' => trim((string) ($input['website'] ?? '')), + 'brand_color' => $this->normalizeBrandColor($input['brand_color'] ?? null) ?? '#0f766e', + 'avatar_path' => $input['avatar_path'] ?? null, + 'cover_path' => $input['cover_path'] ?? null, + 'social' => $this->normalizeSocialInput($input), + 'books' => $books, + ], + 'destination_url' => null, + ]; + } + + /** @param array $input @return array */ + private function normalizeSocialInput(array $input): array + { + $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 $social; + } + /** @param array $input */ private function validateWifi(array $input): array { diff --git a/app/Support/Qr/QrTypeCatalog.php b/app/Support/Qr/QrTypeCatalog.php index 1d3492d..d1eba45 100644 --- a/app/Support/Qr/QrTypeCatalog.php +++ b/app/Support/Qr/QrTypeCatalog.php @@ -17,6 +17,8 @@ class QrTypeCatalog QrCode::TYPE_DOCUMENT, QrCode::TYPE_LINK_LIST, QrCode::TYPE_BUSINESS, + QrCode::TYPE_PORTFOLIO, + QrCode::TYPE_BOOKSHOP, QrCode::TYPE_WIFI, QrCode::TYPE_APP, ]; @@ -50,6 +52,18 @@ class QrTypeCatalog 'category' => 'contact', 'icon' => 'business-profile.svg', ], + QrCode::TYPE_PORTFOLIO => [ + 'label' => 'Portfolio', + 'description' => 'Showcase work for freelancers', + 'category' => 'contact', + 'icon' => 'portfolio.svg', + ], + QrCode::TYPE_BOOKSHOP => [ + 'label' => 'Bookshop', + 'description' => 'Author books with buy links', + 'category' => 'business', + 'icon' => 'bookshop.svg', + ], QrCode::TYPE_WIFI => [ 'label' => 'WiFi', 'description' => 'Network name and password', diff --git a/public/images/qr-icons/bookshop.svg b/public/images/qr-icons/bookshop.svg new file mode 100644 index 0000000..eb6d5c3 --- /dev/null +++ b/public/images/qr-icons/bookshop.svg @@ -0,0 +1 @@ + diff --git a/public/images/qr-icons/portfolio.svg b/public/images/qr-icons/portfolio.svg new file mode 100644 index 0000000..4274928 --- /dev/null +++ b/public/images/qr-icons/portfolio.svg @@ -0,0 +1 @@ + diff --git a/resources/views/public/qr/landing.blade.php b/resources/views/public/qr/landing.blade.php index 236f109..99076be 100644 --- a/resources/views/public/qr/landing.blade.php +++ b/resources/views/public/qr/landing.blade.php @@ -1094,6 +1094,213 @@ +{{-- ===== PORTFOLIO ===== --}} +@elseif($type === \App\Models\QrCode::TYPE_PORTFOLIO) +@php + $pName = $content['name'] ?? $qrCode->label; + $pRole = $content['role'] ?? ''; + $pColor = $content['brand_color'] ?? '#4f46e5'; + $pAvatar = !empty($content['avatar_path']) ? $qrCode->publicPath('portfolio-avatar') : null; + $pCover = !empty($content['cover_path']) ? $qrCode->publicPath('portfolio-cover') : null; + $pSocial = []; + foreach (($content['social'] ?? []) as $platform => $url) { + $pSocial[$platform] = \App\Services\Qr\QrPayloadValidator::normalizeSocialUrl($platform, trim((string) $url)); + } + $pInitials = strtoupper(substr($pName, 0, 1)); + $socialIcons = [ + 'linkedin' => 'linkedin.svg', 'twitter' => 'twitter.svg', 'instagram' => 'instagram.svg', + 'facebook' => 'facebook.svg', 'youtube' => 'youtube.svg', 'whatsapp' => 'whatsapp.svg', + ]; +@endphp +
+
+ @if($pCover) + +
+ @else +
+ @endif +
+
+
+
+
+ @if($pAvatar) + {{ $pName }} + @else + {{ $pInitials }} + @endif +
+

{{ $pName }}

+ @if($pRole)

{{ $pRole }}

@endif + @if(!empty($content['bio']))

{{ $content['bio'] }}

@endif +
+ @if(!empty($content['email'])) + Email + @endif + @if(!empty($content['phone'])) + Call + @endif + @if(!empty($content['website'])) + Website + @endif +
+ @if($pSocial !== []) +
+ @foreach($pSocial as $platform => $url) + @if(isset($socialIcons[$platform])) + + {{ $platform }} + + @endif + @endforeach +
+ @endif +
+
+

Selected work

+
+ @foreach(($content['projects'] ?? []) as $index => $project) +
+ @if(!empty($project['image_path'])) + {{ $project['title'] ?? '' }} + @endif +
+

{{ $project['title'] ?? '' }}

+ @if(!empty($project['description'])) +

{{ $project['description'] }}

+ @endif + @if(!empty($project['tags'])) +
+ @foreach($project['tags'] as $tag) + {{ $tag }} + @endforeach +
+ @endif + @if(!empty($project['url'])) + + View project + + + @endif +
+
+ @endforeach +
+
+
+
+
+ +{{-- ===== BOOKSHOP ===== --}} +@elseif($type === \App\Models\QrCode::TYPE_BOOKSHOP) +@php + $aName = $content['author_name'] ?? $qrCode->label; + $aTagline = $content['tagline'] ?? ''; + $aColor = $content['brand_color'] ?? '#0f766e'; + $aAvatar = !empty($content['avatar_path']) ? $qrCode->publicPath('bookshop-avatar') : null; + $aCover = !empty($content['cover_path']) ? $qrCode->publicPath('bookshop-cover') : null; + $aSocial = []; + foreach (($content['social'] ?? []) as $platform => $url) { + $aSocial[$platform] = \App\Services\Qr\QrPayloadValidator::normalizeSocialUrl($platform, trim((string) $url)); + } + $aInitials = strtoupper(substr($aName, 0, 1)); + $socialIcons = [ + 'linkedin' => 'linkedin.svg', 'twitter' => 'twitter.svg', 'instagram' => 'instagram.svg', + 'facebook' => 'facebook.svg', 'youtube' => 'youtube.svg', 'whatsapp' => 'whatsapp.svg', + ]; +@endphp +
+
+ @if($aCover) + +
+ @else +
+ @endif +
+

Author bookshop

+

{{ $aName }}

+ @if($aTagline)

{{ $aTagline }}

@endif +
+
+ +
+
+
+
+ @if($aAvatar) + {{ $aName }} + @else + {{ $aInitials }} + @endif +
+
+ @if(!empty($content['bio'])) +

{{ $content['bio'] }}

+ @endif +
+ @if(!empty($content['website'])) + Website + @endif + @if(!empty($content['email'])) + Email + @endif +
+
+
+ @if($aSocial !== []) +
+ @foreach($aSocial as $platform => $url) + @if(isset($socialIcons[$platform])) + + {{ $platform }} + + @endif + @endforeach +
+ @endif +
+ +

Books

+
+ @foreach(($content['books'] ?? []) as $index => $book) +
+
+
+ @if(!empty($book['cover_path'])) + {{ $book['title'] ?? '' }} + @else +
+ {{ $book['title'] ?? 'Book' }} +
+ @endif +
+
+

{{ $book['title'] ?? '' }}

+ @if(!empty($book['description'])) +

{{ $book['description'] }}

+ @endif +
+ @foreach(($book['buy_links'] ?? []) as $buy) + + {{ $buy['label'] ?? 'Buy' }} + + @endforeach +
+
+
+
+ @endforeach +
+
+
+ {{-- ===== COUPON ===== --}} @elseif($type === \App\Models\QrCode::TYPE_COUPON)
diff --git a/resources/views/qr-codes/partials/type-fields-create.blade.php b/resources/views/qr-codes/partials/type-fields-create.blade.php index 3792e24..093e1fd 100644 --- a/resources/views/qr-codes/partials/type-fields-create.blade.php +++ b/resources/views/qr-codes/partials/type-fields-create.blade.php @@ -678,6 +678,193 @@
+{{-- Portfolio --}} +
+
+ + + +
+ + +
+ +
+ +
+
+ + +
+
+
+ + + +
+
+ + +
+
+ +
+

Social links (optional)

+
+ @foreach(['linkedin' => 'LinkedIn', 'twitter' => 'X / Twitter', 'instagram' => 'Instagram', 'facebook' => 'Facebook', 'youtube' => 'YouTube', 'whatsapp' => 'WhatsApp'] as $platform => $label) + + @endforeach +
+
+ +
+
+

Projects *

+ +
+ +
+
+ +{{-- Bookshop --}} +
+
+ + + +
+ + +
+
+ +
+
+ + +
+
+ + + +
+
+ + +
+
+ +
+

Social links (optional)

+
+ @foreach(['instagram' => 'Instagram', 'twitter' => 'X / Twitter', 'facebook' => 'Facebook', 'youtube' => 'YouTube', 'linkedin' => 'LinkedIn'] as $platform => $label) + + @endforeach +
+
+ +
+
+

Books *

+ +
+ +
+
+ {{-- App --}}
+ @elseif($qrCode->type === \App\Models\QrCode::TYPE_PORTFOLIO) + @php + $projects = collect($c['projects'] ?? [])->map(fn ($p) => [ + 'title' => $p['title'] ?? '', + 'description' => $p['description'] ?? '', + 'url' => $p['url'] ?? '', + 'tags' => implode(', ', $p['tags'] ?? []), + 'image_path' => $p['image_path'] ?? null, + ])->values()->all(); + if ($projects === []) { + $projects = [['title' => '', 'description' => '', 'url' => '', 'tags' => '', 'image_path' => null]]; + } + @endphp +
+
+ + + +
+ + +
+ + +
+
+
+ + + @if(!empty($c['avatar_path'])) + + @endif +
+
+ + +
+
+
+ @foreach(['linkedin','twitter','instagram','facebook','youtube','whatsapp'] as $platform) + + @endforeach +
+
+
+

Projects

+ +
+ +
+
+ + @elseif($qrCode->type === \App\Models\QrCode::TYPE_BOOKSHOP) + @php + $books = collect($c['books'] ?? [])->map(function ($b) { + $links = collect($b['buy_links'] ?? [])->map(fn ($l) => [ + 'label' => $l['label'] ?? '', + 'url' => $l['url'] ?? '', + ])->values()->all(); + if ($links === []) { + $links = [['label' => 'Amazon', 'url' => '']]; + } + return [ + 'title' => $b['title'] ?? '', + 'description' => $b['description'] ?? '', + 'cover_path' => $b['cover_path'] ?? null, + 'buy_links' => $links, + ]; + })->values()->all(); + if ($books === []) { + $books = [['title' => '', 'description' => '', 'cover_path' => null, 'buy_links' => [['label' => 'Amazon', 'url' => '']]]]; + } + @endphp +
+
+ + + +
+ + +
+ +
+
+
+ + +
+
+ + +
+
+
+ @foreach(['instagram','twitter','facebook','youtube','linkedin'] as $platform) + + @endforeach +
+
+
+

Books

+ +
+ +
+
+ @elseif($qrCode->type === \App\Models\QrCode::TYPE_APP)
name('qr.p Route::get('/q/{shortCode}/business-logo', [QrScanController::class, 'businessLogo'])->name('qr.public.business.logo'); Route::get('/q/{shortCode}/business-cover', [QrScanController::class, 'businessCover'])->name('qr.public.business.cover'); Route::get('/q/{shortCode}/app-icon', [QrScanController::class, 'appIcon'])->name('qr.public.app.icon'); +Route::get('/q/{shortCode}/portfolio-avatar', [QrScanController::class, 'portfolioAvatar'])->name('qr.public.portfolio.avatar'); +Route::get('/q/{shortCode}/portfolio-cover', [QrScanController::class, 'portfolioCover'])->name('qr.public.portfolio.cover'); +Route::get('/q/{shortCode}/portfolio-project/{index}', [QrScanController::class, 'portfolioProjectImage'])->name('qr.public.portfolio.project')->whereNumber('index'); +Route::get('/q/{shortCode}/bookshop-avatar', [QrScanController::class, 'bookshopAvatar'])->name('qr.public.bookshop.avatar'); +Route::get('/q/{shortCode}/bookshop-cover', [QrScanController::class, 'bookshopCover'])->name('qr.public.bookshop.cover'); +Route::get('/q/{shortCode}/bookshop-book/{index}', [QrScanController::class, 'bookshopBookCover'])->name('qr.public.bookshop.book')->whereNumber('index'); }); Route::middleware(['auth', 'platform.session'])->group(function () { diff --git a/tests/Feature/PortfolioBookshopQrTest.php b/tests/Feature/PortfolioBookshopQrTest.php new file mode 100644 index 0000000..a4fc894 --- /dev/null +++ b/tests/Feature/PortfolioBookshopQrTest.php @@ -0,0 +1,135 @@ +withoutMiddleware([ + EnsurePlatformSession::class, + \App\Http\Middleware\RedirectLegacyQrToLadillLink::class, + ]); + if (method_exists($this, 'withoutVite')) { + $this->withoutVite(); + } + } + + private function user(): User + { + return User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Creator', + 'email' => 'creator+'.uniqid().'@example.com', + ]); + } + + public function test_portfolio_payload_validates_and_landing_renders(): void + { + $validator = app(QrPayloadValidator::class); + $result = $validator->validateForCreate(QrCode::TYPE_PORTFOLIO, [ + 'name' => 'Ama Mensah', + 'role' => 'Brand designer', + 'bio' => 'I design identity systems.', + 'email' => 'ama@example.com', + 'projects' => [ + [ + 'title' => 'Cafe rebrand', + 'description' => 'Logo and packaging', + 'url' => 'https://example.com/cafe', + 'tags' => 'branding, print', + ], + ], + ]); + + $this->assertSame('Ama Mensah', $result['content']['name']); + $this->assertCount(1, $result['content']['projects']); + $this->assertSame(['branding', 'print'], $result['content']['projects'][0]['tags']); + + $user = $this->user(); + $code = QrCode::create([ + 'user_id' => $user->id, + 'short_code' => 'portf001', + 'type' => QrCode::TYPE_PORTFOLIO, + 'label' => 'Ama portfolio', + 'is_active' => true, + 'payload' => ['content' => $result['content'], 'style' => []], + ]); + + $this->get(route('qr.public.resolve', $code->short_code)) + ->assertOk() + ->assertSee('Ama Mensah') + ->assertSee('Cafe rebrand') + ->assertSee('Selected work'); + } + + public function test_bookshop_payload_validates_and_landing_renders(): void + { + $validator = app(QrPayloadValidator::class); + $result = $validator->validateForCreate(QrCode::TYPE_BOOKSHOP, [ + 'author_name' => 'Kojo Asante', + 'tagline' => 'Stories from Accra', + 'books' => [ + [ + 'title' => 'Harmattan Dust', + 'description' => 'A novel of return.', + 'buy_links' => [ + ['label' => 'Amazon', 'url' => 'https://amazon.com/dp/example'], + ['label' => 'Bookshop.org', 'url' => 'https://bookshop.org/example'], + ], + ], + ], + ]); + + $this->assertSame('Kojo Asante', $result['content']['author_name']); + $this->assertCount(2, $result['content']['books'][0]['buy_links']); + + $user = $this->user(); + $code = QrCode::create([ + 'user_id' => $user->id, + 'short_code' => 'books001', + 'type' => QrCode::TYPE_BOOKSHOP, + 'label' => 'Kojo books', + 'is_active' => true, + 'payload' => ['content' => $result['content'], 'style' => []], + ]); + + $this->get(route('qr.public.resolve', $code->short_code)) + ->assertOk() + ->assertSee('Kojo Asante') + ->assertSee('Harmattan Dust') + ->assertSee('Amazon') + ->assertSee('Bookshop.org'); + } + + public function test_portfolio_requires_project(): void + { + $this->expectException(\RuntimeException::class); + app(QrPayloadValidator::class)->validateForCreate(QrCode::TYPE_PORTFOLIO, [ + 'name' => 'No projects', + 'projects' => [], + ]); + } + + public function test_bookshop_requires_buy_link(): void + { + $this->expectException(\RuntimeException::class); + app(QrPayloadValidator::class)->validateForCreate(QrCode::TYPE_BOOKSHOP, [ + 'author_name' => 'Author', + 'books' => [ + ['title' => 'Untitled', 'buy_links' => []], + ], + ]); + } +}