Align programme flow with Events: system-generated QR, no customizer.
Deploy Ladill Events / deploy (push) Successful in 31s
Deploy Ladill Events / deploy (push) Successful in 31s
Replace the QR-first create and edit experience with simple programme forms that auto-generate Ladill Link codes like events do. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,12 +4,20 @@ namespace App\Http\Controllers\Events;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\QrCode;
|
use App\Models\QrCode;
|
||||||
|
use App\Models\QrWallet;
|
||||||
use App\Support\Qr\QrTypeCatalog;
|
use App\Support\Qr\QrTypeCatalog;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class ProgrammeController extends Controller
|
class ProgrammeController extends Controller
|
||||||
{
|
{
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('events.programme-create', [
|
||||||
|
'pricePerQr' => QrWallet::pricePerQr(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function index(Request $request): View
|
public function index(Request $request): View
|
||||||
{
|
{
|
||||||
$account = ladill_account();
|
$account = ladill_account();
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ class QrCodeController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($requestedType === QrCode::TYPE_ITINERARY) {
|
||||||
|
return view('events.programme-create', [
|
||||||
|
'pricePerQr' => QrWallet::pricePerQr(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return view('qr-codes.create', [
|
return view('qr-codes.create', [
|
||||||
'wallet' => $wallet,
|
'wallet' => $wallet,
|
||||||
'requestedType' => $requestedType,
|
'requestedType' => $requestedType,
|
||||||
@@ -230,6 +236,14 @@ class QrCodeController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($qrCode->type === QrCode::TYPE_ITINERARY) {
|
||||||
|
return view('events.programme-show', [
|
||||||
|
'qrCode' => $qrCode,
|
||||||
|
'previewDataUri' => $previewDataUri,
|
||||||
|
'linkedEventSpeakers' => app(\App\Services\Events\EventProgrammeService::class)->speakerOptionsForProgramme($qrCode),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return view('qr-codes.show', [
|
return view('qr-codes.show', [
|
||||||
'qrCode' => $qrCode,
|
'qrCode' => $qrCode,
|
||||||
'previewDataUri' => $previewDataUri,
|
'previewDataUri' => $previewDataUri,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class QrCodeManagerService
|
|||||||
$type = (string) ($data['type'] ?? '');
|
$type = (string) ($data['type'] ?? '');
|
||||||
$validated = $this->payloadValidator->validateForCreate($type, $data);
|
$validated = $this->payloadValidator->validateForCreate($type, $data);
|
||||||
$styleInput = $data['style'] ?? null;
|
$styleInput = $data['style'] ?? null;
|
||||||
if ($type === QrCode::TYPE_EVENT && empty(array_filter((array) $styleInput))) {
|
if (in_array($type, [QrCode::TYPE_EVENT, QrCode::TYPE_ITINERARY], true) && empty(array_filter((array) $styleInput))) {
|
||||||
$styleInput = $user->getOrCreateQrSetting()->resolvedDefaultStyle();
|
$styleInput = $user->getOrCreateQrSetting()->resolvedDefaultStyle();
|
||||||
}
|
}
|
||||||
$style = QrStyleDefaults::merge($styleInput);
|
$style = QrStyleDefaults::merge($styleInput);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<x-user-layout>
|
||||||
|
<x-slot name="title">New programme</x-slot>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-2xl space-y-6" x-data="{ type: 'itinerary' }">
|
||||||
|
<div>
|
||||||
|
<a href="{{ route('programmes.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||||
|
Programmes
|
||||||
|
</a>
|
||||||
|
<h1 class="mt-2 text-xl font-semibold text-slate-900">New programme</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Build a schedule outline you can attach to events and share with attendees. A Ladill Link QR code is generated automatically.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
|
||||||
|
@endif
|
||||||
|
@if($errors->any())
|
||||||
|
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
<ul class="list-inside list-disc space-y-0.5">
|
||||||
|
@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<form method="post" action="{{ route('events.store') }}" enctype="multipart/form-data" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="type" value="itinerary">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Dashboard label</label>
|
||||||
|
<input type="text" name="label" value="{{ old('label') }}" required maxlength="120"
|
||||||
|
placeholder="e.g. Annual conference programme"
|
||||||
|
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||||
|
<p class="mt-1 text-xs text-slate-400">Private name to find this programme in your dashboard.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="{
|
||||||
|
slug: @js(old('custom_short_code', '')),
|
||||||
|
status: '',
|
||||||
|
timer: null,
|
||||||
|
checkUrl: @js(route('events.check-slug')),
|
||||||
|
check() {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
const s = this.slug.trim();
|
||||||
|
if (s === '') { this.status = ''; return; }
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/.test(s)) { this.status = 'invalid'; return; }
|
||||||
|
this.status = 'checking';
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
fetch(this.checkUrl + '?code=' + encodeURIComponent(s))
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { this.status = d.available ? 'available' : 'taken'; })
|
||||||
|
.catch(() => { this.status = ''; });
|
||||||
|
}, 400);
|
||||||
|
},
|
||||||
|
}">
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Custom link <span class="font-normal text-slate-400">(optional)</span></label>
|
||||||
|
<div class="mt-1 flex items-stretch rounded-xl border focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"
|
||||||
|
:class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'">
|
||||||
|
<span class="flex items-center rounded-l-xl border-r border-slate-200 bg-slate-50 px-3 text-xs text-slate-400 whitespace-nowrap select-none">{{ \App\Support\LadillLink::baseUrl() }}/</span>
|
||||||
|
<input type="text" name="custom_short_code" x-model="slug" @input="check()" maxlength="20" autocomplete="off"
|
||||||
|
class="min-w-0 flex-1 rounded-r-xl bg-white px-3 py-2.5 text-sm focus:outline-none" placeholder="summit-programme">
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-400">Leave blank to auto-generate your Ladill Link.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@include('qr-codes.partials.type-fields-create')
|
||||||
|
|
||||||
|
<p class="rounded-xl bg-slate-50 px-3.5 py-2.5 text-xs leading-5 text-slate-500">
|
||||||
|
GHS {{ number_format($pricePerQr, 2) }} will be charged from your wallet when you create this programme.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary w-full">Create programme</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</x-user-layout>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<x-user-layout>
|
||||||
|
<x-slot name="title">{{ $qrCode->label }}</x-slot>
|
||||||
|
@php
|
||||||
|
$content = $qrCode->content();
|
||||||
|
$publicUrl = \App\Support\LadillLink::url($qrCode->short_code);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-5xl space-y-6" x-data="{ type: @js($qrCode->type) }">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<a href="{{ route('programmes.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||||
|
Programmes
|
||||||
|
</a>
|
||||||
|
<h1 class="mt-2 text-xl font-semibold text-slate-900">{{ $content['title'] ?? $qrCode->label }}</h1>
|
||||||
|
<p class="mt-1 inline-flex flex-wrap items-center gap-2 text-sm text-slate-500">
|
||||||
|
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-800">Programme</span>
|
||||||
|
@if($qrCode->is_active)
|
||||||
|
<span class="text-emerald-600">● Live</span>
|
||||||
|
@else
|
||||||
|
<span class="text-slate-400">● Inactive</span>
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ $publicUrl }}" target="_blank" rel="noopener"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50">
|
||||||
|
View programme page
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@foreach(['success', 'error'] as $flash)
|
||||||
|
@if(session($flash))
|
||||||
|
<div class="rounded-xl border px-4 py-3 text-sm {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
|
||||||
|
{{ session($flash) }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@if($errors->any())
|
||||||
|
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
<ul class="list-inside list-disc space-y-0.5">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[320px_1fr]">
|
||||||
|
<div class="space-y-4 lg:sticky lg:top-6 lg:self-start">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 text-center">
|
||||||
|
@if($previewDataUri)
|
||||||
|
<img src="{{ $previewDataUri }}" alt="QR code" class="mx-auto h-44 w-44">
|
||||||
|
@endif
|
||||||
|
<p class="mt-3 break-all text-xs text-slate-400">{{ $publicUrl }}</p>
|
||||||
|
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
||||||
|
@foreach(['png' => 'PNG', 'svg' => 'SVG', 'pdf' => 'PDF'] as $fmt => $flabel)
|
||||||
|
<a href="{{ route('events.download', [$qrCode, $fmt]) }}"
|
||||||
|
class="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 hover:bg-slate-50">{{ $flabel }}</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-[11px] leading-5 text-slate-400">System-generated QR code. Customize colors, logos, and frames in QR Plus if needed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ route('events.update', $qrCode) }}" enctype="multipart/form-data" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||||
|
@csrf @method('PATCH')
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Dashboard label</label>
|
||||||
|
<input type="text" name="label" value="{{ old('label', $qrCode->label) }}" required maxlength="120"
|
||||||
|
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-slate-900">Programme page live</p>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">Turn off to hide this programme without deleting it.</p>
|
||||||
|
</div>
|
||||||
|
<div class="relative ml-4 shrink-0">
|
||||||
|
<input type="hidden" name="is_active" value="0">
|
||||||
|
<input type="checkbox" name="is_active" value="1" class="sr-only peer" @checked(old('is_active', $qrCode->is_active))>
|
||||||
|
<div class="h-6 w-11 rounded-full bg-slate-200 transition-colors duration-200 peer-checked:bg-indigo-600"></div>
|
||||||
|
<div class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 peer-checked:translate-x-5"></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
@include('qr-codes.partials.type-fields-edit', ['linkedEventSpeakers' => $linkedEventSpeakers ?? []])
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary w-full">Save changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-user-layout>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
@php
|
@php
|
||||||
$mobileFullScreenPage = request()->routeIs('account.settings')
|
$mobileFullScreenPage = request()->routeIs('account.settings')
|
||||||
|| request()->routeIs('events.create')
|
|| request()->routeIs('events.create')
|
||||||
|
|| request()->routeIs('programmes.create')
|
||||||
|| request()->routeIs('events.show')
|
|| request()->routeIs('events.show')
|
||||||
|| request()->routeIs('events.search');
|
|| request()->routeIs('events.search');
|
||||||
|
|
||||||
$qrMobilePage = request()->routeIs('events.create') || request()->routeIs('events.show');
|
$qrMobilePage = request()->routeIs('events.create') || request()->routeIs('programmes.create') || request()->routeIs('events.show');
|
||||||
@endphp
|
@endphp
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['qr-mobile-page' => $qrMobilePage])>
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['qr-mobile-page' => $qrMobilePage])>
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Mobile Bottom Navigation (hidden on QR create/show which have their own action bar) --}}
|
{{-- Mobile Bottom Navigation (hidden on QR create/show which have their own action bar) --}}
|
||||||
@unless(request()->routeIs('events.create') || request()->routeIs('events.show'))
|
@unless(request()->routeIs('events.create') || request()->routeIs('programmes.create') || request()->routeIs('events.show'))
|
||||||
@php
|
@php
|
||||||
$navUser = auth()->user();
|
$navUser = auth()->user();
|
||||||
$navInitials = collect(explode(' ', trim((string) $navUser?->name)))
|
$navInitials = collect(explode(' ', trim((string) $navUser?->name)))
|
||||||
|
|||||||
+1
-1
@@ -91,7 +91,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
|||||||
Route::post('/events/{event}/attendees/share-comms', [AttendeeController::class, 'shareComms'])->name('events.attendees.share-comms');
|
Route::post('/events/{event}/attendees/share-comms', [AttendeeController::class, 'shareComms'])->name('events.attendees.share-comms');
|
||||||
|
|
||||||
Route::get('/programmes', [ProgrammeController::class, 'index'])->name('programmes.index');
|
Route::get('/programmes', [ProgrammeController::class, 'index'])->name('programmes.index');
|
||||||
Route::get('/programmes/create', fn () => redirect()->route('events.create', ['type' => 'itinerary']))->name('programmes.create');
|
Route::get('/programmes/create', [ProgrammeController::class, 'create'])->name('programmes.create');
|
||||||
|
|
||||||
Route::get('/speakers', [SpeakerController::class, 'hub'])->name('speakers.index');
|
Route::get('/speakers', [SpeakerController::class, 'hub'])->name('speakers.index');
|
||||||
Route::get('/events/{event}/speakers', [SpeakerController::class, 'index'])->name('speakers.show');
|
Route::get('/events/{event}/speakers', [SpeakerController::class, 'index'])->name('speakers.show');
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProgrammeCreateTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->withoutMiddleware(\App\Http\Middleware\EnsurePlatformSession::class);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
config('billing.api_url').'/balance*' => Http::response(['balance_minor' => 50000]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_programme_create_page_has_no_qr_customizer(): void
|
||||||
|
{
|
||||||
|
$user = User::create([
|
||||||
|
'public_id' => 'usr_prog_create',
|
||||||
|
'name' => 'Host',
|
||||||
|
'email' => 'host-prog@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('programmes.create'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('New programme')
|
||||||
|
->assertSee('Create programme')
|
||||||
|
->assertDontSee('qrCustomizer')
|
||||||
|
->assertDontSee('customization-fields')
|
||||||
|
->assertDontSee('Design your QR');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user