Code fix for double-post

User
<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

Assistant
Got it, Neeraj — your logs + code show a classic double‑POST + “re‑attaching a PaymentMethod that’s already attached” combo. Here’s a tight, safe fix you can drop in (test mode first), plus two small correctness tweaks.

# 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).

User
give me full updated code for this

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;
}
}

Assistant
Here’s a **drop‑in replacement** for your `if (isset($_POST['payment_token'])) { … }` block.
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;
    }
}
User
give me code of this function orderExistsByFingerprint
Assistant
Got it 👍 — here’s a simple **`orderExistsByFingerprint`** method you can drop into your `Fossets_db` class.
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?

User
Okay is there any changes needed in
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();
}

Assistant
Yes — a few safe JS tweaks will make the front‑end line up better with your new backend flow and avoid edge‑case bugs.

## 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?

User
Keys for idempotent requests can only be used with the same parameters they were first used with. Try using a key other than 'fossetts:9f9d6e983d073bb2f6474ce9ab44478b3063f8ebac1a9e8ade919d22cd169720' if you meant to execute a different request
Assistant
That Stripe message means you re‑used the **same idempotency key** for a **different** set of `PaymentIntent.create` parameters. Stripe caches the first request it sees for a key; any later request with that key must be byte‑for‑byte identical (amount, currency, PM, customer, metadata, fees… everything). If anything changed (user added an add‑on, voucher changed amount, different pm_ id, etc.), you must send a **new** idempotency key.

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.

User
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://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

Assistant
Here’s a **drop‑in replacement** for your block that builds the idempotency key from the **exact** `$intentParams` (so it changes automatically when amount/PM/etc. change) and safely retries once on the specific mismatch error.

<?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.

Leave a Comment

Your email address will not be published. Required fields are marked *