If you want your user to reserve an item for X minutes before confirming the purchase, this tutorial will show you how to do it, with a project of timeslot booking and reservation timer, built with TALL stack - Livewire and Alpine.
Why Reserve For 15 Minutes?
It's a typical behavior in ticket booking systems, or anywhere where the supply of items is strictly limited. You wouldn't want to sell 100 plane tickets when a plane seats only 30 people, would you?
We have two typical scenarios here:
Scenario one - User A buys the product:
- User A adds a product to their cart
- User B sees that the product is no longer available
- User A buys the product within 15 minutes
- User B was never shown the product
Scenario two - User A abandons the page:
- User A adds a product to their cart
- User B sees that the product is no longer available
- User A waits 15 minutes and doesn't buy the product
- User B can now buy the product
- User B buys the product
We will implement exactly that: a one-page checkout solution with Livewire and Alpine, including 15:00 countdown timer and automatic Laravel command freeing up the item after those 15 minutes are over without the purchase.
In our case, it will be an appointment system with timeslots: the timeslots will be reserved for 15 minutes and then become available again for other users, in case the current user doesn't confirm the reservation.
This tutorial will be a step-by-step one, explaining the Livewire component for picking the dates along the way.
As usual, the link to the GitHub repository will be available at the end of the tutorial.
Database Structure - Basic Setup
Our implementation of the reservation system will be based on the following database structure:
Key fields from the database are:
-
confirmed
- A boolean field indicating the reservation has confirmation. This field is set totrue
when the user completes the checkout process. -
reserved_at
- A datetime field that indicates when the reservation was created. This field is set to the current datetime when the user adds a product to their cart.
Any other field can be added based on the application requirements. We didn't want to focus on them in this tutorial.
Project Setup
For this tutorial, we will be using Livewire 3.0 with Laravel Breeze. You can install it by following the guide if you haven't already.
- https://laravel.com/docs/starter-kits#laravel-breeze-installation
- https://livewire.laravel.com/docs/quickstart#install-livewire
Creating an Appointment Reservation System
Let's start by creating a Livewire component that will be used to make a reservation, showing available timeslots for certain dates. We'll call it DateTimeAvailability
:
php artisan make:livewire DateTimeAvailability
Next, for our tutorial, we will load the component in the dashboard.blade.php
file:
resources/views/dashboard.blade.php
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ __('Date time availability') }} </h2> </x-slot> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="font-sans text-gray-900 antialiased"> <div class="w-full sm:max-w-5xl mt-6 mb-6 px-6 py-8 bg-white shadow-md overflow-hidden sm:rounded-lg"> <livewire:date-time-availability></livewire:date-time-availability> </div> </div> </div> </div></x-app-layout>
Before we dive into our Livewire component - we need to prepare our Layout to accept stackable Scripts:
resources/views/layouts/app.blade.php
<!DOCTYPE html><html lang="{{ str_replace('_', '-', app()->getLocale()) }}"><head> {{-- ... --}} <link href="https://cdn.jsdelivr.net/npm/pikaday/css/pikaday.css" rel="stylesheet"></head><body class="font-sans antialiased"> {{-- ... --}} @stack('scripts')</body></html>
Now that this is done, we can customize our Livewire component.
Creating Time Picker
As you might have guessed - our component is quite empty. We need to fill in the first significant part of it - the time picker:
To create this, we will have to modify our DateTimeAvailability
component:
app/Livewire/DateTimeAvailability.php
use Carbon\Carbon;use Illuminate\Database\Eloquent\Collection;use Livewire\Component;use App\Models\Appointment; class DateTimeAvailability extends Component{ // We will store the current date in this variable public string $date; // We will store available times in this variable public array $availableTimes = []; // We will store all appointments for the selected date in this variable public Collection $appointments; // We will store the selected time in this variable public string $startTime = ''; public function mount(): void { $this->date = now()->format('Y-m-d'); // Get the available times for the current date $this->getIntervalsAndAvailableTimes(); } public function updatedDate(): void { // On date change - regenerate the intervals $this->getIntervalsAndAvailableTimes(); } public function render() { return view('livewire.date-time-availability'); } protected function getIntervalsAndAvailableTimes(): void { // Reset any available times to prevent errors $this->reset('availableTimes'); // Generates date intervals every 30 minutes $carbonIntervals = Carbon::parse($this->date . ' 8 am')->toPeriod($this->date . ' 8 pm', 30, 'minute'); // Get all appointments for the selected date $this->appointments = Appointment::whereDate('start_time', $this->date)->get(); // Loop through the intervals and check if the appointment exists. If it doesn't - add it to the available times foreach ($carbonIntervals as $interval) { $this->availableTimes[$interval->format('h:i A')] = !$this->appointments->contains('start_time', $interval); } }}
Next is the actual View, which will include the time picker and the date picker:
resources/views/livewire/date-time-availability.blade.php
@php use Carbon\Carbon; @endphp<div class="space-y-4"> @if(request()->has('confirmed')) <div class="p-4 bg-green-300"> Appointment Confirmed </div> @endif <form class="space-y-4"> <div class="w-full bg-gray-600 text-center"> <input type="text" id="date" wire:model="date" class="bg-gray-200 text-sm sm:text-base pl-2 pr-4 rounded-lg border border-gray-400 py-1 my-1 focus:outline-none focus:border-blue-400" autocomplete="off" /> </div> <div class="grid gap-4 grid-cols-6"> @foreach($availableTimes as $key => $time) <div class="w-full group"> <input type="radio" id="interval-{{ $key }}" name="time" value="{{ $date . ' ' . $key }}" @disabled(!$time) wire:model="startTime" class="hidden peer"> <label @class(['inline-block w-full text-center border py-1 peer-checked:bg-green-400 peer-checked:border-green-700', 'bg-blue-400 hover:bg-blue-500' => $time, 'bg-gray-100 cursor-not-allowed' => ! $time]) wire:key="interval-{{ $key }}" for="interval-{{ $key }}"> {{ $key }} </label> </div> @endforeach </div> <button class="mt-4 bg-blue-200 hover:bg-blue-600 px-4 py-1 rounded"> Reserve </button> </form></div> @push('scripts') <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/pikaday/pikaday.js"></script> <script> // Allows you to select a day from the calendar new Pikaday({ field: document.getElementById('date'), onSelect: function () { @this.set('date', this.getMoment().format('YYYY-MM-DD')); } }) </script>@endpush
Here's what we did in the view:
- We created a form with a date picker and a time picker
- We looped through the available times and created a radio button for each time
- We turned off the radio button if the time is not available
- We added a
wire:model
directive to the radio button to bind it to thestartTime
variable - We added a
wire:model
directive to the date picker to bind it to thedate
variable - There's a Pikaday picker for dates
The idea is to render a list with a few interactive elements. Next, we will add the reservation functionality.
Creating a Reservation
To create a reservation, we can use Livewire methods:
app/Livewire/DateTimeAvailability.php
use App\Models\Appointment;use Carbon\Carbon;use Illuminate\Database\Eloquent\Collection;use Livewire\Component; class DateTimeAvailability extends Component{ // ... public ?int $appointmentID = null; // ... public function render() { $appointment = $this->appointmentID ? Appointment::find($this->appointmentID) : null; return view('livewire.date-time-availability', [ 'appointment' => $appointment ]); } public function save() { $this->validate([ 'startTime' => 'required', ]); $this->appointmentID = Appointment::create([ 'start_time' => Carbon::parse($this->startTime), 'reserved_at' => now() ])->id; } // ...}
This method must be triggered when the user clicks the "Reserve" button. We can do this by listening to a Form submit event:
resources/views/livewire/date-time-availability.blade.php
<form wire:submit="save" class="space-y-4"> {{-- ... --}}
When the user clicks the "Reserve" button, the save
method will be triggered. This method will validate the startTime
field and create a new Appointment
record.
Starting the Reservation Timer
Now that we have a reservation created, we need to display a timer to the user. This timer will indicate how much time is left before the reservation expires. To do this, we'll need a few things:
First, we need a way to configure what is our timeout in minutes (this is going to set the countdown and later help us with automated reservation release):
config/app.php
// ...'appointmentReservationTime' => env('APPOINTMENT_RESERVATION_TIME', 15),
Note: You can create a separate config file for this, but for the sake of simplicity, we'll use the app.php
file.
Now let's focus on displaying a timer to the user. We'll need to modify our DateTimeAvailability
component:
resources/views/livewire/date-time-availability.blade.php
@php use Carbon\Carbon; @endphp<div class="space-y-4"> @if(request()->has('confirmed')) <div class="p-4 bg-green-300"> Appointment Confirmed </div> @endif @if(!$appointment) <form wire:submit="save" class="space-y-4"> <div class="w-full bg-gray-600 text-center"> <input type="text" id="date" wire:model="date" class="bg-gray-200 text-sm sm:text-base pl-2 pr-4 rounded-lg border border-gray-400 py-1 my-1 focus:outline-none focus:border-blue-400" autocomplete="off" /> </div> <div class="grid gap-4 grid-cols-6"> @foreach($availableTimes as $key => $time) <div class="w-full group"> <input type="radio" id="interval-{{ $key }}" name="time" value="{{ $date . ' ' . $key }}" @disabled(!$time) wire:model="startTime" class="hidden peer"> <label @class(['inline-block w-full text-center border py-1 peer-checked:bg-green-400 peer-checked:border-green-700', 'bg-blue-400 hover:bg-blue-500' => $time, 'bg-gray-100 cursor-not-allowed' => ! $time]) wire:key="interval-{{ $key }}" for="interval-{{ $key }}"> {{ $key }} </label> </div> @endforeach </div> <button class="mt-4 bg-blue-200 hover:bg-blue-600 px-4 py-1 rounded"> Reserve </button> </form> @else <div class="@if(!$appointment) hidden @endif" x-data="timer('{{ Carbon::parse($appointment->reserved_at)->addMinutes(config('app.appointmentReservationTime'))->unix() }}')" > <h2 class="text-xl">Confirmation for Appointment at: {{ $appointment?->start_time }}</h2> <div class="mt-4 mb-4"> <p class="text-center">Please confirm your appointment within the next:</p> <div class="flex items-center justify-center space-x-4 mt-4" x-init="init();"> <div class="flex flex-col items-center px-4"> <span x-text="time().days" class="text-4xl lg:text-5xl">00</span> <span class="text-gray-400 mt-2">Days</span> </div> <span class="w-[1px] h-24 bg-gray-400"></span> <div class="flex flex-col items-center px-4"> <span x-text="time().hours" class="text-4xl lg:text-5xl">23</span> <span class="text-gray-400 mt-2">Hours</span> </div> <span class="w-[1px] h-24 bg-gray-400"></span> <div class="flex flex-col items-center px-4"> <span x-text="time().minutes" class="text-4xl lg:text-5xl">59</span> <span class="text-gray-400 mt-2">Minutes</span> </div> <span class="w-[1px] h-24 bg-gray-400"></span> <div class="flex flex-col items-center px-4"> <span x-text="time().seconds" class="text-4xl lg:text-5xl">28</span> <span class="text-gray-400 mt-2">Seconds</span> </div> </div> </div> <div class="mt-4"> <button class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"> Confirm </button> <button class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"> Cancel </button> </div> </div> @endif</div> @push('scripts') <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/pikaday/pikaday.js"></script> <script> let runningInterval = null; // Allows you to select a day from the calendar new Pikaday({ field: document.getElementById('date'), onSelect: function () { @this.set('date', this.getMoment().format('YYYY-MM-DD')); } }) function timer(expiry) { return { expiry: expiry, remaining: null, init() { this.setRemaining() setInterval(() => { this.setRemaining(); }, 1000); }, setRemaining() { const diff = this.expiry - moment().unix(); this.remaining = diff; }, days() { return { value: this.remaining / 86400, remaining: this.remaining % 86400 }; }, hours() { return { value: this.days().remaining / 3600, remaining: this.days().remaining % 3600 }; }, minutes() { return { value: this.hours().remaining / 60, remaining: this.hours().remaining % 60 }; }, seconds() { return { value: this.minutes().remaining, }; }, format(value) { return ("0" + parseInt(value)).slice(-2) }, time() { return { days: this.format(this.days().value), hours: this.format(this.hours().value), minutes: this.format(this.minutes().value), seconds: this.format(this.seconds().value), } } } } </script>@endpush
Here's what you should see when you click the "Reserve" button:
Let's break down what we did:
- We added a
@if(!$appointment)
condition to the form. This will hide the form when the user has already created a reservation. - We added the countdown timer display to the view.
- We have used the
x-data
directive to create a new Alpine component. This component will be used to display the countdown timer. - Once the component is initialized, we will start a timer updating the countdown timer every second.
The timer can display days, hours, minutes, and seconds. This can be customized to your needs by removing divs
that show that information. For example:
We achieved this by removing the following divs
:
resources/views/livewire/date-time-availability.blade.php
<div class="flex flex-col items-center px-4"> <span x-text="time().days" class="text-4xl lg:text-5xl">00</span> <span class="text-gray-400 mt-2">Days</span></div><span class="w-[1px] h-24 bg-gray-400"></span><div class="flex flex-col items-center px-4"> <span x-text="time().hours" class="text-4xl lg:text-5xl">23</span> <span class="text-gray-400 mt-2">Hours</span></div>
Reservation Actions - Confirming and Cancelling
Next, we need to handle two remaining actions the user can do - confirm or cancel the reservation. Let's start:
resources/views/livewire/date-time-availability.blade.php
{{-- ... --}}<div class="mt-4"> <button wire:click="confirmAppointment" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"> Confirm </button> <button wire:click="cancelAppointment" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"> Cancel </button></div>{{-- ... --}}
Next is the DateTimeAvailability
component:
app/Livewire/DateTimeAvailability.php
// ... public function confirmAppointment(): void{ // First, we need to check if the appointment exists and if it's not expired $appointment = Appointment::find($this->appointmentID); if (!$appointment || Carbon::parse($appointment->reserved_at)->diffInMinutes(now()) > config('app.appointmentReservationTime')) { $this->redirectRoute('dashboard'); return; } $appointment->confirmed = true; $appointment->save(); $this->redirectRoute('dashboard', ['confirmed' => true]);} public function cancelAppointment(): void{ Appointment::find($this->appointmentID)?->delete(); $this->reset('appointmentID');} // ...
If the user clicks the "Confirm" button, the confirmAppointment
method will be triggered. This method will set the confirmed
field to true
and redirect the user to the appointment-confirmed
route. If the user clicks the "Cancel" button, the cancelAppointment
method will be triggered. This method will delete the appointment and reset the appointment
variable.
Automatically Releasing the Reservation - Scheduled Task
The last thing to do here is to automatically release the reservation if the user doesn't confirm it within the specified time. To do this, we'll need to create a scheduled task:
php artisan make:command AppointmentClearExpiredCommand
Let's add the following code to our new command:
app/Console/Commands/AppointmentClearExpiredCommand.php
// ...protected $signature = 'appointment:clear-expired'; public function handle(): void{ Appointment::where('reserved_at', '<=', now()->subMinutes(config('app.appointmentReservationTime'))) ->where('confirmed', false) ->delete();}// ...
As you can see, it's pretty simple - delete records older than X minutes and not confirmed. Next, we need to schedule this command:
app/Console/Kernel.php
// ...protected function schedule(Schedule $schedule): void{ // ... $schedule->command('appointment:clear-expired')->everySecond();}// ...
Now, the appointment:clear-expired
command will be executed every second. This will delete all expired reservations. And while it might seem excessive to run this command every second, it's not. It is a fast and performant command. You can also run it every minute, but that might leave some reservations in the database for longer than you'd like.
That's it!
The full code of this tutorial can be found here on GitHub.
Pikaday styles missing in this tutorial!
Oh! You are right, please add:
<link href="https://cdn.jsdelivr.net/npm/pikaday/css/pikaday.css" rel="stylesheet">
To your app.blade.php. I'm updating the tutorial. Thank you!
"livewire/livewire": "^3.0" v3.0.0 Version 3 was just released. 8/24/2023 at 5:00 PM America/Chicago Time
All is installing just fine.
Hello again almost everything is working just fine; hoverer for some reason when you try to confirm the appointment it is not getting sent to the database; the start time is but the confirmed is not.
Not sour what is missing hear!
The public function confirmAppointment(): void section is in the DateTimeAvailability.php file but for some reason it in not getting sent to the database. It is getting redirected back and the table has it taken it is just not in the database.
If it is redirecting you to the page with the time slot selection - that means that this code was triggered:
If that's the case, I would check fi the appointment was not deleted from the database and timer still had time remaining. Can you try to debug what's going on with this part?
This is taken from the database as you can see the confirmed has 0 and not 1 or am I reading this wrong ?
the page has the time slot taken It appears that nothing is being sent to the database the reserved_at and the confirmed are not being changed.
fixed the problem I had to move the
$appointment->confirmed = true; $appointment->save();
to above the
return;
However the reserved_at time is not changed it is still the same as the created_at time; However the updated_at time is changed.
you have removed safety checks by doing so :)
Ok so how do we git this to work?
Dump both if conditions to see which gets teiggered. that way you will know what's causing your issue
also, have you set the expiration timer correctly? It impacts what happens when you click confirm
I downloaded the GitHub repository and set it up however the reserved_at is still the same as the created_at; that is to say that the time on both of them are the same I know that the day would be but I would think that the time would be at least a secant or two different or be matching the updated_at wouldn’t it?
We are checking the time difference from the reservation time, so it's the same as creation time.
in a way, we look at how much time passed since the reservation, and not how much time is still left there
I wonder if it would be really more expensive to load the Appointment as: public Appointment $appointment within the mount() method and then use it on any function then having two different Appointment::find for the two functions?
The problem with this - then our
Appointment
model becomes publicly visible. For example, it can be seen with all of it's parameters sent to the user.This could be a security issue. We would also need to limit which fields we load there, to not leak information.
So having it in two functions - seems more logical. Especially since they only run if you click on them.