Add custom company logo upload and simplify kiosk branding.
Deploy Ladill Frontdesk / deploy (push) Successful in 23s
Deploy Ladill Frontdesk / deploy (push) Successful in 23s
Show Ladill Frontdesk or uploaded logo top-left on kiosk; allow logo upload in onboarding and organization settings. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Frontdesk;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Services\Frontdesk\OrganizationResolver;
|
||||
use App\Support\OrganizationBranding;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
@@ -43,9 +44,16 @@ class OnboardingController extends Controller
|
||||
'visitor_policy' => ['nullable', 'string', 'max:5000'],
|
||||
'badge_expiry_hours' => ['nullable', 'integer', 'min:1', 'max:24'],
|
||||
'kiosk_reset_seconds' => ['nullable', 'integer', 'min:30', 'max:600'],
|
||||
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
|
||||
]);
|
||||
|
||||
$this->organizations->completeOnboarding($request->user(), $validated);
|
||||
$organization = $this->organizations->completeOnboarding($request->user(), $validated);
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$organization->update([
|
||||
'logo_path' => OrganizationBranding::storeLogo($organization, $request->file('logo')),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('frontdesk.dashboard')->with('success', 'Welcome to Ladill Frontdesk!');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use App\Services\Frontdesk\FrontdeskPermissions;
|
||||
use App\Support\OrganizationBranding;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
@@ -51,6 +52,8 @@ class SettingsController extends Controller
|
||||
'notification_channels.*' => ['string', 'in:'.implode(',', array_keys(config('frontdesk.notification_channels')))],
|
||||
'notification_events' => ['nullable', 'array'],
|
||||
'report_daily_recipients' => ['nullable', 'string', 'max:2000'],
|
||||
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
|
||||
'remove_logo' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$settings = $organization->settings ?? [];
|
||||
@@ -81,9 +84,21 @@ class SettingsController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$logoPath = $organization->logo_path;
|
||||
|
||||
if ($request->boolean('remove_logo')) {
|
||||
OrganizationBranding::deleteStoredLogo($organization);
|
||||
$logoPath = null;
|
||||
}
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$logoPath = OrganizationBranding::storeLogo($organization, $request->file('logo'));
|
||||
}
|
||||
|
||||
$organization->update([
|
||||
'name' => $validated['name'],
|
||||
'timezone' => $validated['timezone'],
|
||||
'logo_path' => $logoPath,
|
||||
'settings' => $settings,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class OrganizationBranding
|
||||
{
|
||||
public const DEFAULT_LOGO = 'images/logo/ladillfrontdesk-logo.svg';
|
||||
|
||||
public static function logoUrl(Organization $organization): string
|
||||
{
|
||||
if ($organization->logo_path && Storage::disk('public')->exists($organization->logo_path)) {
|
||||
$version = $organization->updated_at?->getTimestamp() ?? time();
|
||||
|
||||
return Storage::disk('public')->url($organization->logo_path).'?v='.$version;
|
||||
}
|
||||
|
||||
$path = public_path(self::DEFAULT_LOGO);
|
||||
|
||||
return asset(self::DEFAULT_LOGO).'?v='.(@filemtime($path) ?: '1');
|
||||
}
|
||||
|
||||
public static function logoAlt(Organization $organization): string
|
||||
{
|
||||
return $organization->logo_path ? $organization->name : 'Ladill Frontdesk';
|
||||
}
|
||||
|
||||
public static function hasCustomLogo(Organization $organization): bool
|
||||
{
|
||||
return $organization->logo_path !== null
|
||||
&& Storage::disk('public')->exists($organization->logo_path);
|
||||
}
|
||||
|
||||
public static function storeLogo(Organization $organization, UploadedFile $file): string
|
||||
{
|
||||
self::deleteStoredLogo($organization);
|
||||
|
||||
return $file->store('frontdesk/organizations/'.$organization->id, 'public');
|
||||
}
|
||||
|
||||
public static function deleteStoredLogo(Organization $organization): void
|
||||
{
|
||||
if ($organization->logo_path) {
|
||||
Storage::disk('public')->delete($organization->logo_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
@php
|
||||
$fdIcon = 'images/launcher-icons/frontdesk.svg';
|
||||
$fdLogo = 'images/logo/ladillfrontdesk-logo.svg';
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -25,19 +21,13 @@
|
||||
<div class="absolute -right-16 bottom-0 h-80 w-80 rounded-full bg-violet-200/40 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col">
|
||||
<div class="fixed inset-x-0 top-0 z-10 h-1.5 bg-gradient-to-r from-indigo-600 to-violet-600"></div>
|
||||
@include('frontdesk.partials.kiosk-brand')
|
||||
|
||||
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col pt-20">
|
||||
<template x-if="step === 'welcome'">
|
||||
<div class="flex min-h-screen flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div class="fixed inset-x-0 top-0 h-1.5 bg-gradient-to-r from-indigo-600 to-violet-600"></div>
|
||||
|
||||
<img src="{{ asset($fdIcon) }}?v={{ @filemtime(public_path($fdIcon)) ?: '1' }}"
|
||||
alt=""
|
||||
class="h-24 w-24 drop-shadow-sm">
|
||||
<img src="{{ asset($fdLogo) }}?v={{ @filemtime(public_path($fdLogo)) ?: '1' }}"
|
||||
alt="Ladill Frontdesk"
|
||||
class="mt-5 h-7 w-auto">
|
||||
|
||||
<div class="mt-14 max-w-xl">
|
||||
<div class="flex min-h-[calc(100vh-5rem)] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div class="max-w-xl">
|
||||
<p class="text-sm font-semibold uppercase tracking-wider text-indigo-600">Visitor check-in</p>
|
||||
<h1 class="mt-3 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
|
||||
Welcome to {{ $organization->name }}
|
||||
@@ -53,14 +43,7 @@
|
||||
</template>
|
||||
|
||||
<template x-if="step !== 'welcome'">
|
||||
<div class="flex min-h-screen flex-col px-6 py-6">
|
||||
<header class="mb-6 text-center">
|
||||
<img src="{{ asset($fdIcon) }}?v={{ @filemtime(public_path($fdIcon)) ?: '1' }}"
|
||||
alt=""
|
||||
class="mx-auto h-10 w-10">
|
||||
<p class="mt-3 text-sm font-medium text-slate-500">{{ $organization->name }}</p>
|
||||
</header>
|
||||
|
||||
<div class="flex min-h-[calc(100vh-5rem)] flex-col px-6 py-6">
|
||||
<main class="flex-1">
|
||||
<template x-if="step === 'type'">
|
||||
<div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Welcome to Ladill Frontdesk</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">Set up your organization to start managing visitors.</p>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.onboarding.store') }}" class="mt-8 space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<form method="POST" action="{{ route('frontdesk.onboarding.store') }}" enctype="multipart/form-data" class="mt-8 space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
@@ -24,6 +24,13 @@
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Company logo (optional)</label>
|
||||
<p class="mt-1 text-xs text-slate-500">Replaces the Ladill Frontdesk logo on your visitor kiosk.</p>
|
||||
<input type="file" name="logo" accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
class="mt-2 block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-indigo-700">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Timezone</label>
|
||||
<select name="timezone" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@php
|
||||
use App\Support\OrganizationBranding;
|
||||
@endphp
|
||||
<div class="fixed left-0 top-0 z-20 p-6">
|
||||
<img src="{{ OrganizationBranding::logoUrl($organization) }}"
|
||||
alt="{{ OrganizationBranding::logoAlt($organization) }}"
|
||||
class="h-8 w-auto max-w-[220px] object-contain object-left">
|
||||
</div>
|
||||
@@ -6,7 +6,7 @@
|
||||
<h1 class="text-xl font-semibold text-slate-900">Settings</h1>
|
||||
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.settings.update') }}" class="mt-6 max-w-2xl space-y-6">
|
||||
<form method="POST" action="{{ route('frontdesk.settings.update') }}" enctype="multipart/form-data" class="mt-6 max-w-2xl space-y-6">
|
||||
@csrf @method('PUT')
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 space-y-4">
|
||||
@@ -24,6 +24,21 @@
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Company logo</label>
|
||||
<p class="mt-1 text-xs text-slate-500">Shown on the visitor kiosk instead of the Ladill Frontdesk logo. PNG, JPG, WebP, or SVG up to 2 MB.</p>
|
||||
@if (\App\Support\OrganizationBranding::hasCustomLogo($organization))
|
||||
<img src="{{ \App\Support\OrganizationBranding::logoUrl($organization) }}"
|
||||
alt="{{ $organization->name }}"
|
||||
class="mt-3 h-12 w-auto max-w-xs rounded-lg border border-slate-200 bg-white object-contain p-2">
|
||||
<label class="mt-3 flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" name="remove_logo" value="1" @checked(old('remove_logo'))>
|
||||
Remove custom logo
|
||||
</label>
|
||||
@endif
|
||||
<input type="file" name="logo" accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
class="mt-3 block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-indigo-700">
|
||||
</div>
|
||||
@else
|
||||
<dl class="text-sm space-y-2">
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Name</dt><dd>{{ $organization->name }}</dd></div>
|
||||
|
||||
@@ -11,7 +11,9 @@ use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Models\Visit;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FrontdeskPhase7Test extends TestCase
|
||||
@@ -93,6 +95,19 @@ class FrontdeskPhase7Test extends TestCase
|
||||
$this->assertTrue($device->fresh()->isOnline());
|
||||
}
|
||||
|
||||
public function test_kiosk_device_page_shows_custom_organization_logo(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$path = UploadedFile::fake()->image('brand.png', 320, 80)->store('frontdesk/organizations/'.$this->organization->id, 'public');
|
||||
$this->organization->update(['logo_path' => $path]);
|
||||
|
||||
$device = $this->createKioskDevice();
|
||||
|
||||
$this->get(route('frontdesk.kiosk.device', $device->device_token))
|
||||
->assertOk()
|
||||
->assertSee(Storage::disk('public')->url($path), false);
|
||||
}
|
||||
|
||||
public function test_kiosk_device_can_check_in_visitor(): void
|
||||
{
|
||||
$device = $this->createKioskDevice();
|
||||
|
||||
@@ -11,6 +11,8 @@ use App\Models\User;
|
||||
use App\Models\Visit;
|
||||
use App\Models\Visitor;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FrontdeskWebTest extends TestCase
|
||||
@@ -160,4 +162,46 @@ class FrontdeskWebTest extends TestCase
|
||||
$this->assertEquals('Updated Org', $this->organization->name);
|
||||
$this->assertEquals(6, $this->organization->settings['badge_expiry_hours']);
|
||||
}
|
||||
|
||||
public function test_org_admin_can_upload_organization_logo(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->put(route('frontdesk.settings.update'), [
|
||||
'name' => 'Updated Org',
|
||||
'timezone' => 'Africa/Accra',
|
||||
'badge_expiry_hours' => 6,
|
||||
'kiosk_reset_seconds' => 90,
|
||||
'notification_channels' => ['email'],
|
||||
'logo' => UploadedFile::fake()->image('company-logo.png', 400, 120),
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->organization->refresh();
|
||||
$this->assertNotNull($this->organization->logo_path);
|
||||
Storage::disk('public')->assertExists($this->organization->logo_path);
|
||||
}
|
||||
|
||||
public function test_org_admin_can_remove_organization_logo(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$path = UploadedFile::fake()->image('company-logo.png')->store('frontdesk/organizations/'.$this->organization->id, 'public');
|
||||
$this->organization->update(['logo_path' => $path]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->put(route('frontdesk.settings.update'), [
|
||||
'name' => 'Updated Org',
|
||||
'timezone' => 'Africa/Accra',
|
||||
'badge_expiry_hours' => 6,
|
||||
'kiosk_reset_seconds' => 90,
|
||||
'notification_channels' => ['email'],
|
||||
'remove_logo' => '1',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->organization->refresh();
|
||||
$this->assertNull($this->organization->logo_path);
|
||||
Storage::disk('public')->assertMissing($path);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user