Hidden ticket access codes (#1)

# Summary

Added the ability to specify hidden ticket access codes on an event. This would allow for special types of tickets to be created on an event and have an unlock code link to one or many hidden tickets.

## Value

The value this adds is to allow event organisers to play with marketing to aid in ticket sales etc.

## Changes

**Admin**
- Added migrations to allow for the access codes table on events and the pivot for many to many codes to tickets
- Added the ability to create an access code in the event customisation 
- Added the ability to view access codes on a hidden ticket
- Added the ability to attach one or multiple access code(s) onto the hidden ticket

**Public event page**
- Shows a box to enter your access code if the event has hidden tickets
- Added the validation messages for empty codes
- Added the check to fetch the hidden tickets if the code was entered correctly
This commit is contained in:
Etienne Marais 2019-01-16 20:33:32 +02:00 committed by GitHub
parent ef58b27212
commit 54861755b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 576 additions and 32 deletions

View File

@ -70,17 +70,22 @@ module.exports = function (grunt) {
}
},
},
phpunit: {
classes: {},
options: {}
},
watch: {
scripts: {
files: ['./public/assets/**/*.js'],
tasks: ['default'],
options: {
spawn: false,
},
},
}
});
// Plugin loading
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-less');
grunt.loadNpmTasks('grunt-contrib-uglify');
//grunt.loadNpmTasks('grunt-phpunit');
grunt.loadNpmTasks('grunt-contrib-watch');
// Task definition
grunt.registerTask('default', ['less', 'concat']);
grunt.registerTask('deploy', ['less', 'concat', 'uglify']);

View File

@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
use App\Models\Event;
use App\Models\EventAccessCodes;
use Illuminate\Http\Request;
class EventAccessCodesController extends MyBaseController
{
/**
* @param $event_id
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function showCreate($event_id)
{
return view('ManageEvent.Modals.CreateAccessCode', [
'event' => Event::scope()->find($event_id),
]);
}
/**
* Creates a ticket
*
* @param Request $request
* @param $event_id
* @return \Illuminate\Http\JsonResponse
*/
public function postCreate(Request $request, $event_id)
{
$eventAccessCode = new EventAccessCodes();
if (!$eventAccessCode->validate($request->all())) {
return response()->json([
'status' => 'error',
'messages' => $eventAccessCode->errors(),
]);
}
$eventAccessCode->event_id = $event_id;
$eventAccessCode->code = strtoupper(strip_tags($request->get('code')));
$eventAccessCode->save();
session()->flash('message', 'Successfully Created Access Code');
return response()->json([
'status' => 'success',
'id' => $eventAccessCode->id,
'message' => trans("Controllers.refreshing"),
'redirectUrl' => route('showEventCustomize', [
'event_id' => $event_id,
'#access_codes',
]),
]);
}
}

View File

