ghostguild-org/app/composables/useHelcimPay.js
Jennie Robinson Faber 8e76ce9366
Some checks failed
Test / vitest (push) Successful in 11m49s
Test / playwright (push) Failing after 9m43s
Test / visual (push) Failing after 9m24s
Test / Notify on failure (push) Successful in 2s
refactor(launch): collapse helcim-pay duplication and use setAuthCookie helper
Follow-up to 51230e5. /simplify review surfaced residual duplication
and a timer leak.

- useHelcimPay: extract _initializeTicket(metadata, errorPrefix) to
  collapse initializeTicketPayment + initializeSeriesTicketPayment
  (95% identical bodies). Drop the dead `amount` arg from initialize-
  TicketPayment — server re-derives ticket amounts in initialize-
  payment.post.js and never reads body.amount for ticket types.
  Capture timer ids and clearTimeout on resolve/reject so the 10-min
  payment timer and 5-second observer timer stop leaking after every
  payment.
- EventTicketPurchase: caller updated for the dropped arg.
- verify.post.js: replace inline jwt.sign + setCookie block with the
  setAuthCookie(event, member) helper. verify was the last hand-rolled
  caller after the helper was extracted in 208638e.
- LAUNCH_READINESS: add simplify-pass-followups bullet pointing to the
  six deferred items in docs/TODO.md.

Tests: 758 passing, 2 skipped, 0 failing.
2026-04-25 22:13:24 +01:00

284 lines
9.7 KiB
JavaScript

