Add Portfolio and Bookshop QR types with tailored landing pages.
Deploy Ladill QR Plus / deploy (push) Successful in 58s

Portfolio showcases freelancer work projects; Bookshop lists author titles with multi-platform purchase links. Includes create/edit forms, public assets, and tests.
This commit is contained in:
isaacclad
2026-07-16 20:56:52 +00:00
parent 95d73d1367
commit d83a314bee
13 changed files with 989 additions and 1 deletions
@@ -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);
+4
View File
@@ -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,
+1 -1
View File
@@ -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.
+96
View File
@@ -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<int, mixed> $items
* @param array<int, mixed> $uploads
* @return array<int, mixed>
*/
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() ?: '';
+139
View File
@@ -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<string, mixed> $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<string, mixed> $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<string, mixed> $input @return array<string, string> */
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<string, mixed> $input */
private function validateWifi(array $input): array
{
+14
View File
@@ -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',