@ -111,6 +111,15 @@ class EventTicketsController extends MyBaseController
$ticket->save();
// Attach the access codes to the ticket if it's hidden and the code ids have come from the front
if ($ticket->is_hidden) {
$ticketAccessCodes = $request->get('ticket_access_codes', []);
if (empty($ticketAccessCodes) === false) {
// Sync the access codes on the ticket
$ticket->event_access_codes()->attach($ticketAccessCodes);
}
}
session()->flash('message', 'Successfully Created Ticket');
return response()->json([
@ -223,6 +232,9 @@ class EventTicketsController extends MyBaseController
]);
}
// Check if the ticket visibility changed on update
$ticketPreviouslyHidden = (bool)$ticket->is_hidden;
$ticket->title = $request->get('title');
$ticket->quantity_available = !$request->get('quantity_available') ? null : $request->get('quantity_available');
$ticket->price = $request->get('price');
@ -235,6 +247,19 @@ class EventTicketsController extends MyBaseController
$ticket->save();
// Attach the access codes to the ticket if it's hidden and the code ids have come from the front
if ($ticket->is_hidden) {
$ticketAccessCodes = $request->get('ticket_access_codes', []);
if (empty($ticketAccessCodes) === false) {
// Sync the access codes on the ticket
$ticket->event_access_codes()->detach();
$ticket->event_access_codes()->attach($ticketAccessCodes);
}
} else if ($ticketPreviouslyHidden) {
// Delete access codes on ticket if the visibility changed to visible
$ticket->event_access_codes()->detach();
}
return response()->json([
'status' => 'success',
'id' => $ticket->id,

View File

@ -32,8 +32,8 @@ class EventViewController extends Controller
}
$data = [
'event' => $event,
'tickets' => $event->tickets()->where('is_hidden', 0)->orderBy('sort_order', 'asc')->get(),
'event' => $event,
'tickets' => $event->tickets()->orderBy('sort_order', 'asc')->get(),
'is_embedded' => 0,
];
/*
@ -136,4 +136,46 @@ class EventViewController extends Controller
'Content-Disposition' => 'attachment; filename="event.ics'
]);
}
/**
* @param Request $request
* @param $event_id
* @return \Illuminate\Http\JsonResponse
*/
public function postShowHiddenTickets(Request $request, $event_id)
{
$event = Event::findOrFail($event_id);
$accessCode = $request->get('access_code');
if (!$accessCode) {
return response()->json([
'status' => 'error',
'message' => 'A valid access code is required',
]);
}
$unlockedHiddenTickets = $event->tickets()
->where('is_hidden', true)
->orderBy('sort_order', 'asc')
->get()
->filter(function($ticket) use ($accessCode) {
// Only return the hidden tickets that match the access code
return ($ticket->event_access_codes()->where('code', $accessCode)->get()->count() > 0);
});
if ($unlockedHiddenTickets->count() === 0) {
return response()->json([
'status' => 'error',
'message' => 'No Tickets matched to your unlock code',
]);
}
$data = [
'event' => $event,
'tickets' => $unlockedHiddenTickets,
'is_embedded' => 0,
];
return view('Public.ViewEvent.Partials.EventHiddenTicketsSelection', $data);
}
}

View File