// HelcimPay.js integration composable
export const useHelcimPay = () => {
let checkoutToken = null;
let secretToken = null;
// Initialize HelcimPay.js session (membership signup flow)
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
try {
const response = await $fetch("/api/helcim/initialize-payment", {
method: "POST",
body: {
customerId,
customerCode,
amount,
metadata: { type: "membership_signup" },
},
});
if (response.success) {
checkoutToken = response.checkoutToken;
secretToken = response.secretToken;
return true;
}
throw new Error("Failed to initialize payment session");
} catch (error) {
console.error("Payment initialization error:", error);
throw error;
}
};
const _initializeTicket = async (metadata, errorPrefix) => {
try {
const response = await $fetch("/api/helcim/initialize-payment", {
method: "POST",
body: {
customerId: null,
customerCode: metadata.email,
metadata,
},
});
if (response.success) {
checkoutToken = response.checkoutToken;
secretToken = response.secretToken;
return {
success: true,
checkoutToken: response.checkoutToken,
amount: response.amount,
};
}
throw new Error(`Failed to initialize ${errorPrefix} session`);
} catch (error) {
console.error(`${errorPrefix} initialization error:`, error);
throw error;
}
};
const initializeTicketPayment = (eventId, email, eventTitle = null) =>
_initializeTicket(
{ type: "event_ticket", eventId, email, eventTitle },
"ticket payment",
);
const initializeSeriesTicketPayment = (seriesId, email, seriesTitle = null) =>
_initializeTicket(
{ type: "series_ticket", seriesId, email, eventTitle: seriesTitle },
"series payment",
);
// Show payment modal
const showPaymentModal = () => {
return new Promise((resolve, reject) => {
if (!checkoutToken) {
reject(
new Error("Payment not initialized. Call initializeHelcimPay first."),
);
return;
}
// Add CSS to style Helcim overlay - needs to target the actual elements Helcim creates
// Helcim injects a div with inline styles directly into body
if (!document.getElementById("helcim-overlay-fix")) {
const style = document.createElement("style");
style.id = "helcim-overlay-fix";
style.textContent = `
/* Style any fixed position div that Helcim creates */
body > div[style] {
background-color: rgba(0, 0, 0, 0.75) !important;
}
/* Target specifically iframes from Helcim */
body > div[style] > iframe[src*="helcim"],
body > div[style] iframe[src*="secure.helcim.app"] {
background: white !important;
border: none !important;
border-radius: 8px !important;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3) !important;
}
`;
document.head.appendChild(style);
}
// Load HelcimPay.js modal script
if (!window.appendHelcimPayIframe) {
console.log("HelcimPay script not loaded, loading now...");
const script = document.createElement("script");
script.src = "https://secure.helcim.app/helcim-pay/services/start.js";
script.async = true;
script.onload = () => {
console.log("HelcimPay script loaded successfully!");
console.log(
"Available functions:",
Object.keys(window).filter(
(key) => key.includes("Helcim") || key.includes("helcim"),
),
);
console.log(
"appendHelcimPayIframe available:",
typeof window.appendHelcimPayIframe,
);
openModal(resolve, reject);
};
script.onerror = () => {
reject(new Error("Failed to load HelcimPay.js"));
};
document.head.appendChild(script);
} else {
console.log("HelcimPay script already loaded, calling openModal");
openModal(resolve, reject);
}
});
};
// Open the payment modal
const openModal = (resolve, reject) => {
try {
console.log("Trying to open modal with checkoutToken:", checkoutToken);
if (typeof window.appendHelcimPayIframe === "function") {
// Set up event listener for HelcimPay.js responses
const helcimPayJsIdentifierKey = "helcim-pay-js-" + checkoutToken;
let observerTimer, paymentTimer;
const handleHelcimPayEvent = (event) => {
console.log("Received window message:", event.data);
if (event.data.eventName === helcimPayJsIdentifierKey) {
console.log("HelcimPay event received:", event.data);
// Remove event listener to prevent multiple responses
window.removeEventListener("message", handleHelcimPayEvent);
clearTimeout(observerTimer);
clearTimeout(paymentTimer);
// Close the Helcim modal
if (typeof window.removeHelcimPayIframe === "function") {
window.removeHelcimPayIframe();
}
if (event.data.eventStatus === "SUCCESS") {
console.log("Payment success:", event.data.eventMessage);
// Parse the JSON string eventMessage
let paymentData;
try {
paymentData = JSON.parse(event.data.eventMessage);
console.log("Parsed payment data:", paymentData);
} catch (parseError) {
console.error("Failed to parse eventMessage:", parseError);
reject(new Error("Invalid payment response format"));
return;
}
// Extract transaction details from nested data structure
const transactionData = paymentData.data?.data || {};
console.log("Transaction data:", transactionData);
resolve({
success: true,
transactionId: transactionData.transactionId,
cardToken: transactionData.cardToken,
cardLast4: transactionData.cardNumber
? transactionData.cardNumber.slice(-4)
: undefined,
cardType: transactionData.cardType || "unknown",
});
} else if (event.data.eventStatus === "ABORTED") {
console.log("Payment aborted:", event.data.eventMessage);
reject(new Error(event.data.eventMessage || "Payment failed"));
} else if (event.data.eventStatus === "HIDE") {
console.log("Modal closed without completion");
reject(new Error("Payment cancelled by user"));
}
}
};
// Add event listener
window.addEventListener("message", handleHelcimPayEvent);
// Set up a MutationObserver to fix Helcim's overlay styling immediately
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && node.tagName === "DIV") {
const computedStyle = window.getComputedStyle(node);
// Check if this is Helcim's overlay (fixed position, full screen)
if (
computedStyle.position === "fixed" &&
computedStyle.inset === "0px"
) {
// Fix the background to show semi-transparent overlay
node.style.setProperty(
"background-color",
"rgba(0, 0, 0, 0.75)",
"important",
);
// Ensure proper centering
node.style.setProperty("display", "flex", "important");
node.style.setProperty("align-items", "center", "important");
node.style.setProperty(
"justify-content",
"center",
"important",
);
observer.disconnect(); // Stop observing once we've found and fixed it
}
}
});
});
});
// Start observing body for child additions
observer.observe(document.body, { childList: true });
// Open the HelcimPay iframe modal
console.log("Calling appendHelcimPayIframe with token:", checkoutToken);
window.appendHelcimPayIframe(checkoutToken, true);
console.log(
"appendHelcimPayIframe called, waiting for window messages...",
);
// Clean up observer after a timeout
observerTimer = setTimeout(() => observer.disconnect(), 5000);
// Add timeout to clean up if no response (10 minutes for manual card entry)
paymentTimer = setTimeout(() => {
console.log("Payment timeout reached, cleaning up event listener...");
window.removeEventListener("message", handleHelcimPayEvent);
reject(new Error("Payment timeout - no response received"));
}, 600000);
} else {
reject(new Error("appendHelcimPayIframe function not available"));
}
} catch (error) {
console.error("Error opening modal:", error);
reject(error);
}
};
// Process payment verification
const verifyPayment = async () => {
try {
return await showPaymentModal();
} catch (error) {
throw error;
}
};
// Cleanup tokens
const cleanup = () => {
checkoutToken = null;
secretToken = null;
};
return {
initializeHelcimPay,
initializeTicketPayment,
initializeSeriesTicketPayment,
verifyPayment,
cleanup,
};
};