Code fix for double-post
August 25, 2025 · AI Conversations
<script>
window.location='https://fossettscircus.com/success/?eid=" . $order_info->event_date_id . "&oc=" . $order_info->order_code . "';
</script>
' ";
goto continue_with_wordpress;
exit();
}
else
{
sendErrorMail( "Order not saved", "Order not saved but charge paid" . var_export( $order_info, true ) );
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest') {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Order could not be saved'
]);
exit;
}
}
}
elseif ($intent->status == 'processing') {
echo json_encode([
'success' => false,
'message' => 'Payment is processing. Please wait a moment and try again.',
'status' => 'processing'
]);
exit;
} elseif ($intent->status == 'requires_payment_method') {
echo json_encode([
'success' => false,
'message' => 'Payment method failed. Please try again with a different card.',
'status' => 'requires_payment_method'
]);
exit;
} elseif ($intent->status == 'requires_confirmation') {
echo json_encode([
'success' => false,
'message' => 'Payment requires confirmation. Please try again.',
'status' => 'requires_confirmation'
]);
exit;
} else {
echo json_encode([
'success' => false,
'message' => 'Payment failed: ' . $intent->status,
'status' => $intent->status,
'errors' => ['There was an error during the transaction, charge was not paid.']
]);
exit;
}
}
catch(StripeErrorCard $e)
{
$body = $e->getJsonBody();
$err = $body['error'];
array_push( $order_errors, $err['message'] );
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest') {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => $err['message'],
'errors' => $order_errors
]);
exit;
}
}
catch (Exception $e)
{
if ( is_object( $e ) && method_exists( $e, 'getJsonBody' ) )
{
$body = $e->getJsonBody();
$err = $body['error'];
array_push( $order_errors, $err['message'] );
}
array_push( $order_errors, 'There was an error during the transaction. Payment not performed.' );
sendErrorMail( "Exception", var_export( $e, true ) );
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest') {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Payment processing error',
'errors' => $order_errors
]);
exit;
}
}
} // /Process payment
} // /Form submitted
?>
<style>
<?php echo file_get_contents( __DIR__ . '/css/style.css'); ?>
/*.div-wrapper{
padding: 0em 1em !important;
}*/
.payment-fields {
display: block !important;
padding: 1em !important;
border-radius: 5px;
width: 100%;
height: 3em;
color: rgb(66, 109, 143);
background-color: rgb(255,255,255);
font-weight: bold;
}
</style>
<script>
<?php echo file_get_contents( __DIR__ . '/js/hashes.js'); ?>
</script>
<script>
</script>
<script>
var event_seat_location_options;
var eventData = <?php echo json_encode($event_data); ?>;
jQuery(document).ready(function() {
if (!eventData || !eventData.event_dates) {
console.error('Event data not available');
return;
}
var latest_available_seat = jQuery('#event-order [name="event_seat_location"] option:selected').val();
event_seat_location_options = jQuery('#event-order [name="event_seat_location"]').html();
jQuery('#event-order [name="event_seat_location"]').html('<option value="">Pick a seat location</option>');
updateAvailableLocations();
jQuery('#event-order [name="event_seat_location"]').val(latest_available_seat);
});
function updateFormSent()
{
var selectedDatetime = jQuery('#event-order [name="event_datetime"]').val();
jQuery('#event-order [name="event_seat_location"] option[data-date-id="' + selectedDatetime + '"]').removeAttr('disabled');
jQuery('#event-order [name="event_seat_location"] option[data-date-id="' + selectedDatetime + '"]').show();
updateTotal();
}
function updateAvailableLocations() {
if (!eventData || !eventData.event_dates) {
console.error('Event data not available');
return;
}
// reset options
jQuery('#event-order [name="event_seat_location"]').html(event_seat_location_options);
var selectedDatetime = jQuery('#event-order [name="event_datetime"]').val();
jQuery('#event-order [name="event_seat_location"] option').prop('disabled', true).hide();
jQuery('#event-order [name="event_seat_location"] option:first').prop('disabled', false).show();
jQuery('#event-order [name="event_seat_location"] option[data-date-id="' + selectedDatetime + '"]').prop('disabled', false).show();
// remove unwanted options
jQuery('#event-order [name="event_seat_location"]').find('option:disabled').remove();
jQuery('#event-order [name="event_seat_location"]').val('');
}
function updateAvailableSeats()
{
var seatsLeft = parseInt( jQuery('#event-order [name="event_seat_location"] option:selected').attr('data-left') );
var seatsSelected = parseInt( jQuery('#event-order [name="event_seat_full"]').val() ) + parseInt( jQuery("#event-order [name='event_seat_child']").val() ) + parseInt( jQuery("#event-order [name='event_seat_senior']").val() );
if ( seatsSelected > seatsLeft )
{
jQuery('#event-order .warning-seats').html('You selected more tickets than are available right now in ' + jQuery("#event-order [name='event_seat_location'] option:selected").text() + ', please pick a different location or reduce the number of tickets by ' + ( seatsSelected - seatsLeft ) + '.' );
jQuery('#event-order .warning-seats').show();
return false;
}
else
{
jQuery('#event-order .warning-seats').hide();
jQuery('#event-order .warning-seats').html('');
return true;
}
}
var V = JSON.parse( '<?php echo $event_data->event_vouchers; ?>' );
var D = 0;
var T = '';
function voucherValid()
{
var SHA1 = new Hashes.SHA1;
var customer_voucher = SHA1.hex( jQuery("#event-order [name='voucher']").val().toLowerCase() );
for (var i = 0; i < V.length; i++)
{
if ( customer_voucher == V[i].voucher_code )
{
D = V[i].voucher_discount;
T = V[i].voucher_discount_type;
return true;
}
}
return false;
}
function updateTotal()
{
var seatsFull = parseInt( jQuery('#event-order [name="event_seat_full"]').val() );
var seatsChild = parseInt( jQuery('#event-order [name="event_seat_child"]').val() );
var seatsSenior = parseInt( jQuery('#event-order [name="event_seat_senior"]').val() );
var priceFull = parseFloat( jQuery('#event-order [name="event_seat_location"]').find('option:selected').attr( 'data-price-full' ) );
var priceReduced = parseFloat( jQuery('#event-order [name="event_seat_location"]').find('option:selected').attr( 'data-price-reduced' ) );
var total = ( seatsFull * priceFull ) + ( ( seatsChild + seatsSenior ) * priceReduced );
if ( voucherValid() )
{
if ( T == 'percentage' )
{
total = total * ( 1 - ( D / 100 ) );
}
else if ( T == 'amount' )
{
total = total - D;
}
}
var currency = '<?php echo $currency_symbol; ?>';
if ( isNaN( total ) )
{
jQuery('#event-order .total').html('');
}
else
{
jQuery('#event-order .total').html('Total: '+ currency + total );
}
}
jQuery(document).ready(function()
{
/*var stripe = Stripe('pk_test_VBObL72a6bVf4DAEpzOKvlh1', { // thenet
stripeAccount: 'acct_1C7mK9IQtQgABICR' // fossetts
});*/
var stripe = Stripe('pk_live_KSh8MRi76Kliuk4fUpBZ23GS', { // thenet live mode
stripeAccount: 'acct_1C7mK9IQtQgABICR' // fossetts
});
<?php if ( isset( $_POST['payment_intent_client_secret'] ) ): ?>
stripe.handleCardAction(
'<?php echo $_POST['payment_intent_client_secret']; ?>'
).then(function(result)
{
if (result.error)
{
jQuery('#event-order .warning-payment').html( result.error.message );
jQuery('#event-order .warning-payment').show();
}
else
{
jQuery('[name="payment_intent_id"]').val( result.paymentIntent.id );
jQuery('#event-order .warning-payment').hide();
jQuery('#event-order .warning-payment').html( '' );
jQuery("#event-order #event-order-form").trigger('submit');
}
});
<?php else: ?>
var elements = stripe.elements();
var paymentStyle = {
base: {
color: 'rgb(66, 109, 143)',
'::placeholder': {
color: 'rgba(66, 109, 143, 0.7)',
},
}
};
// var card = elements.create('card', { hidePostalCode: true, style: paymentStyle });
var card = elements.create('cardNumber', { showIcon: true, style: paymentStyle });
var cardExpiry = elements.create('cardExpiry', { style: paymentStyle });
var cardCvc = elements.create('cardCvc', { style: paymentStyle });
card.addEventListener('change', function( event )
{
if ( event.error )
{
jQuery('#event-order .warning-payment').html( event.error.message );
jQuery('#event-order .warning-payment').show();
}
else
{
jQuery('#event-order .warning-payment').hide();
jQuery('#event-order .warning-payment').html( '' );
}
});
// card.mount('#event-order #payment-fields');
card.mount('#event-order #card-number');
cardExpiry.mount('#event-order #card-expiry');
cardCvc.mount('#event-order #card-cvc');
<?php endif; ?>
jQuery("#event-order [name='event_datetime']").on("change", function()
{
updateAvailableLocations();
updateTotal();
});
jQuery("#event-order [name='event_seat_location'], #event-order [name='event_seat_full'], #event-order [name='event_seat_child'], #event-order [name='event_seat_senior']").on("change", function()
{
updateAvailableSeats();
updateTotal();
});
jQuery("#event-order [name='voucher']").on('input',function()
{
if ( voucherValid() )
{
jQuery('.warning-voucher').hide();
jQuery('.success-voucher').show();
}
else
{
if ( jQuery(this).val() == '' )
{
jQuery('.success-voucher').hide();
jQuery('.warning-voucher').hide();
}
else
{
jQuery('.success-voucher').hide();
jQuery('.warning-voucher').show();
}
}
updateTotal();
});
jQuery("#event-order .order-submit").on('click', function()
{
jQuery("#event-order .order-submit").prop('disabled', true);
if ( updateAvailableSeats() )
{
// Stripe SCA
stripe.createPaymentMethod('card', card, {
billing_details: { name: jQuery('[name="customer_name"]').val() }
})
//stripe.createToken( card )
.then( function(result)
{
if ( result.error )
{
jQuery('#event-order .warning-payment').html( result.error.message );
jQuery('#event-order .warning-payment').show();
jQuery("#event-order .order-submit").prop('disabled', false);
}
else
{
jQuery('#event-order [name="payment_token"]').val( result.paymentMethod.id );
//jQuery('#event-order [name="payment_token"]').val( result.token.id );
jQuery('#event-order .warning-payment').hide();
jQuery('#event-order .warning-payment').html( '' );
jQuery("#event-order #event-order-form").trigger('submit');
}
});
}
else
{
jQuery("#event-order .order-submit").prop('disabled', false);
}
});
/*jQuery("#event-order #event-order-form").on("submit", function(e)
{
jQuery("#event-order .order-submit").trigger('click');
e.preventDefault();
});*/
updateFormSent();
});
</script>
<script>
jQuery(document).ready(function()
{
jQuery('html, body').animate(
{
scrollTop: ( jQuery('.form-errors').first().offset().top - 10 )
}, 500 );
});
</script>
<script>
jQuery(document).ready(function()
{
function checkEmailValue(value, arr) {
var status = 'not_exist';
for (var i = 0; i < arr.length; i++) {
var name = arr[i];
if (name == value) {
status = 'exist';
break;
}
}
return status;
}
//code to check if a value exists in an array
var email_pool = ['gmail.com','yahoo.com','hotmail.com','aol.com','hotmail.co.uk','hotmail.fr','msn.com','yahoo.fr','wanadoo.fr','orange.fr','comcast.net','yahoo.co.uk','yahoo.com.br','yahoo.co.in','live.com','rediffmail.com','free.fr','gmx.de','web.de','yandex.ru','ymail.com','libero.it','outlook.com','uol.com.br','bol.com.br','mail.ru','cox.net','hotmail.it','sbcglobal.net','sfr.fr','live.fr','verizon.net','live.co.uk','googlemail.com','yahoo.es','ig.com.br','live.nl','bigpond.com','terra.com.br','yahoo.it','neuf.fr','yahoo.de','alice.it','rocketmail.com','att.net','laposte.net','facebook.com','bellsouth.net','yahoo.in','hotmail.es','charter.net','yahoo.ca','yahoo.com.au','rambler.ru','hotmail.de','tiscali.it','shaw.ca','yahoo.co.jp','sky.com','earthlink.net','optonline.net','freenet.de','t-online.de','aliceadsl.fr','virgilio.it','home.nl','qq.com','telenet.be','me.com','yahoo.com.ar','tiscali.co.uk','yahoo.com.mx','voila.fr','gmx.net','mail.com','planet.nl','tin.it','live.it','ntlworld.com','arcor.de','yahoo.co.id','frontiernet.net','hetnet.nl','live.com.au','yahoo.com.sg','zonnet.nl','club-internet.fr','juno.com','optusnet.com.au','blueyonder.co.uk','bluewin.ch','skynet.be','sympatico.ca','windstream.net','mac.com','centurytel.net','chello.nl','live.ca','aim.com','bigpond.net.au'];
jQuery( "#confirm_email_address" ).focusout(function() {
jQuery("#confirm_email_error").css("display", (jQuery("#email_address").val() != jQuery("#confirm_email_address").val()) ? "inline-block" : "none");
var user_email = jQuery("#email_address").val();
var domain_name = user_email.indexOf("@");
var slice_email = user_email.slice((domain_name + 1), user_email.length);
if(jQuery("#email_address").val() == jQuery("#confirm_email_address").val()){
// if(!jQuery.inArray(slice_email, email_pool)){
if(checkEmailValue(slice_email, email_pool) == 'not_exist'){
jQuery("#confirm_email_error2").css("display", "inline-block");
jQuery("#confirm_email_error2").html("Hmmm, are you sure... <strong>"+user_email+"</strong> is the correct email address?");
}else{
jQuery("#confirm_email_error2").css("display", "none");
}
}
});
});
</script>
<style>
.email_address_error{
margin-top: 5px !important;
color: rgb(255, 255, 255);
background-color: rgb(237, 30, 38);
font-weight: normal;
padding: 0.3em 15px;
}.email_address_warning{
margin-top: 5px !important;
color: rgb(255, 255, 255);
background-color: #25a828;
font-weight: normal;
padding: 0.3em 15px;
}
</style>
Contact details
# What to change (quick summary)
1) **Make charging idempotent** → prevent double charges on double POSTs.
2) **Stop manually attaching the PaymentMethod**; let the PaymentIntent confirm with `payment_method` directly.
3) **If you must use a Customer**, reuse the PM’s existing `customer` (don’t re‑attach).
4) **Fix the 5% application fee math** (currently using euros instead of cents).
5) **Guard your order insert** so a re‑POST can’t create a duplicate.
—
# 1) Add an idempotency key
Create a stable hash from values that uniquely identify the attempt (event, date, cart contents, email). Use it in the Stripe call so the **second** POST can’t create a second charge.
function buildIdempotencyKey(array $post, string $eventId, string $eventDateId): string {
// include all inputs that should map to a single charge attempt
$payload = [
$eventId,
$eventDateId,
strtolower(trim($post['customer_email'] ?? '')),
json_encode(array_intersect_key($post, array_flip(array_filter(array_keys($post), fn($k)=>str_starts_with($k,'event_seat_')))), JSON_UNESCAPED_UNICODE),
json_encode(array_intersect_key($post, array_flip(array_filter(array_keys($post), fn($k)=>str_starts_with($k,'addon_')))), JSON_UNESCAPED_UNICODE),
$post['voucher'] ?? ''
];
return 'fossetts:' . hash('sha256', implode('|', $payload));
}
Use it when you **create** the PaymentIntent.
—
# 2) Don’t attach the PaymentMethod up front
You’re doing:
StripePaymentMethod::retrieve(...)->attach([...]);
If that PM is already attached to **some** customer on the same connected account, Stripe throws the error you saw. You don’t need to attach it just to charge. Pass it to the PI and confirm.
**Replace your create block** under `if (isset($_POST['payment_token'])) { … }` with this:
$pmId = $_POST['payment_token']; // pm_XXXX from Elements
$idempotencyKey = buildIdempotencyKey($_POST, $event_id, $event_date_id);
// OPTIONAL: if you really want a Customer, try to reuse the PM's existing customer
$customerId = null;
try {
$pmObj = StripePaymentMethod::retrieve($pmId, ['stripe_account' => "acct_1C7mK9IQtQgABICR"]);
if (!empty($pmObj->customer)) {
$customerId = $pmObj->customer; // reuse existing
} else {
// You can skip creating a customer entirely. If you want one only when missing:
// $customer = StripeCustomer::create(['name' => $_POST['customer_name'], 'email' => $_POST['customer_email']], ['stripe_account' => "acct_1C7mK9IQtQgABICR"]);
// $customerId = $customer->id;
}
} catch (Exception $e) {
// Safe to ignore; proceed without customer
}
$intentParams = [
"amount" => $amount_in_cents,
"currency" => $event_data->event['currency'],
"payment_method" => $pmId,
"confirm" => true,
"return_url" => "https://fossettscircus.com/success/",
"application_fee_amount" => $fee_in_cents,
"metadata" => ['Location' => $event_data->event['area']],
"automatic_payment_methods" => [
"enabled" => true,
"allow_redirects" => "never"
],
];
if ($customerId) {
$intentParams["customer"] = $customerId;
}
$intent = StripePaymentIntent::create(
$intentParams,
[
"stripe_account" => "acct_1C7mK9IQtQgABICR",
"idempotency_key" => $idempotencyKey
]
);
**Delete** the explicit `->attach()` call entirely. You can still save cards later using `setup_future_usage` or attaching after success if needed.
—
# 3) Keep your confirm‑existing‑intent path
Your “requires_action → client handles → posts `payment_intent_id` → server confirms” path is right. Leave this as‑is:
if (isset($_POST['payment_intent_id'])) {
$intent = StripePaymentIntent::retrieve(
$_POST['payment_intent_id'],
["stripe_account" => "acct_1C7mK9IQtQgABICR"]
);
$intent->confirm();
}
(And keep your `requires_action` / `succeeded` branches.)
—
# 4) Fix the 5% fee math
This line is wrong:
$fee_in_cents = (int)($amount_in_eur * 5); // ❌ this is euros * 5, not 5%
Use:
$fee_in_cents = (int) round($amount_in_eur * 100 * 0.05); // ✅ 5% of amount, in cents
$amount_in_cents = (int) round($amount_in_eur * 100);
—
# 5) Block duplicate order rows (defense in depth)
Right before `insertOrder`, make a **unique key** and short‑circuit if already inserted:
$orderFingerprint = hash('sha256', $order_info->customer_email . '|' . $order_info->event_id . '|' . $order_info->event_date_id . '|' . $amount_in_cents);
// Example: add a unique index in DB on (order_fingerprint)
// Or just check existence:
if ($fossets_database->orderExistsByFingerprint($orderFingerprint)) {
echo json_encode([
'success' => true,
'redirect_url' => 'https://fossettscircus.com/success/?eid=' . $event_date_id . '&oc=' . $order_info->order_code,
'info' => 'duplicate_post_ignored'
]);
exit;
}
$order_info->order_fingerprint = $orderFingerprint;
if ($fossets_database->insertOrder($order_info)) {
...
}
(If adding a column is heavy, at least check by `payment_intent_id` or your `order_code` if it’s deterministic per attempt.)
—
## Small cleanups (optional but good)
– You call `ob_start()` twice — one at the top is enough.
– Where you compute min amounts per currency, keep amounts in **major** units but always convert once to cents and never mix the two afterward.
– If in‑app browsers or Safari are double‑posting, consider adding a lightweight server lock (e.g., `flock()` on a file keyed by the idempotency key for ~5s) around the create/confirm block.
—
## TL;DR drop-in edits you can copy
Search for these blocks in your file and replace:
1) **Fee calculation**
$fee_in_cents = (int) round($amount_in_eur * 100 * 0.05);
$amount_in_cents = (int) round($amount_in_eur * 100);
2) **PaymentIntent create (remove attach, add idempotency)**
$pmId = $_POST['payment_token'];
$idempotencyKey = buildIdempotencyKey($_POST, $event_id, $event_date_id);
$customerId = null;
try {
$pmObj = StripePaymentMethod::retrieve($pmId, ['stripe_account' => "acct_1C7mK9IQtQgABICR"]);
if (!empty($pmObj->customer)) {
$customerId = $pmObj->customer;
}
} catch (Exception $e) {}
$intentParams = [
"amount" => $amount_in_cents,
"currency" => $event_data->event['currency'],
"payment_method" => $pmId,
"confirm" => true,
"return_url" => "https://fossettscircus.com/success/",
"application_fee_amount" => $fee_in_cents,
"metadata" => ['Location' => $event_data->event['area']],
"automatic_payment_methods" => ["enabled" => true, "allow_redirects" => "never"]
];
if ($customerId) { $intentParams["customer"] = $customerId; }
$intent = StripePaymentIntent::create(
$intentParams,
["stripe_account" => "acct_1C7mK9IQtQgABICR", "idempotency_key" => $idempotencyKey]
);
3) **(Optional) Duplicate order guard** just before `insertOrder(…)`:
$order_info->order_fingerprint = hash('sha256', $order_info->customer_email.'|'.$order_info->event_id.'|'.$order_info->event_date_id.'|'.$amount_in_cents);
if ($fossets_database->orderExistsByFingerprint($order_info->order_fingerprint)) {
echo json_encode(['success'=>true,'redirect_url'=>'https://fossettscircus.com/success/?eid='.$event_date_id.'&oc='.$order_info->order_code]);
exit;
}
—
If you paste these in your **testing** environment, your two specific issues should disappear:
– The PM attach error stops (no more manual `attach()`),
– Duplicate POSTs won’t create duplicate charges (idempotency + duplicate‑row guard).
if (isset($_POST['payment_token'])) {
try {
$customer = StripeCustomer::create([
'name' => $_POST['customer_name'],
'email' => $_POST['customer_email']
], [
'stripe_account' => "acct_1C7mK9IQtQgABICR"
]);
// ✅ Retrieve and attach payment method on connected account
StripePaymentMethod::retrieve(
$_POST['payment_token'],
['stripe_account' => "acct_1C7mK9IQtQgABICR"]
)->attach([
'customer' => $customer->id
], [
'stripe_account' => "acct_1C7mK9IQtQgABICR"
]);
$intent = StripePaymentIntent::create(
array(
"amount" => $amount_in_cents,
"currency" => $event_data->event['currency'],
"payment_method" => $_POST['payment_token'],
"customer" => $customer->id,
"application_fee_amount" => $fee_in_cents,
"description" => "Fossets charge for " . $_POST['customer_email'],
"metadata" => ['Location' => $event_data->event['area']],
"confirm" => true,
"return_url" => "https://fossettscircus.com/success/",
"automatic_payment_methods" => [
"enabled" => true,
"allow_redirects" => "never"
]
), array(
"stripe_account" => "acct_1C7mK9IQtQgABICR" // fossetts
)
);
if ($intent->status === 'requires_action') {
echo json_encode([
'requires_action' => true,
'payment_intent_client_secret' => $intent->client_secret,
'payment_intent_id' => $intent->id,
'status' => 'requires_action'
]);
exit;
} elseif ($intent->status === 'succeeded') {
// Process add-ons for this section too
$addonString = processAddons($_POST);
// Debug logging for add-ons in second section
error_log('Add-on processing debug (second section):');
error_log('POST data: ' . print_r($_POST, true));
error_log('Generated addonString: "' . $addonString . '"');
$order_info = createOrderInfo($_POST, $event_id, $event_date_id, $event_data, $selected_event_date, $ticket_quantities, $ticket_data, $addonString, $voucher_data, $amount_in_eur, $intent);
saveOrderAndSendEmails($order_info, $fossets_database, $event_date_id);
}
} catch (StripeExceptionCardException $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
'status' => 'card_error'
]);
exit;
} catch (Exception $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
'status' => 'error'
]);
exit;
}
}
It removes the manual `attach()`, adds a safe **idempotency key**, and reuses an existing customer *only if* the PaymentMethod is already attached to one.
// Helper once (safe to keep near other helpers)
if (!function_exists('buildIdempotencyKey')) {
function buildIdempotencyKey(array $post, string $eventId, string $eventDateId): string {
// Uniquely represent this charge attempt: event, date, email, tickets, addons, voucher
$ticketsJson = json_encode(
array_intersect_key($post, array_flip(array_filter(array_keys($post), fn($k)=>strpos($k,'event_seat_')===0))),
JSON_UNESCAPED_UNICODE
);
$addonsJson = json_encode(
array_intersect_key($post, array_flip(array_filter(array_keys($post), fn($k)=>strpos($k,'addon_')===0))),
JSON_UNESCAPED_UNICODE
);
$finger = implode('|', [
$eventId,
$eventDateId,
strtolower(trim($post['customer_email'] ?? '')),
$ticketsJson,
$addonsJson,
$post['voucher'] ?? ''
]);
return 'fossetts:' . hash('sha256', $finger);
}
}
if (isset($_POST['payment_token'])) {
try {
$pmId = $_POST['payment_token']; // pm_XXXX from Elements
$idempotencyKey = buildIdempotencyKey($_POST, $event_id, $event_date_id);
// Try to reuse the PM's existing customer (on the connected account).
$customerId = null;
try {
$pmObj = StripePaymentMethod::retrieve(
$pmId,
['stripe_account' => "acct_1C7mK9IQtQgABICR"]
);
if (!empty($pmObj->customer)) {
$customerId = $pmObj->customer; // Reuse, don't re-attach
}
} catch (Exception $e) {
// Non-fatal; proceed without a customer
}
// Build PI params: no manual attach; confirm immediately
$intentParams = [
"amount" => $amount_in_cents, // make sure you computed with round(...*100)
"currency" => $event_data->event['currency'],
"payment_method" => $pmId,
"confirm" => true,
"return_url" => "https://fossettscircus.com/success/",
"application_fee_amount" => $fee_in_cents, // make sure it's 5% in cents
"description" => "Fossets charge for " . ($_POST['customer_email'] ?? ''),
"metadata" => ['Location' => $event_data->event['area']],
"automatic_payment_methods" => [
"enabled" => true,
"allow_redirects" => "never"
]
];
// Only include customer if PM already belongs to one. Otherwise omit.
if ($customerId) {
$intentParams["customer"] = $customerId;
}
$intent = StripePaymentIntent::create(
$intentParams,
[
"stripe_account" => "acct_1C7mK9IQtQgABICR",
"idempotency_key" => $idempotencyKey
]
);
if ($intent->status === 'requires_action') {
echo json_encode([
'requires_action' => true,
'payment_intent_client_secret' => $intent->client_secret,
'payment_intent_id' => $intent->id,
'status' => 'requires_action'
]);
exit;
} elseif ($intent->status === 'succeeded') {
// Process add-ons for this section too
$addonString = processAddons($_POST);
// Debug logging for add-ons in second section
error_log('Add-on processing debug (second section):');
error_log('POST data: ' . print_r($_POST, true));
error_log('Generated addonString: "' . $addonString . '"');
$order_info = createOrderInfo(
$_POST,
$event_id,
$event_date_id,
$event_data,
$selected_event_date,
$ticket_quantities,
$ticket_data,
$addonString,
$voucher_data,
$amount_in_eur,
$intent
);
// (Optional) duplicate-order guard can go here if you added it
saveOrderAndSendEmails($order_info, $fossets_database, $event_date_id);
} else {
echo json_encode([
'success' => false,
'message' => 'Payment failed: ' . $intent->status,
'status' => $intent->status
]);
exit;
}
} catch (StripeExceptionCardException $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
'status' => 'card_error'
]);
exit;
} catch (Exception $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
'status' => 'error'
]);
exit;
}
}
It checks if an order already exists in your orders table using a fingerprint hash (string).
public function orderExistsByFingerprint(string $fingerprint): bool
{
try {
// Adjust table/column name to your schema
$sql = "SELECT COUNT(*) as total
FROM fossets_orders
WHERE order_fingerprint = :fingerprint
LIMIT 1";
$stmt = $this->db->prepare($sql);
$stmt->bindParam(':fingerprint', $fingerprint, PDO::PARAM_STR);
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return ($row && (int)$row['total'] > 0);
} catch (Exception $e) {
error_log("orderExistsByFingerprint error: " . $e->getMessage());
return false;
}
}
### Notes:
1. Replace `fossets_orders` with the actual **orders table** name you’re inserting into.
2. Make sure you add a column `order_fingerprint VARCHAR(64)` (or longer) to that table:
ALTER TABLE fossets_orders ADD COLUMN order_fingerprint VARCHAR(64) UNIQUE;
(`UNIQUE` ensures DB itself blocks accidental duplicates.)
3. Call this before `insertOrder()`:
if ($fossets_database->orderExistsByFingerprint($order_info->order_fingerprint)) {
// Duplicate – return success but skip inserting again
echo json_encode([
'success' => true,
'redirect_url' => 'https://fossettscircus.com/success/?eid='.$event_date_id.'&oc='.$order_info->order_code,
'info' => 'duplicate_post_ignored'
]);
exit;
}
—
Do you want me to also give you the **SQL migration snippet** to safely add this `order_fingerprint` column to your orders table?
js
let orderData = {
tickets: [],
addOns: [],
total: 0
};
// Store event date string globally for use in order summary
let selectedEventDateString = '';
// Global add-ons data for label lookup
let globalAddOnsData = [];
// Cache DOM elements
const domCache = {
form: null,
ticketSections: null,
addOnsGrid: null,
cart: null
};
// Initialize DOM cache
function initializeDomCache() {
domCache.form = document.getElementById('event-order-form');
domCache.ticketSections = document.getElementById('ticket-sections');
domCache.addOnsGrid = document.getElementById('add-ons-grid');
domCache.cart = document.getElementById('cart');
}
function updateOrderSummary() {
console.log('updateOrderSummary called');
console.log('Current orderData:', orderData);
if (!domCache.form) return;
// Update event date/time in order summary (step 3)
const summaryEventDate = document.getElementById('summary-event-date');
if (summaryEventDate) {
summaryEventDate.textContent = selectedEventDateString || '';
}
let subtotal = 0;
let ticketData = [];
// Process each ticket
orderData.tickets.forEach(ticket => {
if (ticket.quantity > 0) {
const itemSubtotal = ticket.quantity * ticket.price;
subtotal += itemSubtotal;
ticketData.push({
section: ticket.section,
type: ticket.type,
quantity: ticket.quantity,
price: ticket.price,
subtotal: itemSubtotal
});
}
});
// Calculate add-ons total
const addOnsTotal = calculateAddOns();
let total = subtotal + addOnsTotal;
// Apply voucher discount if valid – only to tickets
const successVoucher = document.querySelector('.success-voucher');
if (successVoucher && successVoucher.style.display === 'block') {
const voucherText = successVoucher.textContent;
const voucherMatch = voucherText.match(/Voucher Applied: (w+)/);
const discountMatch = voucherText.match(/-(d+)(%|€)/);
if (voucherMatch && discountMatch) {
const voucherCode = voucherMatch[1];
const discountValue = parseFloat(discountMatch[1]);
const discountType = discountMatch[2];
if (discountType === '%') {
// Apply discount only to ticket subtotal
const discountedTicketTotal = subtotal * (1 – discountValue / 100);
total = discountedTicketTotal + addOnsTotal;
} else {
// For fixed amount, apply to ticket total only
const discountedTicketTotal = Math.max(0, subtotal – discountValue);
total = discountedTicketTotal + addOnsTotal;
}
}
}
// Update cart display
updateCartDisplay(ticketData, subtotal, addOnsTotal, total);
saveOrderDataToStorage();
addRemoveListenersToSummary();
// Update Express Checkout amount if available
if (window.updatePaymentRequestAmount) {
window.updatePaymentRequestAmount();
}
}
function calculateSubtotal() {
let subtotal = 0;
const ticketSelects = document.querySelectorAll('select[name^="event_seat_"]');
for (const select of ticketSelects) {
subtotal += parseInt(select.value) * parseFloat(select.dataset.price);
}
return subtotal;
}
function calculateAddOns() {
let addOnsTotal = 0;
orderData.addOns.forEach(addon => {
addOnsTotal += addon.quantity * addon.price;
});
return addOnsTotal;
}
function generateOrderSummaryHTML() {
let html = '';
// Add tickets to summary
if (orderData?.tickets?.length > 0) {
html += orderData.tickets
.filter(ticket => ticket?.type && ticket?.price && ticket?.quantity)
.map(ticket => `
${ticket.type} Ticket
€${ticket.price.toFixed(2)} each
x${ticket.quantity}
€${(ticket.price * ticket.quantity).toFixed(2)}
`).join('');
}
// Add wheelchair count to summary
const wheelchairUsers = getWheelchairUserData();
if (wheelchairUsers > 0) {
html += `
Wheelchair
Access required
x${wheelchairUsers}
–
`;
}
// Add add-ons to summary
if (orderData?.addOns?.length > 0) {
console.log('Generating add-ons summary for:', orderData.addOns);
html += orderData.addOns
.filter(addon => addon?.name && addon?.price && addon?.quantity)
.map(addon => {
console.log('Processing addon:', addon);
const displayName = addon.label || addon.name;
console.log('Display name:', displayName);
return `
${displayName}
€${addon.price.toFixed(2)} each
x${addon.quantity}
€${(addon.price * addon.quantity).toFixed(2)}
`;
}).join('');
}
// If cart is empty, show message
if (!orderData?.tickets?.length && !orderData?.addOns?.length && wheelchairUsers === 0) {
html = `
Your cart is empty
Select tickets to continue
`;
} else {
// Add total if there are items
const total = orderData.total || 0;
html += `
Total:
€${total.toFixed(2)}
`;
}
return html;
}
function saveOrderData(orderData) {
localStorage.setItem('orderData', JSON.stringify(orderData));
if (domCache.form) {
// Update ticket quantities
if (Array.isArray(orderData.tickets)) {
orderData.tickets.forEach(ticket => {
const select = domCache.form.querySelector(`[name="event_seat_${ticket.type.toLowerCase()}"]`);
if (select) select.value = ticket.quantity;
});
}
// Update add-ons
if (Array.isArray(orderData.addOns)) {
orderData.addOns.forEach(addon => {
const select = domCache.form.querySelector(`[name="addon_${addon.name}"]`);
if (select) select.value = addon.quantity;
});
}
}
// Update cart display without triggering saveOrderData
if (Array.isArray(orderData.tickets)) {
updateCartDisplay(
orderData.tickets,
orderData.total – (orderData.addOns?.reduce((sum, addon) => sum + (addon.price * addon.quantity), 0) || 0),
orderData.addOns?.reduce((sum, addon) => sum + (addon.price * addon.quantity), 0) || 0,
orderData.total,
false
);
}
}
// Add this function near the top-level functions
function saveOrderDataToStorage() {
try {
localStorage.setItem('fossettsOrderData', JSON.stringify(orderData));
} catch (e) {
console.error('Failed to save order data:', e);
}
}
function restoreSelectionsToUI() {
// Restore ticket quantities
if (orderData.tickets && Array.isArray(orderData.tickets)) {
orderData.tickets.forEach(ticket => {
// Use lowercase for ticket type to match attribute
const input = document.querySelector(`.quantity-value[data-section="${ticket.section}"][data-ticket-type="${ticket.type.toLowerCase()}"]`);
if (input) {
input.value = ticket.quantity;
}
});
}
// Restore add-on quantities
if (orderData.addOns && Array.isArray(orderData.addOns)) {
orderData.addOns.forEach(addon => {
const input = document.querySelector(`.quantity-value[data-addon-name="${addon.name}"]`);
if (input) {
input.value = addon.quantity;
}
});
}
}
// On DOMContentLoaded, load orderData from localStorage if present
const savedOrderData = localStorage.getItem('fossettsOrderData');
if (savedOrderData) {
try {
orderData = JSON.parse(savedOrderData);
restoreSelectionsToUI();
updateOrderSummary();
// Optionally, update UI for tickets/addons here if needed
} catch (e) {
console.error('Failed to parse saved order data:', e);
}
}
// Global function to show speech bubble warning
function showSpeechBubble(message) {
console.log('Showing speech bubble:', message);
const speechBubble = document.querySelector('.speech-bubble-warning');
if (!speechBubble) {
console.error('Speech bubble element not found');
return;
}
// Determine which step is active and position accordingly
const activeStep = document.querySelector('.step_1.active, .step_2.active, .step_3.active');
let nextButton = document.querySelector('.sticky-cart-bar .next-step');
// For Step 3, target the Buy Now button if it exists
if (activeStep && activeStep.classList.contains('step_3')) {
const buyNowButton = document.querySelector('.buy-now-btn.complete-purchase');
if (buyNowButton && buyNowButton.style.display !== 'none') {
nextButton = buyNowButton;
}
}
console.log('Active step:', activeStep?.className);
console.log('Next button found:', nextButton);
if (nextButton) {
console.log('Next button position:', nextButton.getBoundingClientRect());
}
// Use the same fixed positioning for both desktop and mobile
speechBubble.style.position = 'fixed';
speechBubble.style.bottom = '100px';
speechBubble.style.right = '10px';
speechBubble.style.top = 'auto';
speechBubble.style.left = 'auto';
speechBubble.style.transform = 'none';
speechBubble.style.zIndex = '9999';
// Move bubble to body
if (speechBubble.parentElement !== document.body) {
document.body.appendChild(speechBubble);
}
speechBubble.textContent = message;
speechBubble.style.display = 'block';
// No need to scroll since bubble is in fixed position
// Hide after 3 seconds
setTimeout(() => {
speechBubble.style.display = 'none';
}, 3000);
}
// Update the populateTicketSections function to include order info
function populateTicketSections(eventData) {
if (!domCache.ticketSections) return;
// Update order info block
const orderInfoBlock = document.querySelector('.order-info-block');
if (orderInfoBlock) {
orderInfoBlock.innerHTML = `
Order Information
${eventData.event_date_string}
${eventData.venue_title}
${eventData.venue_full}
`;
}
const html = eventData.tickets.map(section => {
const sectionId = section.title.toLowerCase().replace(/s+/g, '_');
return `
${section.title}
${section.seating_type}
Ticket Type
Price Per Person
Number of Tickets*
${section.ticket_types.map(type => `
${type.label}
Online Special
€${type.price}
–
+
`).join('')}
`}).join('');
domCache.ticketSections.innerHTML = html;
// Add event listeners for quantity buttons
document.querySelectorAll('.ticket-quantity-btn').forEach(button => {
button.addEventListener('click', function() {
const section = this.dataset.section;
const ticketType = this.dataset.ticketType;
const price = parseFloat(this.dataset.price);
const quantityInput = document.querySelector(`.quantity-value[data-section="${section}"][data-ticket-type="${ticketType}"]`);
const currentValue = parseInt(quantityInput.value);
if (this.classList.contains('minus') && currentValue > 0) {
quantityInput.value = currentValue – 1;
updateTicketQuantity(section, ticketType, price, currentValue – 1);
} else if (this.classList.contains('plus')) {
const maxValue = parseInt(quantityInput.getAttribute('max'));
if (currentValue < maxValue) {
quantityInput.value = currentValue + 1;
updateTicketQuantity(section, ticketType, price, currentValue + 1);
}
}
});
});
}
function populateAddOns(eventData) {
if (!domCache.addOnsGrid) return;
// Build all HTML at once
const html = `
${eventData.add_ons.map(addon => `
${addon.label}
${addon.description ? `${addon.description}` : ''}
€${addon.price.toFixed(2)}
–
+
`).join('')}
`;
domCache.addOnsGrid.innerHTML = html;
// Add event listeners for quantity buttons
document.querySelectorAll('.addon-list-item .quantity-btn').forEach(button => {
button.addEventListener('click', function() {
const addonName = this.dataset.addonName;
const price = parseFloat(this.dataset.price);
const quantityInput = document.querySelector(`.addon-list-item .quantity-value[data-addon-name="${addonName}"]`);
const currentValue = parseInt(quantityInput.value);
if (this.classList.contains('minus') && currentValue > 0) {
quantityInput.value = currentValue – 1;
updateAddonQuantity(addonName, price, currentValue – 1);
} else if (this.classList.contains('plus')) {
const maxValue = parseInt(quantityInput.getAttribute('max'));
if (currentValue < maxValue) {
quantityInput.value = currentValue + 1;
updateAddonQuantity(addonName, price, currentValue + 1);
}
}
});
});
}
function checkAndHandleAddOns(eventData) {
// If add_ons is empty or all are disabled, hide step_2
const hasActiveAddOns = eventData.add_ons && eventData.add_ons.some(addon => addon.enabled !== false);
const step2 = document.querySelector('.step_2');
if (!hasActiveAddOns) {
if (step2) {
step2.classList.add('inactive');
step2.style.display = 'none';
}
window.skipAddOnsStep = true;
} else {
if (step2) {
step2.classList.remove('inactive');
step2.style.display = '';
}
window.skipAddOnsStep = false;
}
}
function updateAddonQuantity(addonName, price, quantity) {
console.log('updateAddonQuantity called with:', { addonName, price, quantity });
console.log('globalAddOnsData:', globalAddOnsData);
// Find or create addon in orderData
const addonIndex = orderData.addOns.findIndex(a => a.name === addonName);
if (addonIndex === -1 && quantity > 0) {
// Find the addon data to get the proper label
const addonData = globalAddOnsData.find(addon => addon.name === addonName);
const label = addonData?.label || addonName;
console.log('Found addon data:', addonData);
console.log('Using label:', label);
// Add new addon
orderData.addOns.push({
name: addonName,
label: label,
price: price,
quantity: quantity
});
} else if (addonIndex !== -1) {
if (quantity > 0) {
orderData.addOns[addonIndex].quantity = quantity;
} else {
// Remove addon if quantity is zero
orderData.addOns.splice(addonIndex, 1);
}
}
// Update cart display
updateOrderSummary();
saveOrderDataToStorage();
}
// Event delegation for ticket and add-on selects
function handleSelectChange(event) {
if (event.target.matches('select[name^="event_seat_"], .add-on-quantity select')) {
updateOrderSummary();
}
}
// Initialize event listeners using event delegation
function initializeEventListeners() {
if (domCache.form) {
domCache.form.addEventListener('change', handleSelectChange);
// Add form submission handler
domCache.form.addEventListener('submit', handleFormSubmit);
}
// Add click handler for complete purchase button
const completePurchaseBtn = document.querySelector('.next-step.complete-purchase');
if (completePurchaseBtn) {
completePurchaseBtn.addEventListener('click', function(event) {
event.preventDefault();
handleFormSubmit(event);
});
}
// Add voucher validation
const applyVoucherBtn = document.querySelector('.apply-voucher-btn');
const voucherInput = document.querySelector('input[name="voucher"]');
const warningVoucher = document.querySelector('.warning-voucher');
const successVoucher = document.querySelector('.success-voucher');
if (applyVoucherBtn && voucherInput) {
applyVoucherBtn.addEventListener('click', async function() {
const voucherCode = voucherInput.value.trim();
if (!voucherCode) {
warningVoucher.style.display = 'none';
successVoucher.style.display = 'none';
return;
}
try {
// Get event IDs from URL
const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get('event_id');
const eventDateId = urlParams.get('event_date_id');
if (!eventId) {
throw new Error('Missing event information');
}
// Call backend to validate voucher
const response = await fetch(`/api/validate-voucher.php?event_id=${eventId}&voucher_code=${encodeURIComponent(voucherCode)}`);
const result = await response.json();
if (result.valid) {
warningVoucher.style.display = 'none';
successVoucher.style.display = 'block';
successVoucher.textContent = `Voucher Applied: ${voucherCode} – Discount on tickets ${result.discount_type === 'percentage' ? '-' + result.discount + '%' : '-€' + result.discount}`;
// Update total with discount
updateOrderSummary();
} else {
warningVoucher.style.display = 'block';
successVoucher.style.display = 'none';
}
} catch (error) {
console.error('Error validating voucher:', error);
warningVoucher.style.display = 'block';
successVoucher.style.display = 'none';
}
});
// Clear messages when input changes
voucherInput.addEventListener('input', function() {
warningVoucher.style.display = 'none';
successVoucher.style.display = 'none';
});
}
}
// Main initialization
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM Content Loaded – Starting initialization…');
initializeDomCache();
initializeEventListeners();
// Initialize speech bubble warning
const speechBubble = document.querySelector('.speech-bubble-warning');
if (speechBubble) {
speechBubble.style.display = 'none';
}
// Add form submission handler
const form = document.getElementById('event-order-form');
if (form) {
form.addEventListener('submit', handleFormSubmit);
}
// Add click handler for complete purchase button
const completePurchaseBtn = document.querySelector('.next-step.complete-purchase');
if (completePurchaseBtn) {
completePurchaseBtn.addEventListener('click', function(event) {
event.preventDefault();
handleFormSubmit(event);
});
}
// Initialize step navigation
const params = getUrlParams();
if (params.event_id && params.event_date_id) {
fetchEventData(params.event_id, params.event_date_id)
.then(eventData => {
checkAndHandleAddOns(eventData);
populateTicketSections(eventData);
populateAddOns(eventData);
restoreSelectionsToUI(); // {
console.error('Error loading event data:', error);
// Check if it's a booking closed error
if (error.message && error.message.includes('Online Booking Closed')) {
// Get the event date from the URL parameters to calculate countdown
const params = getUrlParams();
const url = `/api/events.php?event_id=${params.event_id}&event_date_id=${params.event_date_id}`;
fetch(url)
.then(response => response.json())
.then(data => {
const eventDate = data.event_dates.find(date => date.event_date_id === parseInt(params.event_date_id));
if (eventDate) {
const showtime = new Date(eventDate.event_datetime);
const now = new Date();
const timeDiff = showtime – now;
const minutesUntilShow = Math.floor(timeDiff / (1000 * 60));
// Display online closed message with countdown
const ticketSections = document.getElementById('ticket-sections');
if (ticketSections) {
ticketSections.innerHTML = `
ONLINE CLOSED – Starts in ${minutesUntilShow} min
Go to Boxoffice
Box Office Information:
• Open 1 hour before showtime
• Cash and card payments accepted
• Subject to availability
`;
}
}
})
.catch(() => {
// Fallback if we can't get the countdown
const ticketSections = document.getElementById('ticket-sections');
if (ticketSections) {
ticketSections.innerHTML = `
ONLINE CLOSED
This show is starting within 30 minutes.
Go to Boxoffice
`;
}
});
} else {
// Show generic error message
showSpeechBubble('Error loading event data. Please refresh the page.');
}
});
}
// Wheelchair user logic (number input with +/-)
const wheelchairCountInput = document.getElementById('wheelchair-user-count');
const wheelchairMinus = document.getElementById('wheelchair-minus');
const wheelchairPlus = document.getElementById('wheelchair-plus');
if (wheelchairCountInput && wheelchairMinus && wheelchairPlus) {
wheelchairMinus.addEventListener('click', function() {
let val = parseInt(wheelchairCountInput.value) || 0;
if (val > 0) wheelchairCountInput.value = val – 1;
});
wheelchairPlus.addEventListener('click', function() {
let val = parseInt(wheelchairCountInput.value) || 0;
wheelchairCountInput.value = val + 1;
});
}
});
function getWheelchairUserData() {
const wheelchairCountInput = document.getElementById('wheelchair-user-count');
let wheelchairUsers = 0;
if (wheelchairCountInput) {
wheelchairUsers = parseInt(wheelchairCountInput.value) || 0;
}
return wheelchairUsers;
}
// In handleFormSubmit or wherever orderData is finalized before sending to backend:
// orderData.wheelchair_users = getWheelchairUserData();
// Cart functionality
let cartInitialized = false;
let isToggling = false;
function toggleCart(event) {
if (isToggling) return;
isToggling = true;
if (event) {
event.preventDefault();
event.stopPropagation();
}
console.log('Toggling cart', {
cartInitialized,
event: event ? event.type : 'manual',
target: event ? event.target : 'none'
});
const cartSlideOut = document.querySelector('.cart-slide-out');
const cartToggle = document.querySelector('.cart-toggle');
const cartToggleIcon = document.querySelector('.cart-toggle-icon');
if (!cartSlideOut || !cartToggle) {
console.error('Cart elements not found:', { cartSlideOut, cartToggle });
isToggling = false;
return;
}
const isActive = cartSlideOut.classList.contains('active');
if (isActive) {
cartSlideOut.classList.remove('active');
cartToggle.classList.remove('active');
if (cartToggleIcon) cartToggleIcon.classList.remove('active');
} else {
cartSlideOut.classList.add('active');
cartToggle.classList.add('active');
if (cartToggleIcon) cartToggleIcon.classList.add('active');
}
setTimeout(() => {
isToggling = false;
}, 100);
}
function initializeCart() {
if (cartInitialized) {
console.log('Cart already initialized');
return;
}
console.log('Initializing cart functionality');
// Remove any existing event listeners
const cartToggle = document.querySelector('.cart-toggle');
const innerCartToggle = document.querySelector('.cart-toggle.inner');
if (cartToggle) {
cartToggle.removeEventListener('click', toggleCart);
cartToggle.addEventListener('click', toggleCart);
}
if (innerCartToggle) {
innerCartToggle.removeEventListener('click', toggleCart);
innerCartToggle.addEventListener('click', toggleCart);
}
// Add click outside handler
document.removeEventListener('click', handleOutsideClick);
document.addEventListener('click', handleOutsideClick);
cartInitialized = true;
console.log('Cart initialization complete');
}
function handleOutsideClick(event) {
const cartSlideOut = document.querySelector('.cart-slide-out');
const cartToggle = document.querySelector('.cart-toggle');
if (cartSlideOut && cartSlideOut.classList.contains('active')) {
if (!cartSlideOut.contains(event.target) && !cartToggle.contains(event.target)) {
toggleCart();
}
}
}
// Initialize cart when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM Content Loaded – Initializing cart…');
initializeCart();
});
// Step Navigation
function goToStep(stepNumber) {
console.log('Navigating to step:', stepNumber);
// Validate step number
if (stepNumber < 1 || stepNumber > 3) {
console.warn('Invalid step number:', stepNumber);
return;
}
// If skipping step 2
if (window.skipAddOnsStep) {
if (stepNumber === 2) stepNumber = 3;
if (stepNumber === 0) stepNumber = 1;
}
// Validate ticket selection when moving to step 2 or 3
if (stepNumber === 2 || stepNumber === 3) {
let totalTickets = 0;
const quantityInputs = document.querySelectorAll('.quantity-value');
quantityInputs.forEach(input => {
totalTickets += parseInt(input.value) || 0;
});
if (totalTickets === 0) {
showSpeechBubble('Please select at least one ticket before proceeding');
return;
}
}
// Hide all steps first
const steps = document.querySelectorAll('.step_1, .step_2, .step_3');
steps.forEach(step => {
step.classList.remove('active');
console.log('Hiding step:', step.className);
});
// Show selected step
const selectedStep = document.querySelector(`.step_${stepNumber}`);
if (selectedStep) {
// Force a reflow to ensure the transition works
selectedStep.offsetHeight;
selectedStep.classList.add('active');
console.log('Showing step:', selectedStep.className);
// Initialize Stripe when reaching Step 3
if (stepNumber === 3) {
console.log('Reached Step 3, initializing Stripe…');
initializeStripe();
}
} else {
console.error(`Step ${stepNumber} element not found`);
return;
}
// Update step indicator
const stepIndicators = document.querySelectorAll('.step-indicator');
stepIndicators.forEach(indicator => {
indicator.textContent = `Step ${stepNumber} of 3`;
});
// Update navigation buttons
updateNavigationButtons(stepNumber);
// Scroll to top of the form
const form = document.getElementById('event-order-form');
if (form) {
form.scrollIntoView({ behavior: 'smooth' });
}
}
function updateNavigationButtons(currentStep) {
console.log('Updating navigation buttons for step:', currentStep);
// Update sticky navigation
const stickyPrev = document.querySelector('.sticky-cart-bar .prev-step');
const stickyNext = document.querySelector('.sticky-cart-bar .next-step');
const buyNowBtn = document.querySelector('.buy-now-btn');
if (stickyPrev) {
stickyPrev.style.display = 'block';
if (currentStep === 1) {
stickyPrev.innerHTML = '';
stickyPrev.onclick = function(event) {
event.preventDefault();
window.location.href = '/index.html';
};
} else {
stickyPrev.innerHTML = '';
stickyPrev.onclick = function(event) {
event.preventDefault();
goToStep(currentStep – 1);
};
}
}
if (stickyNext) {
if (currentStep === 3) {
// Hide the next button in sticky nav on step 3 but maintain space
stickyNext.style.visibility = 'hidden';
stickyNext.classList.add('hide-on-step3');
} else {
stickyNext.style.visibility = 'visible';
stickyNext.classList.remove('hide-on-step3');
stickyNext.innerHTML = '';
stickyNext.classList.remove('complete-purchase');
stickyNext.onclick = function(event) {
event.preventDefault();
goToStep(currentStep + 1);
};
}
}
// Show/hide buy now button
if (buyNowBtn) {
if (currentStep === 3) {
buyNowBtn.style.display = 'block';
buyNowBtn.onclick = function(event) {
event.preventDefault();
handleFormSubmit(event);
};
} else {
buyNowBtn.style.display = 'none';
}
}
}
// Initialize step navigation when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM Content Loaded – Initializing step navigation…');
// Ensure Step 1 is active by default
const step1 = document.querySelector('.step_1');
const step2 = document.querySelector('.step_2');
const step3 = document.querySelector('.step_3');
if (step1) {
step1.classList.add('active');
console.log('Initialized Step 1 as active');
}
if (step2) {
step2.classList.remove('active');
console.log('Initialized Step 2 as hidden');
}
if (step3) {
step3.classList.remove('active');
console.log('Initialized Step 3 as hidden');
}
// Add click handlers to step navigation buttons
const nextButtons = document.querySelectorAll('.next-step');
const prevButtons = document.querySelectorAll('.prev-step');
nextButtons.forEach(button => {
button.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation(); // Prevent event bubbling
// Find the current active step
const activeStep = document.querySelector('.step_1.active, .step_2.active, .step_3.active');
if (!activeStep) {
console.error('No active step found');
return;
}
const currentStepNumber = parseInt(activeStep.className.match(/step_(d+)/)?.[1] || '1');
console.log('Current step:', currentStepNumber);
// Validate ticket selection before proceeding
if (currentStepNumber === 1) {
let totalTickets = 0;
const quantityInputs = document.querySelectorAll('.quantity-value');
quantityInputs.forEach(input => {
totalTickets += parseInt(input.value) || 0;
});
if (totalTickets === 0) {
showSpeechBubble('Please select at least one ticket before proceeding');
return;
}
}
if (currentStepNumber < 3) {
// Add a small delay to prevent multiple transitions
setTimeout(() => {
goToStep(currentStepNumber + 1);
}, 100);
}
});
});
prevButtons.forEach(button => {
button.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation(); // Prevent event bubbling
// Find the current active step
const activeStep = document.querySelector('.step_1.active, .step_2.active, .step_3.active');
if (!activeStep) {
console.error('No active step found');
return;
}
const currentStepNumber = parseInt(activeStep.className.match(/step_(d+)/)?.[1] || '1');
console.log('Current step:', currentStepNumber);
if (currentStepNumber > 1) {
// If skipping step 2, go directly to step 1 from step 3
let targetStep = currentStepNumber – 1;
if (window.skipAddOnsStep && currentStepNumber === 3) {
targetStep = 1;
}
setTimeout(() => {
goToStep(targetStep);
}, 100);
}
});
});
// Initialize navigation buttons for the first step
updateNavigationButtons(1);
});
// Stripe Integration
let stripe;
let elements;
let stripeInitialized = false;
async function initializeStripe() {
if (stripeInitialized) {
console.log('Stripe already initialized');
return;
}
console.log('Initializing Stripe with Express Checkout…');
try {
// Initialize Stripe with your publishable key
stripe = Stripe('pk_test_516RiT9I5CeFyorHKnzzqo6sPzB0RTZNygaiV21fv7q1qXgktWeZjHre1SqGpYSAMJtuGmtHh1I48Dz1G5IQLYvmQ00Nl16m7Oo', {
apiVersion: '2023-10-16',
stripeAccount: 'acct_1C7mK9IQtQgABICR'
});
// Create elements with appearance configuration
elements = stripe.elements({
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#ed1e26',
colorBackground: '#ffffff',
colorText: '#30313d',
colorDanger: '#df1b41',
fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
spacingUnit: '4px',
borderRadius: '10px'
}
}
});
// Add custom CSS for larger payment buttons
const style = document.createElement('style');
style.textContent = `
.GooglePayButton {
border: 0 !important;
cursor: pointer !important;
max-height: 84px !important;
min-height: 52px !important;
position: relative !important;
}
.ApplePayButton {
border: 0 !important;
cursor: pointer !important;
max-height: 84px !important;
min-height: 52px !important;
position: relative !important;
}
#payment-request-button {
max-height: 84px !important;
min-height: 52px !important;
}
#payment-request-button button {
max-height: 84px !important;
min-height: 52px !important;
transform: scale(1.2) !important;
transform-origin: center !important;
}
`;
document.head.appendChild(style);
// Create and mount card elements with improved styling
const cardNumber = elements.create('cardNumber', {
placeholder: 'Card number',
style: {
base: {
fontSize: '20px',
color: '#30313d',
lineHeight: '50px',
padding: '0 15px',
'::placeholder': {
color: '#aab7c4'
}
}
}
});
// Ensure card number element is properly mounted
const cardNumberContainer = document.getElementById('card-number');
if (cardNumberContainer) {
// Clear any existing content
cardNumberContainer.innerHTML = '';
cardNumber.mount('#card-number');
console.log('Card number element mounted successfully');
} else {
console.error('Card number container not found');
}
const cardExpiry = elements.create('cardExpiry', {
placeholder: 'MM/YY',
style: {
base: {
fontSize: '20px',
color: '#30313d',
lineHeight: '50px',
padding: '0 15px',
'::placeholder': {
color: '#aab7c4'
}
}
}
});
// Ensure card expiry element is properly mounted
const cardExpiryContainer = document.getElementById('card-expiry');
if (cardExpiryContainer) {
// Clear any existing content
cardExpiryContainer.innerHTML = '';
cardExpiry.mount('#card-expiry');
console.log('Card expiry element mounted successfully');
} else {
console.error('Card expiry container not found');
}
const cardCvc = elements.create('cardCvc', {
placeholder: 'CVC',
style: {
base: {
fontSize: '20px',
color: '#30313d',
lineHeight: '50px',
padding: '0 15px',
'::placeholder': {
color: '#aab7c4'
}
}
}
});
// Ensure card CVC element is properly mounted
const cardCvcContainer = document.getElementById('card-cvc');
if (cardCvcContainer) {
// Clear any existing content
cardCvcContainer.innerHTML = '';
cardCvc.mount('#card-cvc');
console.log('Card CVC element mounted successfully');
} else {
console.error('Card CVC container not found');
}
// Add event listeners to card elements for debugging
cardNumber.on('ready', () => {
console.log('Card number element ready');
// Fix accessibility warnings for hidden Stripe inputs
fixStripeAccessibilityWarnings();
});
cardNumber.on('focus', () => {
console.log('Card number focused');
});
cardNumber.on('blur', () => {
console.log('Card number blurred');
});
// Function to fix Stripe accessibility warnings
function fixStripeAccessibilityWarnings() {
// Remove aria-hidden from Stripe's hidden inputs that are focused
const hiddenInputs = document.querySelectorAll('.__PrivateStripeElement-input[aria-hidden="true"]');
hiddenInputs.forEach(input => {
// Remove aria-hidden attribute to prevent accessibility warnings
input.removeAttribute('aria-hidden');
// Ensure they're properly hidden with CSS instead
input.style.position = 'absolute';
input.style.left = '-9999px';
input.style.top = '-9999px';
input.style.width = '1px';
input.style.height = '1px';
input.style.opacity = '0';
input.style.pointerEvents = 'none';
// Prevent focus
input.setAttribute('tabindex', '-1');
input.style.pointerEvents = 'none';
});
}
// Set up MutationObserver to catch dynamically created Stripe elements
function setupStripeAccessibilityObserver() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the added node is a Stripe hidden input
if (node.classList && node.classList.contains('__PrivateStripeElement-input')) {
if (node.getAttribute('aria-hidden') === 'true') {
node.removeAttribute('aria-hidden');
node.style.position = 'absolute';
node.style.left = '-9999px';
node.style.top = '-9999px';
node.style.width = '1px';
node.style.height = '1px';
node.style.opacity = '0';
node.style.pointerEvents = 'none';
}
}
// Also check child elements
const hiddenInputs = node.querySelectorAll && node.querySelectorAll('.__PrivateStripeElement-input[aria-hidden="true"]');
if (hiddenInputs) {
hiddenInputs.forEach(input => {
input.removeAttribute('aria-hidden');
input.style.position = 'absolute';
input.style.left = '-9999px';
input.style.top = '-9999px';
input.style.width = '1px';
input.style.height = '1px';
input.style.opacity = '0';
input.style.pointerEvents = 'none';
});
}
}
});
}
});
});
// Start observing the document body for changes
observer.observe(document.body, {
childList: true,
subtree: true
});
return observer;
}
// — Stripe Express Checkout Element (New Implementation) —
// Calculate the total for Express Checkout
function getTotalAmountForExpressCheckout() {
let subtotal = 0;
orderData.tickets.forEach(ticket => {
if (ticket.quantity > 0) {
subtotal += ticket.quantity * ticket.price;
}
});
let addOnsTotal = 0;
orderData.addOns.forEach(addon => {
addOnsTotal += addon.quantity * addon.price;
});
let totalAmount = subtotal + addOnsTotal;
// Apply voucher discount if present
const successVoucher = document.querySelector('.success-voucher');
if (successVoucher && successVoucher.style.display === 'block') {
const voucherText = successVoucher.textContent;
const discountMatch = voucherText.match(/-(d+)(%|€)/);
if (discountMatch) {
const discountValue = parseFloat(discountMatch[1]);
const discountType = discountMatch[2];
if (discountType === '%') {
totalAmount = subtotal * (1 – discountValue / 100) + addOnsTotal;
} else {
totalAmount = Math.max(0, subtotal – discountValue) + addOnsTotal;
}
}
}
return Math.round(totalAmount * 100); // in cents
}
// Create Payment Request Button (legacy API – works without clientSecret)
const paymentRequestAmount = getTotalAmountForExpressCheckout();
console.log('Creating Payment Request Button with amount:', paymentRequestAmount);
// Create payment request
const paymentRequest = stripe.paymentRequest({
country: 'IE',
currency: 'eur',
total: {
label: 'Fossetts Circus Tickets',
amount: paymentRequestAmount,
},
requestPayerName: true,
requestPayerEmail: true,
requestPayerPhone: true,
});
// Create payment request button
let paymentRequestButton;
try {
console.log('Creating payment request button…');
paymentRequestButton = elements.create('paymentRequestButton', {
paymentRequest: paymentRequest,
style: {
paymentRequestButton: {
type: 'default',
theme: 'dark',
height: '44px'
}
}
});
console.log('Payment Request Button created successfully:', paymentRequestButton);
} catch (error) {
console.error('Error creating Payment Request Button:', error);
console.error('Error details:', {
message: error.message,
stack: error.stack,
name: error.name
});
return;
}
// Check if payment request is supported
paymentRequest.canMakePayment().then(function(result) {
console.log('Payment Request canMakePayment result:', result);
// TEMPORARY: Always hide Google Pay and Apple Pay button for debugging
const paymentRequestContainer = document.getElementById('payment-request-button');
if (paymentRequestContainer) {
paymentRequestContainer.style.display = 'none';
}
return; // Prevent any further logic from showing the button
// — original logic below (commented out) —
/*
if (result) {
console.log('Payment Request is supported, mounting button…');
const paymentRequestContainer = document.getElementById('payment-request-button');
if (paymentRequestContainer && paymentRequestAmount > 0) {
paymentRequestContainer.innerHTML = '';
paymentRequestContainer.style.display = 'block';
try {
paymentRequestButton.mount('#payment-request-button');
console.log('Payment Request Button mounted successfully');
} catch (error) {
console.error('Error mounting Payment Request Button:', error);
}
} else {
console.log('Payment Request container not found or amount is 0');
if (paymentRequestContainer) {
paymentRequestContainer.style.display = 'none';
}
}
} else {
console.log('Payment Request is not supported');
const paymentRequestContainer = document.getElementById('payment-request-button');
if (paymentRequestContainer) {
paymentRequestContainer.style.display = 'none';
}
}
*/
});
// Handle payment request events
paymentRequest.on('paymentmethod', async function(ev) {
console.log('Payment Request paymentmethod event:', ev);
try {
// Prepare form data
const form = document.getElementById('event-order-form');
const formData = new FormData(form);
// Add wheelchair user count
const wheelchairUsers = getWheelchairUserData();
if (wheelchairUsers > 0) {
formData.append('wheelchair_users', wheelchairUsers);
}
// Add payment token (backend expects payment_token)
formData.append('payment_token', ev.paymentMethod.id);
// Add event and date ids from URL parameters
const urlParams = getUrlParams();
const eventId = urlParams.event_id;
const eventDateId = urlParams.event_date_id;
// Add event and date ids to form data
formData.append('event_id', eventId);
formData.append('event_date_id', eventDateId);
// Add customer information from Payment Request
formData.append('customer_name', ev.payerName || '');
formData.append('customer_email', ev.payerEmail || '');
formData.append('customer_confirm_email', ev.payerEmail || '');
formData.append('customer_phone', ev.payerPhone || '');
// Collect ticket quantities (same as regular form submission)
console.log('Collecting ticket quantities for Payment Request…');
const sectionQuantities = {};
const quantityInputs = document.querySelectorAll('.quantity-value[data-section][data-ticket-type]');
quantityInputs.forEach(input => {
const section = input.dataset.section;
const ticketType = input.dataset.ticketType;
const quantity = parseInt(input.value) || 0;
if (!sectionQuantities[section]) {
sectionQuantities[section] = {};
}
if (ticketType && quantity > 0) {
sectionQuantities[section][ticketType.toLowerCase()] = quantity;
}
console.log('Processing ticket input:', {
section,
ticketType,
quantity,
value: input.value
});
});
console.log('Section quantities:', sectionQuantities);
// Send all ticket types dynamically with seating area information
Object.entries(sectionQuantities).forEach(([section, sectionData]) => {
Object.entries(sectionData).forEach(([ticketType, quantity]) => {
// Convert spaces to underscores in the ticket type name for the form field
// The server expects: event_seat_{seating_area}_{ticket_type_with_underscores}
const cleanTicketType = ticketType.replace(/s+/g, '_');
formData.append(`event_seat_${section}_${cleanTicketType}`, quantity);
console.log('Added form field:', `event_seat_${section}_${cleanTicketType}`, '=', quantity);
console.log('Ticket type details:', {
section: section,
originalTicketType: ticketType,
cleanTicketType: cleanTicketType,
quantity: quantity,
fieldName: `event_seat_${section}_${cleanTicketType}`
});
});
});
// Add add-ons
console.log('Collecting add-ons for Payment Request…');
const addonInputs = document.querySelectorAll('.addon-list-item .quantity-value');
console.log('Found addon inputs:', addonInputs.length);
addonInputs.forEach(input => {
const addonId = input.dataset.addonId;
const addonName = input.dataset.addonName;
const quantity = parseInt(input.value) || 0;
console.log('Processing addon input:', {
addonId,
addonName,
quantity,
value: input.value
});
if (addonId && quantity > 0) {
formData.append(`addon_${addonId}`, quantity);
console.log(`Added addon_${addonId} = ${quantity}`);
}
});
// Add voucher if present
const successVoucher = document.querySelector('.success-voucher');
if (successVoucher && successVoucher.style.display === 'block') {
const voucherText = successVoucher.textContent;
const voucherMatch = voucherText.match(/Voucher: ([A-Z0-9]+)/);
if (voucherMatch) {
formData.append('voucher_code', voucherMatch[1]);
}
}
// Submit order to server
const response = await fetch(`/order.php?event_id=${eventId}&event_date_id=${eventDateId}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
const result = await response.json();
if (result.success) {
// Complete the payment request successfully
ev.complete('success');
window.location.href = result.redirect_url;
} else {
// Complete the payment request with error
ev.complete('fail');
showSpeechBubble(result.message || 'Payment failed. Please try again.');
}
} catch (error) {
console.error('Error handling Payment Request payment:', error);
// Complete the payment request with error
ev.complete('fail');
showSpeechBubble('Payment processing failed. Please try again.');
}
});
paymentRequest.on('cancel', function() {
console.log('Payment Request cancelled');
});
// Update Payment Request amount when order changes
function updatePaymentRequestAmount() {
const newAmount = getTotalAmountForExpressCheckout();
paymentRequest.update({
total: {
label: 'Fossetts Circus Tickets',
amount: newAmount,
}
});
}
// Store the update function globally for use in order updates
window.updatePaymentRequestAmount = updatePaymentRequestAmount;
stripeInitialized = true;
console.log('Stripe Payment Request Button initialized successfully');
// Set up observer to catch dynamically created Stripe elements
const stripeObserver = setupStripeAccessibilityObserver();
// Suppress specific Stripe accessibility warnings
const originalWarn = console.warn;
console.warn = function(…args) {
const message = args.join(' ');
if (message.includes('aria-hidden') && message.includes('Stripe')) {
// Suppress Stripe aria-hidden warnings
return;
}
originalWarn.apply(console, args);
};
// Fix accessibility warnings after all elements are mounted
setTimeout(() => {
fixStripeAccessibilityWarnings();
}, 1000);
} catch (error) {
console.error('Error initializing Stripe Express Checkout:', error);
throw error;
}
}
// Initialize payment functionality when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM Content Loaded – Initializing payment…');
// Initialize Stripe
initializeStripe();
// Add form submit handler
const form = document.getElementById('event-order-form');
if (form) {
form.addEventListener('submit', handleFormSubmit);
}
// Add click handler for complete purchase button
const completePurchaseBtn = document.querySelector('.next-step.complete-purchase');
if (completePurchaseBtn) {
completePurchaseBtn.addEventListener('click', function(event) {
event.preventDefault();
handleFormSubmit(event);
});
}
});
async function handleFormSubmit(event) {
event.preventDefault();
console.log('Form submission started');
// Ensure order summary is up to date before checking total
updateOrderSummary();
// Recalculate total directly before checking minimum
let subtotal = 0;
orderData.tickets.forEach(ticket => {
if (ticket.quantity > 0) {
subtotal += ticket.quantity * ticket.price;
}
});
let addOnsTotal = 0;
orderData.addOns.forEach(addon => {
addOnsTotal += addon.quantity * addon.price;
});
let totalAmount = subtotal + addOnsTotal;
// Apply voucher discount if present
const successVoucher = document.querySelector('.success-voucher');
if (successVoucher && successVoucher.style.display === 'block') {
const voucherText = successVoucher.textContent;
const discountMatch = voucherText.match(/-(d+)(%|€)/);
if (discountMatch) {
const discountValue = parseFloat(discountMatch[1]);
const discountType = discountMatch[2];
if (discountType === '%') {
totalAmount = subtotal * (1 – discountValue / 100) + addOnsTotal;
} else {
totalAmount = Math.max(0, subtotal – discountValue) + addOnsTotal;
}
}
}
// Log detailed payment calculation
console.log('Payment calculation details:', {
ticketSubtotal: subtotal,
addOnsTotal: addOnsTotal,
totalBeforeVoucher: subtotal + addOnsTotal,
totalAfterVoucher: totalAmount,
ticketDetails: orderData.tickets.map(t => ({
type: t.type,
quantity: t.quantity,
price: t.price,
subtotal: t.quantity * t.price
})),
addonDetails: orderData.addOns.map(a => ({
name: a.name,
quantity: a.quantity,
price: a.price,
subtotal: a.quantity * a.price
}))
});
// Validate minimum payment amount
if (totalAmount < 0.5) {
const errorMessage = `The minimum payment amount is €0.50. Current total: €${totalAmount.toFixed(2)}`;
console.error(errorMessage);
showSpeechBubble(errorMessage);
return;
}
// Validate form
const form = document.getElementById('event-order-form');
if (!form) {
console.error('Form not found');
return;
}
// Get form elements safely
const customerName = form.querySelector('[name="customer_name"]')?.value?.trim();
const customerPhone = form.querySelector('[name="customer_phone"]')?.value?.trim();
const email = document.getElementById('email_address')?.value?.trim();
const confirmEmail = document.getElementById('confirm_email_address')?.value?.trim();
// Validate required fields
if (!customerName) {
showSpeechBubble('Please enter your full name');
return;
}
if (!customerPhone) {
showSpeechBubble('Please enter your phone number');
return;
}
if (!email) {
showSpeechBubble('Please enter your email address');
return;
}
if (!confirmEmail) {
showSpeechBubble('Please confirm your email address');
return;
}
// Validate email match
if (email !== confirmEmail) {
showSpeechBubble('Email addresses do not match');
return;
}
// Validate email format
const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
if (!emailRegex.test(email)) {
showSpeechBubble('Please enter a valid email address');
return;
}
// Validate phone number (basic validation)
const phoneRegex = /^[ds+()-]{8,}$/;
if (!phoneRegex.test(customerPhone)) {
showSpeechBubble('Please enter a valid phone number');
return;
}
// Validate card details
if (!stripe || !elements) {
showSpeechBubble('Payment system not initialized. Please refresh the page.');
return;
}
const cardNumber = elements.getElement('cardNumber');
const cardExpiry = elements.getElement('cardExpiry');
const cardCvc = elements.getElement('cardCvc');
if (!cardNumber || !cardExpiry || !cardCvc) {
showSpeechBubble('Please enter all card details');
return;
}
// Get submit button (check both locations)
const submitButton = document.querySelector('.buy-now-btn') || document.querySelector('.next-step.complete-purchase');
if (!submitButton) {
console.error('Submit button not found');
showSpeechBubble('Error: Submit button not found. Please refresh the page.');
return;
}
// Disable submit button
submitButton.disabled = true;
submitButton.textContent = 'Processing…';
try {
// Get event IDs from URL
const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get('event_id');
const eventDateId = urlParams.get('event_date_id');
if (!eventId || !eventDateId) {
throw new Error('Missing event information');
}
// Create FormData object
const formData = new FormData();
// Add form data
formData.append('customer_name', customerName);
formData.append('customer_email', email);
formData.append('customer_confirm_email', confirmEmail);
formData.append('customer_phone', customerPhone);
formData.append('event_id', eventId);
formData.append('event_date_id', eventDateId);
// Sum ticket quantities across all sections – DYNAMIC APPROACH
// Instead of hardcoded mappings, we'll send all ticket types as they are
// Debug log for ticket collection
console.log('Collecting ticket quantities…');
// Collect all quantities by section and type
const sectionQuantities = {};
const quantityInputs = document.querySelectorAll('.quantity-value[data-section][data-ticket-type]');
quantityInputs.forEach(input => {
const section = input.dataset.section;
const ticketType = input.dataset.ticketType;
const quantity = parseInt(input.value) || 0;
if (!sectionQuantities[section]) {
sectionQuantities[section] = {};
}
if (ticketType && quantity > 0) {
// Store the original ticket type name as it appears in the database
sectionQuantities[section][ticketType] = quantity;
}
console.log('Processing ticket input:', {
section,
ticketType,
quantity,
value: input.value
});
});
// Log the collected ticket counts
console.log('Section quantities:', sectionQuantities);
// Send all ticket types dynamically with seating area information
Object.entries(sectionQuantities).forEach(([section, sectionData]) => {
Object.entries(sectionData).forEach(([ticketType, quantity]) => {
// Convert spaces to underscores in the ticket type name for the form field
// The server expects: event_seat_{seating_area}_{ticket_type_with_underscores}
const cleanTicketType = ticketType.replace(/s+/g, '_');
formData.append(`event_seat_${section}_${cleanTicketType}`, quantity);
console.log('Added form field:', `event_seat_${section}_${cleanTicketType}`, '=', quantity);
console.log('Ticket type details:', {
section: section,
originalTicketType: ticketType,
cleanTicketType: cleanTicketType,
quantity: quantity,
fieldName: `event_seat_${section}_${cleanTicketType}`
});
});
});
// Add add-ons
console.log('Collecting add-ons…');
const addonInputs = document.querySelectorAll('.addon-list-item .quantity-value');
console.log('Found addon inputs:', addonInputs.length);
addonInputs.forEach(input => {
const addonId = input.dataset.addonId;
const addonName = input.dataset.addonName;
const quantity = parseInt(input.value) || 0;
console.log('Processing addon input:', {
addonId,
addonName,
quantity,
value: input.value,
dataset: input.dataset
});
if (addonId && quantity > 0) {
formData.append(`addon_${addonId}`, quantity);
console.log(`Added addon_${addonId} = ${quantity}`);
}
});
// Add voucher if present
const successVoucher = document.querySelector('.success-voucher');
if (successVoucher && successVoucher.style.display === 'block') {
const voucherInput = document.querySelector('input[name="voucher"]');
if (voucherInput) {
formData.append('voucher', voucherInput.value.trim());
}
}
// Add wheelchair user count
const wheelchairUsers = getWheelchairUserData();
if (wheelchairUsers > 0) {
formData.append('wheelchair_users', wheelchairUsers);
}
console.log('Submitting form data:', {
eventId,
eventDateId,
customerName,
email,
customerPhone,
formData: Object.fromEntries(formData)
});
// Debug: Log all form data entries
console.log('All form data entries:');
for (let [key, value] of formData.entries()) {
console.log(`${key}: ${value}`);
}
// Create payment method using existing card elements
const { error: paymentMethodError, paymentMethod } = await stripe.createPaymentMethod({
type: 'card',
card: cardNumber,
billing_details: {
name: customerName,
email: email,
phone: customerPhone
}
});
if (paymentMethodError) {
throw new Error(paymentMethodError.message);
}
// Add payment token to form data
formData.append('payment_token', paymentMethod.id);
// Submit order to server
const response = await fetch(`/order.php?event_id=${eventId}&event_date_id=${eventDateId}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
console.log('Server response status:', response.status);
const responseText = await response.text();
console.log('Server response text:', responseText);
let result;
try {
result = JSON.parse(responseText);
console.log('Parsed server response:', result);
// Log detailed error information if present
if (!result.success) {
console.error('Server error details:', {
message: result.message,
status: result.status,
total: totalAmount,
formData: Object.fromEntries(formData)
});
}
} catch (e) {
console.error('Failed to parse server response:', e);
console.error('Raw response:', responseText);
throw new Error('Server returned an invalid response');
}
if (!result) {
throw new Error('Empty response from server');
}
if (result.error) {
throw new Error(result.error);
}
if (result.requires_action) {
// Handle 3D Secure authentication
const { error: confirmError } = await stripe.confirmCardPayment(
result.payment_intent_client_secret
);
if (confirmError) {
throw new Error(confirmError.message);
}
// Retry the order submission with the payment intent ID
formData.append('payment_intent_id', result.payment_intent_id);
const finalResponse = await fetch(`/order.php?event_id=${eventId}&event_date_id=${eventDateId}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
if (!finalResponse.ok) {
throw new Error(`Server error: ${finalResponse.status}`);
}
const finalResult = await finalResponse.json();
if (!finalResult.success) {
throw new Error(finalResult.message || 'Order processing failed');
}
// Redirect directly to the detailed success page
if (finalResult.redirect_url) {
window.location.href = finalResult.redirect_url;
} else {
// Fallback if no redirect URL
const successMessage = document.createElement('div');
successMessage.className = 'success-message';
successMessage.innerHTML = `
Order Successful!
Thank you for your purchase. Your order has been processed successfully.
Order Reference: ${finalResult.order_code || 'N/A'}
A confirmation email has been sent to ${email}
Return to Home
`;
// Replace form content with success message
const form = document.getElementById('event-order-form');
if (form) {
form.innerHTML = '';
form.appendChild(successMessage);
}
}
} else if (result.success) {
// Redirect directly to the detailed success page
if (result.redirect_url) {
window.location.href = result.redirect_url;
} else {
// Fallback if no redirect URL
const successMessage = document.createElement('div');
successMessage.className = 'success-message';
successMessage.innerHTML = `
Order Successful!
Thank you for your purchase. Your order has been processed successfully.
Order Reference: ${result.order_code || 'N/A'}
A confirmation email has been sent to ${email}
Return to Home
`;
// Replace form content with success message
const form = document.getElementById('event-order-form');
if (form) {
form.innerHTML = '';
form.appendChild(successMessage);
}
}
} else {
throw new Error(result.message || 'Order processing failed');
}
} catch (error) {
console.error('Payment error:', error);
showSpeechBubble(error.message || 'An error occurred during payment processing');
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = 'Complete Purchase';
}
}
}
function updateCartDisplay(ticketData, subtotal, addOnsTotal, total, shouldSave = true) {
console.log('updateCartDisplay called with:', { ticketData, subtotal, addOnsTotal, total, shouldSave });
console.log('orderData.addOns:', orderData.addOns);
// Generate cart items HTML
let cartItemsHTML = '';
// Add ticket items
if (Array.isArray(ticketData)) {
ticketData.forEach(ticket => {
// Format the section name (remove underscores and capitalize)
const sectionName = ticket.section
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
// Dynamic ticket type mapping – handle any ticket type
let typeLabel = ticket.type;
// Only apply mapping for legacy ticket types, keep new types as-is
const legacyTypeMapping = {
'FULL': 'Adult',
'CHILD': 'Child',
'SENIOR': 'Senior'
};
if (legacyTypeMapping[ticket.type]) {
typeLabel = legacyTypeMapping[ticket.type];
}
cartItemsHTML += `
${typeLabel} – ${sectionName}
€${ticket.price.toFixed(2)} each remove
x${ticket.quantity}
€${(ticket.price * ticket.quantity).toFixed(2)}
`;
});
}
// Add add-ons
if (Array.isArray(orderData.addOns)) {
console.log('Processing add-ons in updateCartDisplay:', orderData.addOns);
orderData.addOns.forEach(addon => {
if (addon.quantity > 0) {
const displayName = addon.label || addon.name;
console.log('Addon display name:', displayName, 'for addon:', addon);
cartItemsHTML += `
${displayName}
€${addon.price.toFixed(2)} each remove
x${addon.quantity}
€${(addon.price * addon.quantity).toFixed(2)}
`;
}
});
}
// Add voucher discount if present
const successVoucher = document.querySelector('.success-voucher');
if (successVoucher && successVoucher.style.display === 'block') {
const voucherText = successVoucher.textContent;
const voucherMatch = voucherText.match(/Voucher Applied: (w+)/);
const discountMatch = voucherText.match(/-(d+)(%|€)/);
if (voucherMatch && discountMatch) {
const voucherCode = voucherMatch[1];
const discountValue = parseFloat(discountMatch[1]);
const discountType = discountMatch[2];
let discountAmount = 0;
if (discountType === '%') {
// Apply discount only to ticket subtotal
discountAmount = subtotal * (discountValue / 100);
} else {
// For fixed amount, apply to ticket total only
discountAmount = Math.min(discountValue, subtotal);
}
cartItemsHTML += `
Voucher Applied: ${voucherCode}
Discount on tickets -${discountValue}${discountType} (remove)
-€${discountAmount.toFixed(2)}
`;
}
}
// Update all cart items containers
const cartItemsContainers = document.querySelectorAll('.cart-items');
cartItemsContainers.forEach(container => {
if (container) {
container.innerHTML = cartItemsHTML;
}
});
// Update the main order summary total
const summaryTotal = document.getElementById('summary-total-amount');
if (summaryTotal) {
summaryTotal.textContent = `€${total.toFixed(2)}`;
}
// Update the sticky cart bar total
const stickyCartTotal = document.querySelector('.sticky-cart-bar .cart-total-amount');
if (stickyCartTotal) {
stickyCartTotal.textContent = `€${total.toFixed(2)}`;
}
// Add click handlers for remove buttons
document.querySelectorAll('.remove-item').forEach(button => {
button.addEventListener('click', function() {
const type = this.dataset.type;
console.log('Remove button clicked:', { type, dataset: this.dataset });
if (type === 'ticket') {
const section = this.dataset.section;
const ticketType = this.dataset.ticketType;
console.log('Attempting to remove ticket:', { section, ticketType });
// Try to find the quantity input with the exact ticket type
let quantityInput = document.querySelector(`.quantity-value[data-section="${section}"][data-ticket-type="${ticketType}"]`);
// If not found, try case-insensitive search
if (!quantityInput) {
const allQuantityInputs = document.querySelectorAll('.quantity-value[data-section="' + section + '"]');
console.log('All quantity inputs for section', section, ':', allQuantityInputs);
// Try to find by exact match first, then case-insensitive
for (let input of allQuantityInputs) {
const inputTicketType = input.dataset.ticketType;
if (inputTicketType === ticketType ||
(inputTicketType && inputTicketType.toLowerCase() === ticketType.toLowerCase())) {
quantityInput = input;
console.log('Found quantity input:', inputTicketType);
break;
}
}
}
if (quantityInput) {
console.log('Found quantity input, setting to 0:', quantityInput);
quantityInput.value = '0';
updateTicketQuantity(section, ticketType, parseFloat(quantityInput.dataset.price), 0);
} else {
console.error('Could not find quantity input for ticket removal:', { section, ticketType });
// Fallback: manually remove from orderData
const ticketIndex = orderData.tickets.findIndex(t =>
t.section === section &&
(t.type === ticketType || t.type.toLowerCase() === ticketType.toLowerCase())
);
if (ticketIndex !== -1) {
console.log('Removing ticket from orderData directly:', orderData.tickets[ticketIndex]);
orderData.tickets.splice(ticketIndex, 1);
updateOrderSummary();
} else {
console.error('Could not find ticket in orderData to remove');
// Force refresh the cart display
updateOrderSummary();
}
}
} else if (type === 'addon') {
const name = this.dataset.name;
const quantityInput = document.querySelector(`.add-on-item .quantity-value[data-addon-name="${name}"]`);
if (quantityInput) {
quantityInput.value = '0';
updateAddonQuantity(name, parseFloat(quantityInput.dataset.price), 0);
}
} else if (type === 'voucher') {
// Clear voucher
const voucherInput = document.querySelector('input[name="voucher"]');
const warningVoucher = document.querySelector('.warning-voucher');
const successVoucher = document.querySelector('.success-voucher');
if (voucherInput) voucherInput.value = '';
if (warningVoucher) warningVoucher.style.display = 'none';
if (successVoucher) successVoucher.style.display = 'none';
updateOrderSummary();
}
});
});
// Only save order data if shouldSave is true
if (shouldSave) {
saveOrderData({
tickets: ticketData || [],
addOns: orderData.addOns || [],
total: total
});
}
}
function addRemoveListenersToSummary() {
document.querySelectorAll('.remove-item').forEach(el => {
el.addEventListener('click', function() {
const type = this.getAttribute('data-type');
const name = this.getAttribute('data-name');
if (type === 'addon') {
updateAddonQuantity(name, 0, 0);
// Update the UI input if present
const input = document.querySelector(`.quantity-value[data-addon-name="${name}"]`);
if (input) input.value = 0;
} else if (type === 'ticket') {
const section = this.getAttribute('data-section');
const ticketType = this.getAttribute('data-ticket-type');
const input = document.querySelector(`.quantity-value[data-section="${section}"][data-ticket-type="${ticketType.toLowerCase()}"]`);
if (input) {
input.value = 0;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
});
});
}
function getUrlParams() {
const params = new URLSearchParams(window.location.search);
return {
event_id: params.get('event_id'),
event_date_id: params.get('event_date_id')
};
}
async function fetchEventData(event_id, event_date_id) {
console.log('Starting fetchEventData…');
console.log('URL parameters:', { event_id, event_date_id });
try {
const url = `/api/events.php?event_id=${event_id}&event_date_id=${event_date_id}`;
console.log('Making request to:', url);
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
cache: 'no-cache'
});
console.log('Response status:', response.status);
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log('API response:', data);
if (data.error) {
throw new Error(data.error);
}
// Find the specific event date
const eventDate = data.event_dates.find(date => date.event_date_id === parseInt(event_date_id));
console.log('Found event date:', eventDate);
if (!eventDate) {
throw new Error('Event date not found');
}
// Check if showtime is active
if (eventDate.showtime_status !== 'active') {
throw new Error('This showtime is not available for booking');
}
// Check if booking is closed (within 30 minutes of showtime)
if (eventDate.booking_closed) {
// Calculate minutes until showtime
const showtime = new Date(eventDate.event_datetime);
const now = new Date();
const timeDiff = showtime – now;
const minutesUntilShow = Math.floor(timeDiff / (1000 * 60));
// Show online closed message with countdown
const onlineClosedMessage = `ONLINE CLOSED – Starts in ${minutesUntilShow} min`;
// Update the page to show online closed message
document.body.innerHTML = `
${onlineClosedMessage}
Go to Boxoffice
Return to Home
`;
return; // Stop further processing
}
// Format the date string
const dateString = new Date(eventDate.event_datetime).toLocaleString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
console.log('Formatted date string:', dateString);
// Store event date string globally
selectedEventDateString = dateString;
// Update venue info in DOM
const venueTitle = document.getElementById('venue-title');
const venueFull = document.getElementById('venue-full');
const eventDateElement = document.getElementById('event-date');
const summaryEventDate = document.getElementById('summary-event-date');
if (venueTitle) venueTitle.textContent = data.event.venue_title;
if (venueFull) venueFull.textContent = data.event.venue_full;
if (eventDateElement) eventDateElement.textContent = dateString;
if (summaryEventDate) summaryEventDate.textContent = dateString;
// Update order info block
const orderInfoBlock = document.querySelector('.order-info-block');
if (orderInfoBlock) {
orderInfoBlock.innerHTML = `
Order Information
${dateString}
${data.event.venue_title}
${data.event.venue_full}
`;
}
// Fetch dynamic add-ons
let addOns = [];
try {
const addonsResponse = await fetch('/boxoffice/api/addons', {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
cache: 'no-cache'
});
if (addonsResponse.ok) {
const contentType = addonsResponse.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const addonsData = await addonsResponse.json();
addOns = addonsData.map(addon => ({
id: addon.id,
name: addon.name.toLowerCase().replace(/s+/g, ''),
label: addon.name,
description: addon.description || '',
price: parseFloat(addon.price),
img: addon.thumb || '/elements/fossetts-popcorn.jpg'
}));
}
}
} catch (e) {
console.warn('Error fetching add-ons:', e);
}
// Store add-ons data globally for label lookup
globalAddOnsData = addOns;
console.log('Global add-ons data stored:', globalAddOnsData);
// Process the new data structure with capacities and ticket_definitions
const tickets = [];
// Group ticket definitions by seating area
const ticketGroups = {};
eventDate.ticket_definitions.forEach(ticketDef => {
if (!ticketGroups[ticketDef.seating_area]) {
ticketGroups[ticketDef.seating_area] = [];
}
ticketGroups[ticketDef.seating_area].push(ticketDef);
});
// Create ticket sections for each seating area
Object.keys(ticketGroups).forEach(seatingArea => {
const ticketDefs = ticketGroups[seatingArea];
const capacity = eventDate.capacities.find(cap => cap.seating_area === seatingArea);
console.log('Processing seating area:', seatingArea);
console.log('Ticket definitions for this area:', ticketDefs);
tickets.push({
title: seatingArea,
css_class: seatingArea.toLowerCase().replace(/s+/g, '-'),
seating_type: 'Non allocated seating',
capacity: capacity ? capacity.capacity : 0,
ticket_types: ticketDefs.map(ticketDef => {
console.log('Processing ticket definition:', ticketDef);
return {
label: ticketDef.name,
price: parseFloat(ticketDef.price),
select_name: `event_seat_${ticketDef.name.toLowerCase().replace(/s+/g, '_')}`,
seats_per_ticket: ticketDef.seats_per_ticket || 1
};
})
});
});
console.log('Processed tickets:', tickets);
return {
venue_title: data.event.venue_title || '',
venue_full: data.event.venue_full || '',
event_date_string: dateString,
tickets: tickets,
add_ons: addOns
};
} catch (error) {
console.error('Error in fetchEventData:', error);
throw error;
}
}
function createTicketType(ticket) {
const ticketType = document.createElement('div');
ticketType.className = 'ticket-type';
ticketType.innerHTML = `
${ticket.ticket_type}
–
0
+
`;
// Add event listeners for plus and minus buttons
const minusBtn = ticketType.querySelector('.minus');
const plusBtn = ticketType.querySelector('.plus');
const quantityValue = ticketType.querySelector('.quantity-value');
minusBtn.addEventListener('click', () => {
const currentValue = parseInt(quantityValue.textContent);
if (currentValue > 0) {
quantityValue.textContent = currentValue – 1;
updateTicketQuantity(ticket.ticket_id, currentValue – 1);
}
});
plusBtn.addEventListener('click', () => {
const currentValue = parseInt(quantityValue.textContent);
if (currentValue < ticket.seats_left) {
quantityValue.textContent = currentValue + 1;
updateTicketQuantity(ticket.ticket_id, currentValue + 1);
}
});
return ticketType;
}
function updateTicketQuantity(section, ticketType, price, quantity) {
// Normalize ticket type to uppercase for consistency, but preserve original for display
const normalizedTicketType = ticketType.toUpperCase();
// Find or create ticket in orderData
const ticketIndex = orderData.tickets.findIndex(t =>
t.section === section && t.type === normalizedTicketType
);
if (ticketIndex === -1) {
// Add new ticket
orderData.tickets.push({
section: section,
type: normalizedTicketType,
price: price,
quantity: quantity
});
} else {
// Update existing ticket
orderData.tickets[ticketIndex].quantity = quantity;
}
console.log('Updated ticket quantity:', {
section: section,
ticketType: ticketType,
normalizedType: normalizedTicketType,
price: price,
quantity: quantity,
orderData: orderData.tickets
});
// Update cart display
updateOrderSummary();
}
## The important fixes
1) **Don’t uppercase ticket types.**
You currently normalize to `UPPERCASE` in `updateTicketQuantity`, but elsewhere you look them up with `.toLowerCase()` and via dataset values. This mismatch breaks remove‑buttons and DOM lookups.
2) **Always store the computed total.**
`updateOrderSummary()` calculates `total` but doesn’t persist it to `orderData.total`, which can leave stale totals for Express Checkout or summary UIs.
3) **Voucher regex should allow decimals.**
Use `([d.]+)` instead of `(d+)` so `-12.50%` or `-€2.5` work.
4) **Call the Payment Request amount updater after totals change.**
You already try, just make sure it runs *after* writing `orderData.total`.
—
## Drop‑in code changes
### A) `updateTicketQuantity` — keep the original label/casing
**Replace** your current function with this:
function updateTicketQuantity(section, ticketType, price, quantity) {
// Keep the original ticketType as-is (no uppercasing)
const keySection = section; // section already slugged (e.g., 'ringside')
const keyType = ticketType; // preserve original label for consistency
// Find or create ticket in orderData
const idx = orderData.tickets.findIndex(
t => t.section === keySection && t.type === keyType
);
if (idx === -1) {
orderData.tickets.push({
section: keySection,
type: keyType,
price: price,
quantity: quantity
});
} else {
orderData.tickets[idx].quantity = quantity;
}
// Reflect changes in UI + totals
updateOrderSummary();
}
> Why: your other code (remove buttons, selectors, formData building) uses the dataset ticket type strings directly and often lowercases them for lookups. Uppercasing here caused hard‑to‑spot mismatches.
—
### B) `updateOrderSummary` — persist total and support decimal discounts
**Patch** the function in two places:
1. **Allow decimals in voucher regex** (change both places where you parse discounts):
- const discountMatch = voucherText.match(/-(d+)(%|€)/);
+ const discountMatch = voucherText.match(/-([d.]+)(%|€)/);
2. **Persist total to orderData, then update Payment Request amount**:
- // Update cart display
+ // Update cart display
updateCartDisplay(ticketData, subtotal, addOnsTotal, total);
- saveOrderDataToStorage();
- addRemoveListenersToSummary();
-
- // Update Express Checkout amount if available
- if (window.updatePaymentRequestAmount) {
- window.updatePaymentRequestAmount();
- }
+ // Persist latest total for downstream consumers (summary, express checkout)
+ orderData.total = total;
+ saveOrderDataToStorage();
+ addRemoveListenersToSummary();
+ // Update Express Checkout amount if available (after setting orderData.total)
+ if (window.updatePaymentRequestAmount) {
+ window.updatePaymentRequestAmount();
+ }
—
### C) `updateCartDisplay` — remove tickets reliably
Because we stopped uppercasing, your existing remove logic mostly works. One tiny improvement: try exact match first and only then case‑insensitive.
**Replace the ticket remove search block** with:
// ... inside '.remove-item' click handler for type === 'ticket'
const section = this.dataset.section;
const ticketType = this.dataset.ticketType; // already lowercased in the HTML
// Try direct match on lowercased dataset
let quantityInput = document.querySelector(
`.quantity-value[data-section="${section}"][data-ticket-type="${ticketType}"]`
);
// Fallback: find case-insensitively if needed
if (!quantityInput) {
const allInputs = document.querySelectorAll(`.quantity-value[data-section="${section}"]`);
for (let input of allInputs) {
const inputType = (input.dataset.ticketType || '').toLowerCase();
if (inputType === ticketType) {
quantityInput = input;
break;
}
}
}
if (quantityInput) {
quantityInput.value = '0';
updateTicketQuantity(section, quantityInput.dataset.ticketType, parseFloat(quantityInput.dataset.price), 0);
} else {
// Fallback remove from orderData
const idx = orderData.tickets.findIndex(
t => t.section === section && (t.type.toLowerCase() === ticketType)
);
if (idx !== -1) {
orderData.tickets.splice(idx, 1);
}
updateOrderSummary();
}
*(If your existing remove works fine after fix A, you can skip this block; it’s just a sturdier version.)*
—
### D) Voucher parsing in two other places
You also parse the voucher in:
– `updateCartDisplay` (for the discount line)
– `handleFormSubmit` (for recomputing totals client‑side)
Update those regexes the same way:
- const discountMatch = voucherText.match(/-(d+)(%|€)/);
+ const discountMatch = voucherText.match(/-([d.]+)(%|€)/);
—
### E) (Optional) Make the ticket “key” explicit
If you want rock‑solid lookups, you can also store a normalized key alongside the human label:
– When building buttons/inputs in `populateTicketSections`, add:
– `data-ticket-key="${type.label.toLowerCase().replace(/s+/g,'_')}"`
– Then, in `updateTicketQuantity` / remove logic, use `ticketKey` for internal matching and keep `type` (the human label) for display. This isn’t strictly required after fix A, but it future‑proofs names with punctuation.
—
## That’s it
With these changes:
– Your front‑end won’t accidentally change ticket names (no more uppercase drift).
– Totals are consistently available (`orderData.total`) for Express Checkout and summaries.
– Voucher amounts accept decimals.
– Remove buttons and DOM lookups stay in sync with what you submit to the backend (`event_seat_{section}_{ticket_type_with_underscores}`).
Want me to produce a consolidated patched JS file with these edits applied inline to your current code?
Here’s a safe fix:
– Build your `intentParams` **first**.
– Canonicalize those params (sorted JSON) and hash them to produce the idempotency key.
– Pass the key **only** on `PaymentIntent::create` (never on `confirm`/`retrieve`).
– If you change *anything* (amount, voucher, add‑ons, pm id), the key will naturally change.
### Drop‑in code
/**
* Deterministically builds an idempotency key from the exact create params you will send.
* If any field differs (amount, currency, payment_method, application_fee_amount, customer, metadata, etc.)
* the key will differ automatically.
*/
function buildIdempotencyKeyFromParams(array $intentParams, string $accountId = ''): string
{
// Sort recursively for stable JSON
$sort = function (&$arr) use (&$sort) {
foreach ($arr as &$v) { if (is_array($v)) { $sort($v); } }
ksort($arr);
};
$copy = $intentParams;
$sort($copy);
$payload = json_encode([
'stripe_account' => $accountId,
'intent_params' => $copy,
], JSON_UNESCAPED_UNICODE);
return 'fossetts:' . hash('sha256', $payload);
}
Use it like this (replace your previous key logic):
$pmId = $_POST['payment_token']; // pm_...
$customerId = null;
// (Optional) reuse PM's existing customer on the connected account
try {
$pmObj = StripePaymentMethod::retrieve($pmId, ['stripe_account' => "acct_1C7mK9IQtQgABICR"]);
if (!empty($pmObj->customer)) { $customerId = $pmObj->customer; }
} catch (Exception $e) { /* non-fatal */ }
$intentParams = [
"amount" => $amount_in_cents, // integer cents
"currency" => $event_data->event['currency'],
"payment_method" => $pmId,
"confirm" => true,
"return_url" => "https://fossettscircus.com/success/",
"application_fee_amount" => $fee_in_cents, // integer cents
"description" => "Fossets charge for " . ($_POST['customer_email'] ?? ''),
"metadata" => ['Location' => $event_data->event['area']],
"automatic_payment_methods" => ["enabled" => true, "allow_redirects" => "never"],
];
if ($customerId) { $intentParams["customer"] = $customerId; }
// ✅ Build the key from the EXACT params you’re about to send
$idempotencyKey = buildIdempotencyKeyFromParams($intentParams, "acct_1C7mK9IQtQgABICR");
try {
$intent = StripePaymentIntent::create(
$intentParams,
[
"stripe_account" => "acct_1C7mK9IQtQgABICR",
"idempotency_key" => $idempotencyKey
]
);
} catch (StripeExceptionInvalidRequestException $e) {
// If a stale key was reused with different params, regenerate once and retry.
// (Guarded to avoid infinite retries.)
$msg = $e->getMessage();
if (strpos($msg, 'Keys for idempotent requests can only be used with the same parameters') !== false) {
// Regenerate with a tiny nonce to differentiate (still safe; we only do this on mismatch)
$intentParams['_nonce'] = bin2hex(random_bytes(6));
$idempotencyKey = buildIdempotencyKeyFromParams($intentParams, "acct_1C7mK9IQtQgABICR");
$intent = StripePaymentIntent::create(
$intentParams,
[
"stripe_account" => "acct_1C7mK9IQtQgABICR",
"idempotency_key" => $idempotencyKey
]
);
} else {
throw $e;
}
}
### Why this fixes your error
– Your previous key likely didn’t include **all** parameters (e.g., `amount_in_cents`, `application_fee_amount`, or changed PM id). When those changed but the key didn’t, Stripe flagged the mismatch.
– By deriving the key **from the sorted params themselves**, any change produces a new key automatically.
– The small retry path only triggers when Stripe detects you accidentally reused a key with different params (e.g., a cached key variable). It regenerates once and proceeds.
### Extra gotchas to avoid
– Only send an idempotency key on **create**. Do **not** use it on:
– `PaymentIntent::retrieve`
– `$intent->confirm()`
– Keep amounts as integers in cents everywhere when you build `intentParams`.
– If you add or remove metadata later, that also changes the key (as desired).
If you want, I can splice this directly into the earlier `if (isset($_POST['payment_token'])) { … }` block you’re using.
try {
$pmId = $_POST['payment_token']; // pm_XXXX from Elements
$idempotencyKey = buildIdempotencyKey($_POST, $event_id, $event_date_id);
// Try to reuse the PM's existing customer (on the connected account).
$customerId = null;
try {
$pmObj = StripePaymentMethod::retrieve(
$pmId,
['stripe_account' => "acct_1C7mK9IQtQgABICR"]
);
if (!empty($pmObj->customer)) {
$customerId = $pmObj->customer; // Reuse, don't re-attach
}
} catch (Exception $e) {
// Non-fatal; proceed without a customer
}
// Build PI params: no manual attach; confirm immediately
$intentParams = [
"amount" => $amount_in_cents, // make sure you computed with round(…*100)
"currency" => $event_data->event['currency'],
"payment_method" => $pmId,
"confirm" => true,
"return_url" => "https://staging.fossettscircus.com/success/",
"application_fee_amount" => $fee_in_cents, // make sure it's 5% in cents
"description" => "Fossets charge for " . ($_POST['customer_email'] ?? ''),
"metadata" => ['Location' => $event_data->event['area']],
"automatic_payment_methods" => [
"enabled" => true,
"allow_redirects" => "never"
]
];
// Only include customer if PM already belongs to one. Otherwise omit.
if ($customerId) {
$intentParams["customer"] = $customerId;
}
$intent = StripePaymentIntent::create(
$intentParams,
[
"stripe_account" => "acct_1C7mK9IQtQgABICR",
"idempotency_key" => $idempotencyKey
]
);
if ($intent->status === 'requires_action') {
echo json_encode([
'requires_action' => true,
'payment_intent_client_secret' => $intent->client_secret,
'payment_intent_id' => $intent->id,
'status' => 'requires_action'
]);
exit;
} elseif ($intent->status === 'succeeded') {
// Process add-ons for this section too
$addonString = processAddons($_POST);
// Debug logging for add-ons in second section
error_log('Add-on processing debug (second section):');
error_log('POST data: ' . print_r($_POST, true));
error_log('Generated addonString: "' . $addonString . '"');
$order_info = createOrderInfo(
$_POST,
$event_id,
$event_date_id,
$event_data,
$selected_event_date,
$ticket_quantities,
$ticket_data,
$addonString,
$voucher_data,
$amount_in_eur,
$intent
);
// (Optional) duplicate-order guard can go here if you added it
saveOrderAndSendEmails($order_info, $fossets_database, $event_date_id);
} else {
echo json_encode([
'success' => false,
'message' => 'Payment failed: ' . $intent->status,
'status' => $intent->status
]);
exit;
}
} catch (StripeExceptionCardException $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
'status' => 'card_error'
]);
exit;
} catch (Exception $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
'status' => 'error'
]);
exit;
}
}
give me update code in above
<?php
// Put this helper once (e.g., above your handler). Safe to define-guard.
if (!function_exists('buildIdempotencyKeyFromParams')) {
/**
* Build a deterministic idempotency key from the exact PI create params.
* Any param change (amount, pm, metadata, fees...) yields a different key.
*/
function buildIdempotencyKeyFromParams(array $intentParams, string $accountId = ''): string
{
$sort = function (&$arr) use (&$sort) {
foreach ($arr as &$v) { if (is_array($v)) { $sort($v); } }
ksort($arr);
};
$copy = $intentParams;
$sort($copy);
$payload = json_encode([
'stripe_account' => $accountId,
'intent_params' => $copy,
], JSON_UNESCAPED_UNICODE);
return 'fossetts:' . hash('sha256', $payload);
}
}
if (isset($_POST['payment_token'])) {
try {
$pmId = $_POST['payment_token']; // pm_XXXX from Elements (card)
$connected = "acct_1C7mK9IQtQgABICR";
// Try to reuse the PM's existing customer (on the connected account)
$customerId = null;
try {
$pmObj = StripePaymentMethod::retrieve($pmId, ['stripe_account' => $connected]);
if (!empty($pmObj->customer)) {
$customerId = $pmObj->customer; // Reuse; do NOT re-attach
}
} catch (Exception $e) {
// Non-fatal; proceed without a customer
}
// Build PI params (no manual attach; confirm immediately)
$intentParams = [
"amount" => (int)$amount_in_cents, // integer cents
"currency" => $event_data->event['currency'],
"payment_method" => $pmId,
"confirm" => true,
"return_url" => "https://staging.fossettscircus.com/success/",
"application_fee_amount" => (int)$fee_in_cents, // integer cents
"description" => "Fossets charge for " . ($_POST['customer_email'] ?? ''),
"metadata" => ['Location' => $event_data->event['area']],
"automatic_payment_methods" => [
"enabled" => true,
"allow_redirects" => "never"
]
];
if ($customerId) {
$intentParams["customer"] = $customerId;
}
// Idempotency key derived from the EXACT params
$idempotencyKey = buildIdempotencyKeyFromParams($intentParams, $connected);
// Create PI (with idempotency)
try {
$intent = StripePaymentIntent::create(
$intentParams,
[
"stripe_account" => $connected,
"idempotency_key" => $idempotencyKey
]
);
} catch (StripeExceptionInvalidRequestException $e) {
// Handle the "same key, different params" error by regenerating ONCE with a nonce
if (strpos($e->getMessage(), 'Keys for idempotent requests can only be used with the same parameters') !== false) {
$intentParams['_nonce'] = bin2hex(random_bytes(6));
$idempotencyKey = buildIdempotencyKeyFromParams($intentParams, $connected);
$intent = StripePaymentIntent::create(
$intentParams,
[
"stripe_account" => $connected,
"idempotency_key" => $idempotencyKey
]
);
} else {
throw $e;
}
}
if ($intent->status === 'requires_action') {
echo json_encode([
'requires_action' => true,
'payment_intent_client_secret' => $intent->client_secret,
'payment_intent_id' => $intent->id,
'status' => 'requires_action'
]);
exit;
} elseif ($intent->status === 'succeeded') {
// Process add-ons for this section too
$addonString = processAddons($_POST);
error_log('Add-on processing debug (second section):');
error_log('POST data: ' . print_r($_POST, true));
error_log('Generated addonString: "' . $addonString . '"');
$order_info = createOrderInfo(
$_POST,
$event_id,
$event_date_id,
$event_data,
$selected_event_date,
$ticket_quantities,
$ticket_data,
$addonString,
$voucher_data,
$amount_in_eur,
$intent
);
// Optional: duplicate-order guard before saving (fingerprint check)
// if ($fossets_database->orderExistsByFingerprint($order_info->order_fingerprint)) { ... }
saveOrderAndSendEmails($order_info, $fossets_database, $event_date_id);
} else {
echo json_encode([
'success' => false,
'message' => 'Payment failed: ' . $intent->status,
'status' => $intent->status
]);
exit;
}
} catch (StripeExceptionCardException $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
'status' => 'card_error'
]);
exit;
} catch (Exception $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage(),
'status' => 'error'
]);
exit;
}
}
**Notes**
– This removes manual `PaymentMethod::attach` (prevents the “already attached” error).
– The idempotency key now **tracks params**, so changing amount/PM/etc. won’t collide with a stale key.
– The retry path only kicks in for Stripe’s exact mismatch error and regenerates the key once with a nonce.