@ -145,6 +145,11 @@ Route::group(
'uses' => 'EventViewController@postContactOrganiser',
]);
Route::post('/{event_id}/show_hidden', [
'as' => 'postShowHiddenTickets',
'uses' => 'EventViewController@postShowHiddenTickets',
]);
/*
* Used for previewing designs in the backend. Doesn't log page views etc.
*/
@ -559,12 +564,10 @@ Route::group(
'as' => 'showEventCustomize',
'uses' => 'EventCustomizeController@showCustomize',
]);
Route::get('{event_id}/customize/{tab?}', [
'as' => 'showEventCustomizeTab',
'uses' => 'EventCustomizeController@showCustomize',
]);
Route::post('{event_id}/customize/order_page', [
'as' => 'postEditEventOrderPage',
'uses' => 'EventCustomizeController@postEditEventOrderPage',
@ -581,12 +584,23 @@ Route::group(
'as' => 'postEditEventSocial',
'uses' => 'EventCustomizeController@postEditEventSocial',
]);
Route::post('{event_id}/customize/fees', [
'as' => 'postEditEventFees',
'uses' => 'EventCustomizeController@postEditEventFees',
]);
/**
* Event access codes
*/
Route::get('{event_id}/access_codes/create', [
'as' => 'showCreateEventAccessCode',
'uses' => 'EventAccessCodesController@showCreate',
]);
Route::post('{event_id}/access_codes/create', [
'as' => 'postCreateEventAccessCode',
'uses' => 'EventAccessCodesController@postCreate',
]);
/*
* -------

View File

@ -137,6 +137,16 @@ class Event extends MyBaseModel
return $this->hasMany(\App\Models\Order::class);
}
/**
* The access codes associated with the event.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function access_codes()
{
return $this->hasMany(\App\Models\EventAccessCodes::class, 'event_id', 'id');
}
/**
* The account associated with the event.
*

View File

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\SoftDeletes;
class EventAccessCodes extends MyBaseModel
{
use SoftDeletes;
/**
* The validation rules.
*
* @return array $rules
*/
public function rules()
{
return [
'code' => 'required|string',
];
}
/**
* The Event associated with the event access code.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function event()
{
return $this->belongsTo(\App\Models\Event::class, 'event_id', 'id');
}
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
function tickets()
{
return $this->belongsToMany(
Ticket::class,
'ticket_event_access_code',
'event_access_code_id',
'ticket_id'
)->withTimestamps();
}
}

View File

@ -72,6 +72,19 @@ class Ticket extends MyBaseModel
return $this->belongsToMany(\App\Models\Question::class);
}
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
function event_access_codes()
{
return $this->belongsToMany(
EventAccessCodes::class,
'ticket_event_access_code',
'ticket_id',
'event_access_code_id'
)->withTimestamps();
}
/**
* TODO:implement the reserved method.
*/

View File

@ -20,7 +20,7 @@
"illuminate/support": "~5.6",
"intervention/image": "~2.4",
"laracasts/utilities": "~2.1",
"laravel/framework": "~5.6",
"laravel/framework": "~5.6",
"laravel/socialite": "~3.0",
"laravelcollective/html": "~5.6",
"league/flysystem-aws-s3-v3": "~1.0",
@ -37,14 +37,15 @@
"php-http/curl-client": "^1.7",
"php-http/message": "^1.6",
"predis/predis": "~1.1",
"vinelab/http": "~1.5"
"vinelab/http": "~1.5",
"laravel/tinker": "^1.0"
},
"require-dev": {
"phpunit/phpunit": "7.3.*",
"phpspec/phpspec": "5.0.*",
"fzaninotto/faker": "1.8.*",
"symfony/dom-crawler": "~3.0",
"symfony/css-selector": "~3.0"
"symfony/css-selector": "~3.0"
},
"autoload": {
"classmap": [

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateEventAccessCodesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('event_access_codes', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('event_id');
$table->string('code')->default('');
$table->timestamps();
$table->softDeletes();
// Add events table foreign key
$table->foreign('event_id')->references('id')->on('events')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('event_access_codes');
}
}

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateTicketEventAccessCodeTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('ticket_event_access_code', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('ticket_id');
$table->unsignedInteger('event_access_code_id');
$table->timestamps();
$table->foreign('ticket_id')->references('id')->on('tickets');
$table->foreign('event_access_code_id')->references('id')->on('event_access_codes');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::disableForeignKeyConstraints();
Schema::dropIfExists('ticket_event_access_code');
Schema::enableForeignKeyConstraints();
}
}

View File

@ -176,7 +176,32 @@ $(function() {
}).change();
// Apply access code here to unlock hidden tickets
$('#apply_access_code').click(function(e) {
var $clicked = $(this);
// Hide any previous errors
$clicked.closest('.form-group')
.removeClass('has-error');
var url = $clicked.closest('.has-access-codes').data('url');
var data = {
'access_code': $('#unlock_code').val(),
'_token': $('input:hidden[name=_token]').val()
};
$.post(url, data, function(response) {
if (response.status === 'error') {
// Show any access code errors
$clicked.closest('.form-group').addClass('has-error');
showMessage(response.message);
return;
}
$clicked.closest('.has-access-codes').before(response);
$('#unlock_code').val('');
$clicked.closest('.has-access-codes').remove();
});
});
});
function processFormErrors($form, errors)

View File

