Add Portfolio and Bookshop QR types with tailored landing pages.
Deploy Ladill QR Plus / deploy (push) Successful in 58s
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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() ?: '';
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user