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);
|
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
|
private function escapeVcard(string $value): string
|
||||||
{
|
{
|
||||||
return str_replace(["\n", "\r", ',', ';'], [' ', ' ', '\\,', '\\;'], $value);
|
return str_replace(["\n", "\r", ',', ';'], [' ', ' ', '\\,', '\\;'], $value);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class QrCode extends Model
|
|||||||
public const TYPE_SHOP = 'shop';
|
public const TYPE_SHOP = 'shop';
|
||||||
public const TYPE_APP = 'app';
|
public const TYPE_APP = 'app';
|
||||||
public const TYPE_BOOK = 'book';
|
public const TYPE_BOOK = 'book';
|
||||||
|
public const TYPE_PORTFOLIO = 'portfolio';
|
||||||
|
public const TYPE_BOOKSHOP = 'bookshop';
|
||||||
public const TYPE_WIFI = 'wifi';
|
public const TYPE_WIFI = 'wifi';
|
||||||
public const TYPE_COUPON = 'coupon';
|
public const TYPE_COUPON = 'coupon';
|
||||||
public const TYPE_EVENT = 'event';
|
public const TYPE_EVENT = 'event';
|
||||||
@@ -200,6 +202,8 @@ class QrCode extends Model
|
|||||||
self::TYPE_SHOP,
|
self::TYPE_SHOP,
|
||||||
self::TYPE_APP,
|
self::TYPE_APP,
|
||||||
self::TYPE_BOOK,
|
self::TYPE_BOOK,
|
||||||
|
self::TYPE_PORTFOLIO,
|
||||||
|
self::TYPE_BOOKSHOP,
|
||||||
self::TYPE_WIFI,
|
self::TYPE_WIFI,
|
||||||
self::TYPE_EVENT,
|
self::TYPE_EVENT,
|
||||||
self::TYPE_ITINERARY,
|
self::TYPE_ITINERARY,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class AfiaService
|
|||||||
|
|
||||||
What Ladill QR Plus does and where things live:
|
What Ladill QR Plus does and where things live:
|
||||||
- Overview (Dashboard): recent codes, active code count, scans in the last 30 days, wallet balance.
|
- 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.
|
- 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}.
|
- 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.
|
- 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 ($type === QrCode::TYPE_CHURCH) {
|
||||||
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
||||||
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'church-logos');
|
$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 ($qrCode->type === QrCode::TYPE_CHURCH) {
|
||||||
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
||||||
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_logo'], 'church-logos');
|
$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_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_APP => ['app_name', 'ios_url', 'android_url', 'web_url', 'app_icon'],
|
||||||
QrCode::TYPE_BOOK => ['book_title', 'author', 'description', 'price_ghs'],
|
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'],
|
QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'],
|
||||||
default => [],
|
default => [],
|
||||||
};
|
};
|
||||||
@@ -480,6 +546,36 @@ class QrCodeManagerService
|
|||||||
return $path;
|
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
|
private function storeBookCover(User $user, UploadedFile $file): string
|
||||||
{
|
{
|
||||||
$mime = $file->getMimeType() ?: '';
|
$mime = $file->getMimeType() ?: '';
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ class QrPayloadValidator
|
|||||||
QrCode::TYPE_SHOP => $this->validateShop($input),
|
QrCode::TYPE_SHOP => $this->validateShop($input),
|
||||||
QrCode::TYPE_APP => $this->validateApp($input),
|
QrCode::TYPE_APP => $this->validateApp($input),
|
||||||
QrCode::TYPE_BOOK => $this->validateBook($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),
|
QrCode::TYPE_WIFI => $this->validateWifi($input),
|
||||||
default => throw new RuntimeException('Unsupported QR type.'),
|
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 */
|
/** @param array<string, mixed> $input */
|
||||||
private function validateWifi(array $input): array
|
private function validateWifi(array $input): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class QrTypeCatalog
|
|||||||
QrCode::TYPE_DOCUMENT,
|
QrCode::TYPE_DOCUMENT,
|
||||||
QrCode::TYPE_LINK_LIST,
|
QrCode::TYPE_LINK_LIST,
|
||||||
QrCode::TYPE_BUSINESS,
|
QrCode::TYPE_BUSINESS,
|
||||||
|
QrCode::TYPE_PORTFOLIO,
|
||||||
|
QrCode::TYPE_BOOKSHOP,
|
||||||
QrCode::TYPE_WIFI,
|
QrCode::TYPE_WIFI,
|
||||||
QrCode::TYPE_APP,
|
QrCode::TYPE_APP,
|
||||||
];
|
];
|
||||||
@@ -50,6 +52,18 @@ class QrTypeCatalog
|
|||||||
'category' => 'contact',
|
'category' => 'contact',
|
||||||
'icon' => 'business-profile.svg',
|
'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 => [
|
QrCode::TYPE_WIFI => [
|
||||||
'label' => 'WiFi',
|
'label' => 'WiFi',
|
||||||
'description' => 'Network name and password',
|
'description' => 'Network name and password',
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#0f766e" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><path d="M8 7h8M8 11h6"/></svg>
|
||||||
|
After Width: | Height: | Size: 304 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4f46e5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M3 12h18"/></svg>
|
||||||
|
After Width: | Height: | Size: 281 B |
@@ -1094,6 +1094,213 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- ===== 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
|
||||||
|
<div class="min-h-screen bg-slate-100 pb-12">
|
||||||
|
<div class="relative h-44 overflow-hidden">
|
||||||
|
@if($pCover)
|
||||||
|
<img src="{{ $pCover }}" alt="" class="absolute inset-0 h-full w-full object-cover">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-black/10 to-black/55"></div>
|
||||||
|
@else
|
||||||
|
<div class="absolute inset-0" style="background: linear-gradient(135deg, {{ $pColor }}, {{ $pColor }}aa);"></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="relative mx-auto -mt-14 max-w-md px-4">
|
||||||
|
<div class="overflow-hidden rounded-3xl bg-white shadow-xl">
|
||||||
|
<div class="px-6 pb-6 pt-4 text-center">
|
||||||
|
<div class="mx-auto -mt-12 mb-3 flex h-24 w-24 items-center justify-center overflow-hidden rounded-full border-4 border-white shadow-lg" style="background: {{ $pColor }};">
|
||||||
|
@if($pAvatar)
|
||||||
|
<img src="{{ $pAvatar }}" alt="{{ $pName }}" class="h-full w-full object-cover">
|
||||||
|
@else
|
||||||
|
<span class="text-3xl font-bold text-white">{{ $pInitials }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<h1 class="text-xl font-bold text-slate-900">{{ $pName }}</h1>
|
||||||
|
@if($pRole)<p class="mt-1 text-sm font-medium" style="color: {{ $pColor }};">{{ $pRole }}</p>@endif
|
||||||
|
@if(!empty($content['bio']))<p class="mt-3 text-sm leading-relaxed text-slate-600">{{ $content['bio'] }}</p>@endif
|
||||||
|
<div class="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||||
|
@if(!empty($content['email']))
|
||||||
|
<a href="mailto:{{ $content['email'] }}" class="rounded-full px-3 py-1.5 text-xs font-semibold text-white" style="background: {{ $pColor }};">Email</a>
|
||||||
|
@endif
|
||||||
|
@if(!empty($content['phone']))
|
||||||
|
<a href="tel:{{ $content['phone'] }}" class="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700">Call</a>
|
||||||
|
@endif
|
||||||
|
@if(!empty($content['website']))
|
||||||
|
<a href="{{ $content['website'] }}" target="_blank" rel="noopener" class="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700">Website</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if($pSocial !== [])
|
||||||
|
<div class="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||||
|
@foreach($pSocial as $platform => $url)
|
||||||
|
@if(isset($socialIcons[$platform]))
|
||||||
|
<a href="{{ $url }}" target="_blank" rel="noopener" class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100">
|
||||||
|
<img src="/images/qr-icons/{{ $socialIcons[$platform] }}" alt="{{ $platform }}" class="h-4 w-4">
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-slate-100 px-4 py-5">
|
||||||
|
<h2 class="mb-3 px-2 text-xs font-semibold uppercase tracking-wider text-slate-400">Selected work</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach(($content['projects'] ?? []) as $index => $project)
|
||||||
|
<div class="overflow-hidden rounded-2xl border border-slate-100 bg-slate-50">
|
||||||
|
@if(!empty($project['image_path']))
|
||||||
|
<img src="{{ $qrCode->publicPath('portfolio-project/'.$index) }}" alt="{{ $project['title'] ?? '' }}"
|
||||||
|
class="h-40 w-full object-cover">
|
||||||
|
@endif
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="text-sm font-bold text-slate-900">{{ $project['title'] ?? '' }}</h3>
|
||||||
|
@if(!empty($project['description']))
|
||||||
|
<p class="mt-1 text-sm leading-relaxed text-slate-600">{{ $project['description'] }}</p>
|
||||||
|
@endif
|
||||||
|
@if(!empty($project['tags']))
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
@foreach($project['tags'] as $tag)
|
||||||
|
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" style="background: {{ $pColor }}14; color: {{ $pColor }};">{{ $tag }}</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if(!empty($project['url']))
|
||||||
|
<a href="{{ $project['url'] }}" target="_blank" rel="noopener"
|
||||||
|
class="mt-3 inline-flex items-center gap-1 text-xs font-semibold" style="color: {{ $pColor }};">
|
||||||
|
View project
|
||||||
|
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/></svg>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ===== 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
|
||||||
|
<div class="min-h-screen bg-stone-100 pb-12">
|
||||||
|
<div class="relative h-48 overflow-hidden">
|
||||||
|
@if($aCover)
|
||||||
|
<img src="{{ $aCover }}" alt="" class="absolute inset-0 h-full w-full object-cover">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-black/20 to-black/60"></div>
|
||||||
|
@else
|
||||||
|
<div class="absolute inset-0" style="background: linear-gradient(145deg, {{ $aColor }}, {{ $aColor }}99);"></div>
|
||||||
|
@endif
|
||||||
|
<div class="absolute inset-x-0 bottom-0 px-5 pb-5 text-white">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-white/80">Author bookshop</p>
|
||||||
|
<h1 class="mt-1 text-2xl font-bold">{{ $aName }}</h1>
|
||||||
|
@if($aTagline)<p class="mt-1 text-sm text-white/90">{{ $aTagline }}</p>@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-md px-4">
|
||||||
|
<div class="relative -mt-8 rounded-3xl bg-white p-5 shadow-xl">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-2xl border-2 border-white shadow" style="background: {{ $aColor }};">
|
||||||
|
@if($aAvatar)
|
||||||
|
<img src="{{ $aAvatar }}" alt="{{ $aName }}" class="h-full w-full object-cover">
|
||||||
|
@else
|
||||||
|
<span class="text-2xl font-bold text-white">{{ $aInitials }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
@if(!empty($content['bio']))
|
||||||
|
<p class="text-sm leading-relaxed text-slate-600">{{ $content['bio'] }}</p>
|
||||||
|
@endif
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
@if(!empty($content['website']))
|
||||||
|
<a href="{{ $content['website'] }}" target="_blank" rel="noopener" class="rounded-full px-3 py-1 text-xs font-semibold text-white" style="background: {{ $aColor }};">Website</a>
|
||||||
|
@endif
|
||||||
|
@if(!empty($content['email']))
|
||||||
|
<a href="mailto:{{ $content['email'] }}" class="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-slate-700">Email</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if($aSocial !== [])
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2 border-t border-slate-100 pt-4">
|
||||||
|
@foreach($aSocial as $platform => $url)
|
||||||
|
@if(isset($socialIcons[$platform]))
|
||||||
|
<a href="{{ $url }}" target="_blank" rel="noopener" class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-100">
|
||||||
|
<img src="/images/qr-icons/{{ $socialIcons[$platform] }}" alt="{{ $platform }}" class="h-4 w-4">
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mb-3 mt-8 text-xs font-semibold uppercase tracking-wider text-slate-500">Books</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
@foreach(($content['books'] ?? []) as $index => $book)
|
||||||
|
<div class="overflow-hidden rounded-3xl border border-stone-200 bg-white shadow-sm">
|
||||||
|
<div class="flex gap-4 p-4">
|
||||||
|
<div class="h-36 w-24 shrink-0 overflow-hidden rounded-xl bg-stone-100 shadow-inner">
|
||||||
|
@if(!empty($book['cover_path']))
|
||||||
|
<img src="{{ $qrCode->publicPath('bookshop-book/'.$index) }}" alt="{{ $book['title'] ?? '' }}"
|
||||||
|
class="h-full w-full object-cover">
|
||||||
|
@else
|
||||||
|
<div class="flex h-full w-full items-center justify-center px-2 text-center text-[10px] font-semibold text-stone-400">
|
||||||
|
{{ $book['title'] ?? 'Book' }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="text-base font-bold text-slate-900">{{ $book['title'] ?? '' }}</h3>
|
||||||
|
@if(!empty($book['description']))
|
||||||
|
<p class="mt-1 line-clamp-3 text-sm leading-relaxed text-slate-600">{{ $book['description'] }}</p>
|
||||||
|
@endif
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
@foreach(($book['buy_links'] ?? []) as $buy)
|
||||||
|
<a href="{{ $buy['url'] }}" target="_blank" rel="noopener"
|
||||||
|
class="inline-flex items-center rounded-full px-3 py-1.5 text-xs font-semibold text-white shadow-sm"
|
||||||
|
style="background: {{ $aColor }};">
|
||||||
|
{{ $buy['label'] ?? 'Buy' }}
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- ===== COUPON ===== --}}
|
{{-- ===== COUPON ===== --}}
|
||||||
@elseif($type === \App\Models\QrCode::TYPE_COUPON)
|
@elseif($type === \App\Models\QrCode::TYPE_COUPON)
|
||||||
<div class="flex min-h-screen flex-col items-center justify-center px-4 py-12">
|
<div class="flex min-h-screen flex-col items-center justify-center px-4 py-12">
|
||||||
|
|||||||
@@ -678,6 +678,193 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Portfolio --}}
|
||||||
|
<div x-show="type === 'portfolio'" x-cloak
|
||||||
|
x-data="{
|
||||||
|
projects: [{ title: '', description: '', url: '', tags: '' }],
|
||||||
|
addProject() { this.projects.push({ title: '', description: '', url: '', tags: '' }) },
|
||||||
|
removeProject(i) { this.projects.splice(i, 1); if (this.projects.length === 0) this.addProject() },
|
||||||
|
avatarPreview: null,
|
||||||
|
coverPreview: null,
|
||||||
|
}"
|
||||||
|
class="space-y-4">
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<input type="text" name="name" value="{{ old('name') }}" placeholder="Your name *"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
<input type="text" name="role" value="{{ old('role') }}" placeholder="Title / specialty (e.g. Product designer)"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
<textarea name="bio" rows="3" placeholder="Short bio"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">{{ old('bio') }}</textarea>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<input type="email" name="email" value="{{ old('email') }}" placeholder="Email"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
<input type="text" name="phone" value="{{ old('phone') }}" placeholder="Phone"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
</div>
|
||||||
|
<input type="url" name="website" value="{{ old('website') }}" placeholder="Website / portfolio URL"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-medium text-slate-500">Photo</label>
|
||||||
|
<input type="file" name="portfolio_avatar" accept="image/*"
|
||||||
|
@change="avatarPreview = $event.target.files[0] ? URL.createObjectURL($event.target.files[0]) : null"
|
||||||
|
class="mt-1 block w-full text-xs text-slate-600 file:mr-2 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-2 file:py-1 file:text-xs file:font-semibold file:text-indigo-700">
|
||||||
|
<div x-show="avatarPreview" class="mt-2"><img :src="avatarPreview" class="h-14 w-14 rounded-full object-cover shadow" alt=""></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-medium text-slate-500">Cover</label>
|
||||||
|
<input type="file" name="portfolio_cover" accept="image/*"
|
||||||
|
@change="coverPreview = $event.target.files[0] ? URL.createObjectURL($event.target.files[0]) : null"
|
||||||
|
class="mt-1 block w-full text-xs text-slate-600 file:mr-2 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-2 file:py-1 file:text-xs file:font-semibold file:text-indigo-700">
|
||||||
|
<x-qr.cover-image-hint />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-medium text-slate-500">Brand color</label>
|
||||||
|
<input type="color" name="brand_color" value="{{ old('brand_color', '#4f46e5') }}"
|
||||||
|
class="mt-1 h-10 w-full cursor-pointer rounded-xl border border-slate-200 p-1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-semibold text-slate-600">Social links <span class="font-normal text-slate-400">(optional)</span></p>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
@foreach(['linkedin' => 'LinkedIn', 'twitter' => 'X / Twitter', 'instagram' => 'Instagram', 'facebook' => 'Facebook', 'youtube' => 'YouTube', 'whatsapp' => 'WhatsApp'] as $platform => $label)
|
||||||
|
<input type="text" name="social[{{ $platform }}]" value="{{ old('social.'.$platform) }}" placeholder="{{ $label }}"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-xs font-semibold text-slate-600">Projects *</p>
|
||||||
|
<button type="button" @click="addProject()" class="text-xs font-semibold text-indigo-600 hover:text-indigo-800">+ Add project</button>
|
||||||
|
</div>
|
||||||
|
<template x-for="(project, i) in projects" :key="i">
|
||||||
|
<div class="space-y-2 rounded-xl border border-slate-200 bg-slate-50/60 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400" x-text="'Project ' + (i + 1)"></p>
|
||||||
|
<button type="button" @click="removeProject(i)" class="text-xs text-red-600 hover:text-red-800">Remove</button>
|
||||||
|
</div>
|
||||||
|
<input type="text" :name="'projects['+i+'][title]'" x-model="project.title" placeholder="Project title *"
|
||||||
|
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
<textarea :name="'projects['+i+'][description]'" x-model="project.description" rows="2" placeholder="What you built / delivered"
|
||||||
|
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30"></textarea>
|
||||||
|
<input type="url" :name="'projects['+i+'][url]'" x-model="project.url" placeholder="Project or case-study URL"
|
||||||
|
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
<input type="text" :name="'projects['+i+'][tags]'" x-model="project.tags" placeholder="Tags (comma-separated)"
|
||||||
|
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-medium text-slate-500">Project image</label>
|
||||||
|
<input type="file" :name="'project_images['+i+']'" accept="image/*"
|
||||||
|
class="mt-1 block w-full text-xs text-slate-600 file:mr-2 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-2 file:py-1 file:text-xs file:font-semibold file:text-indigo-700">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Bookshop --}}
|
||||||
|
<div x-show="type === 'bookshop'" x-cloak
|
||||||
|
x-data="{
|
||||||
|
books: [{ title: '', description: '', buy_links: [{ label: 'Amazon', url: '' }] }],
|
||||||
|
addBook() { this.books.push({ title: '', description: '', buy_links: [{ label: 'Amazon', url: '' }] }) },
|
||||||
|
removeBook(i) { this.books.splice(i, 1); if (this.books.length === 0) this.addBook() },
|
||||||
|
addLink(b) { this.books[b].buy_links.push({ label: '', url: '' }) },
|
||||||
|
removeLink(b, l) { this.books[b].buy_links.splice(l, 1); if (this.books[b].buy_links.length === 0) this.addLink(b) },
|
||||||
|
avatarPreview: null,
|
||||||
|
coverPreview: null,
|
||||||
|
}"
|
||||||
|
class="space-y-4">
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<input type="text" name="author_name" value="{{ old('author_name') }}" placeholder="Author name *"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
<input type="text" name="tagline" value="{{ old('tagline') }}" placeholder="Tagline (e.g. Stories that stay with you)"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
<textarea name="bio" rows="3" placeholder="About the author"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">{{ old('bio') }}</textarea>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<input type="email" name="email" value="{{ old('email') }}" placeholder="Contact email"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
<input type="url" name="website" value="{{ old('website') }}" placeholder="Author website"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-medium text-slate-500">Author photo</label>
|
||||||
|
<input type="file" name="bookshop_avatar" accept="image/*"
|
||||||
|
@change="avatarPreview = $event.target.files[0] ? URL.createObjectURL($event.target.files[0]) : null"
|
||||||
|
class="mt-1 block w-full text-xs text-slate-600 file:mr-2 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-2 file:py-1 file:text-xs file:font-semibold file:text-indigo-700">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-medium text-slate-500">Cover banner</label>
|
||||||
|
<input type="file" name="bookshop_cover" accept="image/*"
|
||||||
|
@change="coverPreview = $event.target.files[0] ? URL.createObjectURL($event.target.files[0]) : null"
|
||||||
|
class="mt-1 block w-full text-xs text-slate-600 file:mr-2 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-2 file:py-1 file:text-xs file:font-semibold file:text-indigo-700">
|
||||||
|
<x-qr.cover-image-hint />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-medium text-slate-500">Brand color</label>
|
||||||
|
<input type="color" name="brand_color" value="{{ old('brand_color', '#0f766e') }}"
|
||||||
|
class="mt-1 h-10 w-full cursor-pointer rounded-xl border border-slate-200 p-1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-semibold text-slate-600">Social links <span class="font-normal text-slate-400">(optional)</span></p>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
@foreach(['instagram' => 'Instagram', 'twitter' => 'X / Twitter', 'facebook' => 'Facebook', 'youtube' => 'YouTube', 'linkedin' => 'LinkedIn'] as $platform => $label)
|
||||||
|
<input type="text" name="social[{{ $platform }}]" value="{{ old('social.'.$platform) }}" placeholder="{{ $label }}"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-xs font-semibold text-slate-600">Books *</p>
|
||||||
|
<button type="button" @click="addBook()" class="text-xs font-semibold text-indigo-600 hover:text-indigo-800">+ Add book</button>
|
||||||
|
</div>
|
||||||
|
<template x-for="(book, b) in books" :key="b">
|
||||||
|
<div class="space-y-2 rounded-xl border border-slate-200 bg-slate-50/60 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400" x-text="'Book ' + (b + 1)"></p>
|
||||||
|
<button type="button" @click="removeBook(b)" class="text-xs text-red-600 hover:text-red-800">Remove</button>
|
||||||
|
</div>
|
||||||
|
<input type="text" :name="'books['+b+'][title]'" x-model="book.title" placeholder="Book title *"
|
||||||
|
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
<textarea :name="'books['+b+'][description]'" x-model="book.description" rows="2" placeholder="Short description"
|
||||||
|
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30"></textarea>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-medium text-slate-500">Cover image</label>
|
||||||
|
<input type="file" :name="'book_covers['+b+']'" accept="image/*"
|
||||||
|
class="mt-1 block w-full text-xs text-slate-600 file:mr-2 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-2 file:py-1 file:text-xs file:font-semibold file:text-indigo-700">
|
||||||
|
<x-qr.cover-image-hint variant="book" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 rounded-lg border border-dashed border-slate-200 bg-white p-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-[11px] font-semibold text-slate-500">Where to buy *</p>
|
||||||
|
<button type="button" @click="addLink(b)" class="text-[11px] font-semibold text-indigo-600">+ Link</button>
|
||||||
|
</div>
|
||||||
|
<template x-for="(link, l) in book.buy_links" :key="l">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-[1fr_2fr_auto]">
|
||||||
|
<input type="text" :name="'books['+b+'][buy_links]['+l+'][label]'" x-model="link.label" placeholder="Amazon, Bookshop.org…"
|
||||||
|
class="rounded-lg border border-slate-200 px-2.5 py-1.5 text-sm">
|
||||||
|
<input type="url" :name="'books['+b+'][buy_links]['+l+'][url]'" x-model="link.url" placeholder="https://…"
|
||||||
|
class="rounded-lg border border-slate-200 px-2.5 py-1.5 text-sm">
|
||||||
|
<button type="button" @click="removeLink(b, l)" class="text-xs text-red-600">Remove</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- App --}}
|
{{-- App --}}
|
||||||
<div x-show="type === 'app'" x-cloak x-data="{ iconPreview: null }" class="grid gap-3">
|
<div x-show="type === 'app'" x-cloak x-data="{ iconPreview: null }" class="grid gap-3">
|
||||||
<input type="text" name="app_name" value="{{ old('app_name') }}" placeholder="App name *"
|
<input type="text" name="app_name" value="{{ old('app_name') }}" placeholder="App name *"
|
||||||
|
|||||||
@@ -731,6 +731,159 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@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
|
||||||
|
<div x-data="{
|
||||||
|
projects: @js($projects),
|
||||||
|
addProject() { this.projects.push({ title: '', description: '', url: '', tags: '', image_path: null }) },
|
||||||
|
removeProject(i) { this.projects.splice(i, 1); if (this.projects.length === 0) this.addProject() },
|
||||||
|
}" class="space-y-4">
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<input type="text" name="name" value="{{ $c['name'] ?? '' }}" placeholder="Your name *"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
<input type="text" name="role" value="{{ $c['role'] ?? '' }}" placeholder="Title / specialty"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
<textarea name="bio" rows="3" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">{{ $c['bio'] ?? '' }}</textarea>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<input type="email" name="email" value="{{ $c['email'] ?? '' }}" placeholder="Email" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||||
|
<input type="text" name="phone" value="{{ $c['phone'] ?? '' }}" placeholder="Phone" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||||
|
</div>
|
||||||
|
<input type="url" name="website" value="{{ $c['website'] ?? '' }}" placeholder="Website" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||||
|
<input type="color" name="brand_color" value="{{ $c['brand_color'] ?? '#4f46e5' }}" class="h-10 w-full rounded-xl border border-slate-200 p-1">
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-medium text-slate-500">Replace photo</label>
|
||||||
|
<input type="file" name="portfolio_avatar" accept="image/*" class="mt-1 block w-full text-xs">
|
||||||
|
@if(!empty($c['avatar_path']))
|
||||||
|
<img src="{{ route('qr.public.portfolio.avatar', $qrCode->short_code) }}" class="mt-2 h-14 w-14 rounded-full object-cover" alt="">
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-medium text-slate-500">Replace cover</label>
|
||||||
|
<input type="file" name="portfolio_cover" accept="image/*" class="mt-1 block w-full text-xs">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
@foreach(['linkedin','twitter','instagram','facebook','youtube','whatsapp'] as $platform)
|
||||||
|
<input type="text" name="social[{{ $platform }}]" value="{{ $c['social'][$platform] ?? '' }}" placeholder="{{ ucfirst($platform) }}"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-xs font-semibold text-slate-600">Projects</p>
|
||||||
|
<button type="button" @click="addProject()" class="text-xs font-semibold text-indigo-600">+ Add</button>
|
||||||
|
</div>
|
||||||
|
<template x-for="(project, i) in projects" :key="i">
|
||||||
|
<div class="space-y-2 rounded-xl border border-slate-200 p-3">
|
||||||
|
<input type="hidden" :name="'projects['+i+'][image_path]'" :value="project.image_path || ''">
|
||||||
|
<input type="text" :name="'projects['+i+'][title]'" x-model="project.title" placeholder="Title *" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<textarea :name="'projects['+i+'][description]'" x-model="project.description" rows="2" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm"></textarea>
|
||||||
|
<input type="url" :name="'projects['+i+'][url]'" x-model="project.url" placeholder="URL" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<input type="text" :name="'projects['+i+'][tags]'" x-model="project.tags" placeholder="Tags" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<input type="file" :name="'project_images['+i+']'" accept="image/*" class="block w-full text-xs">
|
||||||
|
<button type="button" @click="removeProject(i)" class="text-xs text-red-600">Remove project</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@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
|
||||||
|
<div x-data="{
|
||||||
|
books: @js($books),
|
||||||
|
addBook() { this.books.push({ title: '', description: '', cover_path: null, buy_links: [{ label: 'Amazon', url: '' }] }) },
|
||||||
|
removeBook(i) { this.books.splice(i, 1); if (this.books.length === 0) this.addBook() },
|
||||||
|
addLink(b) { this.books[b].buy_links.push({ label: '', url: '' }) },
|
||||||
|
removeLink(b, l) { this.books[b].buy_links.splice(l, 1); if (this.books[b].buy_links.length === 0) this.addLink(b) },
|
||||||
|
}" class="space-y-4">
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<input type="text" name="author_name" value="{{ $c['author_name'] ?? '' }}" placeholder="Author name *" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||||
|
<input type="text" name="tagline" value="{{ $c['tagline'] ?? '' }}" placeholder="Tagline" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||||
|
<textarea name="bio" rows="3" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">{{ $c['bio'] ?? '' }}</textarea>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<input type="email" name="email" value="{{ $c['email'] ?? '' }}" placeholder="Email" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||||
|
<input type="url" name="website" value="{{ $c['website'] ?? '' }}" placeholder="Website" class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||||
|
</div>
|
||||||
|
<input type="color" name="brand_color" value="{{ $c['brand_color'] ?? '#0f766e' }}" class="h-10 w-full rounded-xl border border-slate-200 p-1">
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-medium text-slate-500">Replace author photo</label>
|
||||||
|
<input type="file" name="bookshop_avatar" accept="image/*" class="mt-1 block w-full text-xs">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-medium text-slate-500">Replace cover banner</label>
|
||||||
|
<input type="file" name="bookshop_cover" accept="image/*" class="mt-1 block w-full text-xs">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
@foreach(['instagram','twitter','facebook','youtube','linkedin'] as $platform)
|
||||||
|
<input type="text" name="social[{{ $platform }}]" value="{{ $c['social'][$platform] ?? '' }}" placeholder="{{ ucfirst($platform) }}"
|
||||||
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-xs font-semibold text-slate-600">Books</p>
|
||||||
|
<button type="button" @click="addBook()" class="text-xs font-semibold text-indigo-600">+ Add book</button>
|
||||||
|
</div>
|
||||||
|
<template x-for="(book, b) in books" :key="b">
|
||||||
|
<div class="space-y-2 rounded-xl border border-slate-200 p-3">
|
||||||
|
<input type="hidden" :name="'books['+b+'][cover_path]'" :value="book.cover_path || ''">
|
||||||
|
<input type="text" :name="'books['+b+'][title]'" x-model="book.title" placeholder="Title *" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<textarea :name="'books['+b+'][description]'" x-model="book.description" rows="2" class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm"></textarea>
|
||||||
|
<input type="file" :name="'book_covers['+b+']'" accept="image/*" class="block w-full text-xs">
|
||||||
|
<div class="space-y-2 rounded-lg border border-dashed border-slate-200 p-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-[11px] font-semibold text-slate-500">Buy links</p>
|
||||||
|
<button type="button" @click="addLink(b)" class="text-[11px] font-semibold text-indigo-600">+ Link</button>
|
||||||
|
</div>
|
||||||
|
<template x-for="(link, l) in book.buy_links" :key="l">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-[1fr_2fr_auto]">
|
||||||
|
<input type="text" :name="'books['+b+'][buy_links]['+l+'][label]'" x-model="link.label" placeholder="Amazon" class="rounded-lg border border-slate-200 px-2.5 py-1.5 text-sm">
|
||||||
|
<input type="url" :name="'books['+b+'][buy_links]['+l+'][url]'" x-model="link.url" placeholder="https://…" class="rounded-lg border border-slate-200 px-2.5 py-1.5 text-sm">
|
||||||
|
<button type="button" @click="removeLink(b, l)" class="text-xs text-red-600">Remove</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="removeBook(b)" class="text-xs text-red-600">Remove book</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@elseif($qrCode->type === \App\Models\QrCode::TYPE_APP)
|
@elseif($qrCode->type === \App\Models\QrCode::TYPE_APP)
|
||||||
<div x-data="{ iconPreview: null }" class="grid gap-3">
|
<div x-data="{ iconPreview: null }" class="grid gap-3">
|
||||||
<input type="text" name="app_name" value="{{ $c['name'] ?? '' }}" placeholder="App name"
|
<input type="text" name="app_name" value="{{ $c['name'] ?? '' }}" placeholder="App name"
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ Route::get('/q/{shortCode}/file', [QrScanController::class, 'file'])->name('qr.p
|
|||||||
Route::get('/q/{shortCode}/business-logo', [QrScanController::class, 'businessLogo'])->name('qr.public.business.logo');
|
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}/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}/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 () {
|
Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Qr\QrPayloadValidator;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PortfolioBookshopQrTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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' => []],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user