@ -4744,7 +4744,32 @@ function log() {
}).change();
// Apply access code here to unlock hidden tickets
$('#apply_access_code').click(function(e) {
var $clicked = $(this);
// Hide any previous errors
$clicked.closest('.form-group')
.removeClass('has-error');
var url = $clicked.closest('.has-access-codes').data('url');
var data = {
'access_code': $('#unlock_code').val(),
'_token': $('input:hidden[name=_token]').val()
};
$.post(url, data, function(response) {
if (response.status === 'error') {
// Show any access code errors
$clicked.closest('.form-group').addClass('has-error');
showMessage(response.message);
return;
}
$clicked.closest('.has-access-codes').before(response);
$('#unlock_code').val('');
$clicked.closest('.has-access-codes').remove();
});
});
});
function processFormErrors($form, errors)

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,10 @@
<?php
return [
'access_codes_heading' => 'Access Codes to unlock hidden tickets',
'access_codes_code' => 'Code',
'access_codes_created_at' => 'Created At',
'no_access_codes_yet' => 'No Access Codes yet!',
'create_access_code' => 'Create Access Code',
'access_code_title' => 'Code',
'access_code_title_placeholder' => 'ex: CONFERENCE2019',
];

View File

@ -36,6 +36,7 @@ return [
'expiry_year' => 'Expiry year',
'first_name' => 'First name',
'free' => 'FREE',
'has_unlock_codes' => 'Do you have an unlock code?',
'inc_fees' => 'Booking Fees',
'last_name' => 'Last name',
'offline_payment_instructions' => 'Offline payment instructions',

View File

@ -43,6 +43,7 @@ return array (
'ticket_background_color' => 'Ticket Background Color',
'ticket_border_color' => 'Ticket Border Color',
'ticket_design' => 'Ticket Design',
'access_codes' => 'Access Codes',
'ticket_preview' => 'Ticket Preview',
'ticket_sales_paused' => 'Sales Paused',
'ticket_sub_text_color' => 'Ticket Sub Text Color',

View File

@ -5,6 +5,7 @@
return array (
//==================================== Translations ====================================//
'apply' => 'Apply',
'action' => 'Action',
'affiliates' => 'Affiliates',
'attendees' => 'Attendees',
@ -42,21 +43,21 @@ return array (
'submit' => 'Submit',
'success' => 'Success',
'ticket_design' => 'Ticket Design',
'access_codes' => 'Access Codes',
'tickets' => 'Tickets',
'TOP' => 'TOP',
'TOP' => 'TOP',
'total' => 'total',
'whoops' => 'Whoops!',
'yes' => 'Yes',
'no' => 'No',
/*
* Lines below will turn obsolete in localization helper, it is declared in app/Helpers/macros.
* If you run it, it will break file input fields.
*/
'upload' => 'Upload',
'browse' => 'Browse',
/*
* Lines below will turn obsolete in localization helper, it is declared in app/Helpers/macros.
* If you run it, it will break file input fields.
*/
'upload' => 'Upload',
'browse' => 'Browse',
//================================== Obsolete strings ==================================//
'LLH:obsolete' =>
array (
'LLH:obsolete' => [
'months_long' => 'January|February|March|April|May|June|July|August|September|October|November|December',
),
],
);

View File

@ -201,6 +201,8 @@
class="{{$tab == 'fees' ? 'active' : ''}}"><a href="#fees" data-toggle="tab">@lang("basic.service_fees")</a></li>
<li data-route="{{route('showEventCustomizeTab', ['event_id' => $event->id, 'tab' => 'ticket_design'])}}"
class="{{$tab == 'ticket_design' ? 'active' : ''}}"><a href="#ticket_design" data-toggle="tab">@lang("basic.ticket_design")</a></li>
<li data-route="{{route('showEventCustomizeTab', ['event_id' => $event->id, 'tab' => 'access_codes'])}}"
class="{{$tab == 'access_codes' ? 'active' : ''}}"><a href="#access_codes" data-toggle="tab">@lang("basic.access_codes")</a></li>
</ul>
<!--/ tab -->
@ -587,6 +589,50 @@
</div>
<div class="tab-pane {{$tab == 'access_codes' ? 'active' : ''}}" id="access_codes">
<div class="row">
<div class="col-md-9">
<div class="btn-toolbar" role="toolbar">
<div class="btn-group btn-group-responsive">
<button data-modal-id='CreateAccessCode'
data-href="{{route('showCreateEventAccessCode', [ 'event_id' => $event->id ])}}"
class='loadModal btn btn-success' type="button"><i class="ico-ticket"></i> @lang("EventAccessCode.create_access_code")
</button>
</div>
</div>
</div>
</div>
<div class="row"><div class="col-md-12">&nbsp;</div></div>
<div class="row">
<div class="col-md-6">
@if($event->access_codes->count())
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>@lang("EventAccessCode.access_codes_code")</th>
<th>@lang("EventAccessCode.access_codes_created_at")</th>
</tr>
</thead>
<tbody>
@foreach($event->access_codes as $access_code)
<tr>
<td><strong>{{ $access_code->code }}</strong></td>
<td>{{ $access_code->created_at }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="alert alert-info">
@lang("EventAccessCode.no_access_codes_yet")
</div>
@endif
</div>
</div>
</div>
</div>
<!--/ tab content -->
</div>

View File

@ -0,0 +1,32 @@
<div role="dialog" class="modal fade" style="display: none;">
{!! Form::open(['url' => route('postCreateEventAccessCode', ['event_id' => $event->id]), 'class' => 'ajax']) !!}
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header text-center">
<button type="button" class="close" data-dismiss="modal">×</button>
<h3 class="modal-title">
<i class="ico-ticket"></i>
@lang("EventAccessCode.create_access_code")</h3>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-12">
<div class="form-group">
{!! Form::label('code', trans("EventAccessCode.access_code_title"), ['class'=>'control-label required']) !!}
{!! Form::text('code', Input::old('code'),
[
'class'=>'form-control',
'placeholder' => trans("EventAccessCode.access_code_title_placeholder")
]) !!}
</div>
</div>
</div>
</div> <!-- /end modal body-->
<div class="modal-footer">
{!! Form::button(trans("basic.cancel"), ['class'=>"btn modal-close btn-danger",'data-dismiss'=>'modal']) !!}
{!! Form::submit(trans("EventAccessCode.create_access_code"), ['class'=>"btn btn-success"]) !!}
</div>
</div><!-- /end modal content-->
{!! Form::close() !!}
</div>
</div>

View File

@ -100,9 +100,35 @@
{!! Form::checkbox('is_hidden', null, null, ['id' => 'is_hidden']) !!}
{!! Form::label('is_hidden', trans("ManageEvent.hide_this_ticket"), array('class'=>' control-label')) !!}
</div>
</div>
</div>
@if ($ticket->is_hidden)
<div class="col-md-12">
<h4>Select access codes</h4>
@if($ticket->event->access_codes->count())
<?php
$isSelected = false;
$selectedAccessCodes = $ticket->event_access_codes()->get()->map(function($accessCode) {
return $accessCode->pivot->event_access_code_id;
})->toArray();
?>
@foreach($ticket->event->access_codes as $access_code)
<div class="row">
<div class="col-md-12">
<div class="custom-checkbox mb5">
{!! Form::checkbox('ticket_access_codes[]', $access_code->id, in_array($access_code->id, $selectedAccessCodes), ['id' => 'ticket_access_code_' . $access_code->id, 'data-toggle' => 'toggle']) !!}
{!! Form::label('ticket_access_code_' . $access_code->id, $access_code->code) !!}
</div>
</div>
</div>
@endforeach
@else
<div class="alert alert-info">
@lang("EventAccessCode.no_access_codes_yet")
</div>
@endif
</div>
@endif
</div>
<a href="javascript:void(0);" class="show-more-options">
@lang("ManageEvent.more_options")

View File

@ -0,0 +1,71 @@
<?php
$is_free_event = true;
?>
@foreach($tickets as $ticket)
<tr class="ticket" property="offers" typeof="Offer">
<td>
<span class="ticket-title semibold" property="name">
{{$ticket->title}}
</span>
<p class="ticket-descripton mb0 text-muted" property="description">
{{$ticket->description}}
</p>
</td>
<td style="width:200px; text-align: right;">
<div class="ticket-pricing" style="margin-right: 20px;">
@if($ticket->is_free)
@lang("Public_ViewEvent.free")
<meta property="price" content="0">
@else
<?php
$is_free_event = false;
?>
<span title='{{money($ticket->price, $event->currency)}} @lang("Public_ViewEvent.ticket_price") + {{money($ticket->total_booking_fee, $event->currency)}} @lang("Public_ViewEvent.booking_fees")'>{{money($ticket->total_price, $event->currency)}} </span>
<span class="tax-amount text-muted text-smaller">{{ ($event->organiser->tax_name && $event->organiser->tax_value) ? '(+'.money(($ticket->total_price*($event->organiser->tax_value)/100), $event->currency).' '.$event->organiser->tax_name.')' : '' }}</span>
<meta property="priceCurrency"
content="{{ $event->currency->code }}">
<meta property="price"
content="{{ number_format($ticket->price, 2, '.', '') }}">
@endif
</div>
</td>
<td style="width:85px;">
@if($ticket->is_paused)
<span class="text-danger">
@lang("Public_ViewEvent.currently_not_on_sale")
</span>
@else
@if($ticket->sale_status === config('attendize.ticket_status_sold_out'))
<span class="text-danger" property="availability"
content="http://schema.org/SoldOut">
@lang("Public_ViewEvent.sold_out")
</span>
@elseif($ticket->sale_status === config('attendize.ticket_status_before_sale_date'))
<span class="text-danger">
@lang("Public_ViewEvent.sales_have_not_started")
</span>
@elseif($ticket->sale_status === config('attendize.ticket_status_after_sale_date'))
<span class="text-danger">
@lang("Public_ViewEvent.sales_have_ended")
</span>
@else
{!! Form::hidden('tickets[]', $ticket->id) !!}
<meta property="availability" content="http://schema.org/InStock">
<select name="ticket_{{$ticket->id}}" class="form-control"
style="text-align: center">
@if ($tickets->count() > 1)
<option value="0">0</option>
@endif
@for($i=$ticket->min_per_person; $i<=$ticket->max_per_person; $i++)
<option value="{{$i}}">{{$i}}</option>
@endfor
</select>
@endif
@endif
</td>
</tr>
@endforeach

View File

@ -22,7 +22,7 @@
<?php
$is_free_event = true;
?>
@foreach($tickets as $ticket)
@foreach($tickets->where('is_hidden', false) as $ticket)
<tr class="ticket" property="offers" typeof="Offer">
<td>
<span class="ticket-title semibold" property="name">
@ -90,12 +90,32 @@
</td>
</tr>
@endforeach
<tr>
<td colspan="3" style="text-align: center">
@lang("Public_ViewEvent.below_tickets")
</td>
</tr>
@if ($tickets->where('is_hidden', true)->count() > 0)
<tr class="has-access-codes" data-url="{{route('postShowHiddenTickets', ['event_id' => $event->id])}}">
<td colspan="3" style="text-align: left">
@lang("Public_ViewEvent.has_unlock_codes")
<div class="form-group" style="display:inline-block;margin-bottom:0;margin-left:15px;">
{!! Form::text('unlock_code', null, [
'class' => 'form-control',
'id' => 'unlock_code',
'style' => 'display:inline-block;width:65%;text-transform:uppercase;',
'placeholder' => 'ex: UNLOCKCODE01',
]) !!}
{!! Form::button(trans("basic.apply"), [
'class' => "btn btn-success",
'id' => 'apply_access_code',
'style' => 'display:inline-block;margin-top:-2px;',
'data-dismiss' => 'modal',
]) !!}
</div>
</td>
</tr>
@endif
<tr>
<td colspan="3" style="text-align: center">
@lang("Public_ViewEvent.below_tickets")
</td>
</tr>
<tr class="checkout">
<td colspan="3">
@if(!$is_free_event)