Timeslot Checkout Page with Reservation Timer: Livewire and Alpine

Timeslot Checkout Page with Reservation Timer: Livewire and Alpine
Admin
Wednesday, August 23, 2023 8 mins to read
Share
Timeslot Checkout Page with Reservation Timer: Livewire and Alpine

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 to true 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.


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 the startTime variable
  • We added a wire:model directive to the date picker to bind it to the date 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.