diff --git a/blocks/checkout/_checkout.json b/blocks/checkout/_checkout.json
new file mode 100644
index 0000000..13e7d2a
--- /dev/null
+++ b/blocks/checkout/_checkout.json
@@ -0,0 +1,71 @@
+{
+ "definitions": [
+ {
+ "title": "Checkout",
+ "id": "checkout",
+ "plugins": {
+ "xwalk": {
+ "page": {
+ "resourceType": "core/franklin/components/block/v1/block",
+ "template": {
+ "name": "Checkout",
+ "model": "checkout",
+ "key-value": true
+ }
+ }
+ }
+ }
+ }
+ ],
+ "models": [
+ {
+ "id": "checkout",
+ "fields": [
+ {
+ "component": "text",
+ "name": "buttoneventtype",
+ "label": "Button Event Type",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "text",
+ "name": "buttonwebhookurl",
+ "label": "Button Webhook URL",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "aem-content",
+ "name": "buttondata",
+ "label": "Button Data Sheet",
+ "valueType": "string",
+ "description": "AEM sheet path whose key/value rows are sent on submit"
+ },
+ {
+ "component": "text",
+ "name": "buttonformid",
+ "label": "Button Form ID",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "aem-content",
+ "valueType": "string",
+ "name": "confirmationpath",
+ "label": "Confirmation Page Path",
+ "description": "Page to redirect to after purchase confirmation. Defaults to /en/confirmation if not set."
+ },
+ {
+ "component": "text",
+ "name": "custom-class",
+ "label": "Custom class",
+ "value": "",
+ "valueType": "string",
+ "description": "Space-separated class names to add to the block"
+ }
+ ]
+ }
+ ],
+ "filters": []
+}
\ No newline at end of file
diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css
new file mode 100644
index 0000000..1233d31
--- /dev/null
+++ b/blocks/checkout/checkout.css
@@ -0,0 +1,365 @@
+/* Let the section container use full checkout width so no empty space on the right */
+main > .section:has(.checkout-block) > div {
+ max-width: 1400px;
+}
+
+/* Checkout block – Trip Summary layout (aligned with page, matches flights block width) */
+.checkout-block {
+ width: 100%;
+ max-width: 100%;
+ margin: 0 auto;
+ padding: 0 2rem 2rem;
+ font-family: var(--body-font-family, system-ui, sans-serif);
+ box-sizing: border-box;
+}
+
+/* Fill full width of block so no empty space on the right */
+.checkout-block .checkout-wrapper {
+ width: 100%;
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 2fr 1fr;
+ gap: 2.5rem;
+ align-items: start;
+ box-sizing: border-box;
+}
+
+@media (max-width: 900px) {
+ .checkout-block .checkout-wrapper {
+ grid-template-columns: 1fr;
+ }
+ .checkout-block {
+ padding: 0 1.5rem 2rem;
+ }
+ .checkout-header {
+ margin: 0 -1.5rem 0;
+ padding: 1rem 1.5rem;
+ }
+}
+
+/* Header – full width of content area */
+.checkout-header {
+ background: var(--link-color, #0563c1);
+ color: #fff;
+ margin: 0 -2rem 0;
+ padding: 1rem 2rem;
+ border-radius: 0;
+}
+
+.checkout-title {
+ margin: 0;
+ font-size: 1.5rem;
+ font-weight: 700;
+}
+
+/* Main column – min-width 0 so grid column can shrink and layout fills width */
+.checkout-main {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.checkout-section {
+ background: #fff;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ padding: 1.5rem 2rem;
+ text-align: left;
+}
+
+.checkout-section-title {
+ margin: 0 0 1rem 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #333;
+}
+
+.checkout-description {
+ margin: 0 0 1rem 0;
+ font-size: 0.875rem;
+ color: #666;
+ line-height: 1.5;
+}
+
+.checkout-description a {
+ color: var(--link-color, #0563c1);
+}
+
+/* Trip table */
+.checkout-trip-table {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.checkout-trip-header {
+ display: grid;
+ grid-template-columns: 1fr 70px 120px 48px;
+ gap: 1rem;
+ padding: 0.75rem 0;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: #666;
+ border-bottom: 1px solid #e0e0e0;
+}
+
+.checkout-trip-row {
+ display: grid;
+ grid-template-columns: 1fr 70px 120px 48px;
+ gap: 1rem;
+ align-items: center;
+ padding: 1.25rem 0;
+ border-bottom: 1px solid #eee;
+}
+
+.checkout-trip-row:last-child {
+ border-bottom: none;
+}
+
+.checkout-trip-name {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ min-width: 0;
+}
+
+.checkout-trip-image {
+ flex-shrink: 0;
+ width: 120px;
+ height: 80px;
+ border-radius: 6px;
+ overflow: hidden;
+ background: #f0f0f0;
+}
+
+.checkout-trip-image img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.checkout-trip-route {
+ font-size: 0.9375rem;
+ color: #333;
+}
+
+.checkout-trip-qty {
+ font-size: 0.9375rem;
+ color: #333;
+}
+
+.checkout-trip-price {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: #333;
+}
+
+.checkout-trip-remove {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.checkout-remove-btn {
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: 1px solid #ccc;
+ border-radius: 6px;
+ background: #fff;
+ font-size: 1.25rem;
+ line-height: 1;
+ cursor: pointer;
+ color: #666;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.checkout-remove-btn:hover {
+ background: #f5f5f5;
+ color: #c00;
+ border-color: #c00;
+}
+
+.checkout-empty-trip {
+ margin: 0;
+ padding: 1.5rem;
+ color: #666;
+ font-size: 0.9375rem;
+ line-height: 1.5;
+}
+
+/* Upgrade & Preferences – left-aligned */
+.checkout-checkbox {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+ font-size: 0.9375rem;
+ cursor: pointer;
+ text-align: left;
+}
+
+.checkout-checkbox input {
+ width: 1rem;
+ height: 1rem;
+ flex-shrink: 0;
+}
+
+.checkout-prefs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.checkout-prefs label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.875rem;
+ min-width: 140px;
+}
+
+.checkout-prefs select {
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #ccc;
+ border-radius: 6px;
+ font-size: 0.9375rem;
+}
+
+/* Forms – left-aligned (not centered) */
+.checkout-form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ text-align: left;
+ align-items: flex-start;
+}
+
+.checkout-form label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.875rem;
+ color: #333;
+ width: 100%;
+ text-align: left;
+}
+
+.checkout-form input[type="text"],
+.checkout-form input[type="email"],
+.checkout-form input[type="tel"],
+.checkout-form select {
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #ccc;
+ border-radius: 6px;
+ font-size: 0.9375rem;
+ font-family: inherit;
+}
+
+.checkout-form select {
+ appearance: none;
+ background-color: #fff;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23333' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.75rem center;
+ padding-right: 2rem;
+}
+
+body.citi-signal-theme .checkout-form input[type="text"],
+body.citi-signal-theme .checkout-form input[type="email"],
+body.citi-signal-theme .checkout-form input[type="tel"],
+body.citi-signal-theme .checkout-form select,
+body.citi-signal-theme .checkout-prefs select {
+ height: 32px;
+ min-height: 32px;
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+/* Sidebar – Trip Total (proportional width, aligned with main content) */
+.checkout-sidebar {
+ position: sticky;
+ top: 1rem;
+ min-width: 0;
+}
+
+.checkout-total-box {
+ width: 100%;
+ background: #f8f9fa;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ padding: 1.75rem 2rem;
+}
+
+.checkout-total-title {
+ margin: 0 0 1rem 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #333;
+}
+
+.checkout-total-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.35rem 0;
+ font-size: 0.9375rem;
+ color: #333;
+}
+
+.checkout-total-divider {
+ border: none;
+ border-top: 1px solid #ddd;
+ margin: 0.75rem 0;
+}
+
+.checkout-total-final {
+ font-weight: 700;
+ font-size: 1rem;
+}
+
+.checkout-confirm-btn {
+ width: 100%;
+ margin-top: 1.25rem;
+ padding: 0.875rem 1.25rem;
+ background: var(--link-color, #0563c1);
+ color: #fff;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.checkout-confirm-btn:hover {
+ filter: brightness(1.05);
+}
+label.checkout-checkbox {
+ flex-direction: row;
+}
+
+@media (max-width: 600px) {
+ .checkout-block {
+ padding: 0 1rem 2rem;
+ }
+ .checkout-header {
+ margin: 0 -1rem 0;
+ padding: 1rem 1rem;
+ }
+ .checkout-trip-header,
+ .checkout-trip-row {
+ grid-template-columns: 1fr 50px 80px 40px;
+ font-size: 0.8125rem;
+ }
+ .checkout-trip-image {
+ width: 90px;
+ height: 60px;
+ }
+}
\ No newline at end of file
diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js
new file mode 100644
index 0000000..c2b4517
--- /dev/null
+++ b/blocks/checkout/checkout.js
@@ -0,0 +1,563 @@
+import { dispatchCustomEvent } from '../../scripts/custom-events.js';
+import { readBlockConfig } from '../../scripts/aem.js';
+import { normalizeAemPath } from '../../scripts/scripts.js';
+import { fetchButtonDataSheet } from '../../scripts/form-data-layer.js';
+/**
+ * Checkout block – consolidates selected flights from the flights block and shows Trip Summary.
+ * Selected flights are stored in localStorage (project_selected_flights) when user clicks Select on any flight.
+ * Book Now on the flights block redirects to the checkout page where this block is authored.
+ * Confirm Purchase saves booking to sessionStorage and redirects to the confirmation page.
+ */
+
+const TRIP_STORAGE_KEY = 'project_selected_flights';
+const BOOKING_STORAGE_KEY = 'project_booking_confirmation';
+
+const LIVE_CONFIRMATION_PATH = '/en/confirmation';
+
+function getConfirmationPath(authoredPath) {
+ if (authoredPath) return normalizeAemPath(authoredPath);
+ if (typeof window === 'undefined') return LIVE_CONFIRMATION_PATH;
+ const isAuthor = window.location.hostname.includes('author') || window.location.hostname.includes('adobeaemcloud');
+ if (isAuthor) {
+ const pathname = window.location.pathname;
+ const enIndex = pathname.indexOf('/en/');
+ if (enIndex !== -1) return pathname.slice(0, enIndex + 4) + 'confirmation.html';
+ if (pathname.endsWith('/en')) return pathname + '/confirmation.html';
+ return '/en/confirmation.html';
+ }
+ return LIVE_CONFIRMATION_PATH;
+}
+
+function getSelectedFlights() {
+ try {
+ const localRaw = localStorage.getItem(TRIP_STORAGE_KEY);
+ if (localRaw) return JSON.parse(localRaw);
+ return [];
+ } catch {
+ return [];
+ }
+}
+
+function setSelectedFlights(list) {
+ localStorage.setItem(TRIP_STORAGE_KEY, JSON.stringify(list));
+}
+
+function removeFlight(id) {
+ const list = getSelectedFlights().filter((f) => f.id !== id);
+ setSelectedFlights(list);
+}
+
+function getFlightsTotal(flights) {
+ return flights.reduce((sum, f) => sum + (parseFloat(f.price) || 0), 0);
+}
+
+function syncCartDataLayerAfterFlightRemoval(removedFlightId) {
+ if (typeof window.updateDataLayer !== 'function') return;
+
+ const existingCart = typeof window.getDataLayerProperty === 'function'
+ ? (window.getDataLayerProperty('cart') || {})
+ : ((window.dataLayer && window.dataLayer.cart) || {});
+
+ const existingProducts = (existingCart && existingCart.products && typeof existingCart.products === 'object')
+ ? existingCart.products
+ : {};
+ const nextProducts = { ...existingProducts };
+ delete nextProducts[removedFlightId];
+
+ const productEntries = Object.values(nextProducts);
+ const subTotal = productEntries.reduce((sum, product) => {
+ const price = parseFloat(product?.price) || 0;
+ const quantity = parseInt(product?.quantity, 10) || 1;
+ return sum + (price * quantity);
+ }, 0);
+ const productCount = productEntries.length;
+
+ const nextCart = {
+ ...existingCart,
+ products: nextProducts,
+ productCount,
+ subTotal,
+ total: subTotal,
+ };
+
+ window.updateDataLayer({ cart: nextCart }, false);
+}
+
+function formatPrice(price) {
+ const n = parseFloat(price);
+ if (Number.isNaN(n)) return '$0.00';
+ return `$${n.toFixed(2)}`;
+}
+
+function formatRoute(flight) {
+ const from = flight.fromName || flight.from || '';
+ const to = flight.toName || flight.to || '';
+ const fromCode = flight.from ? ` (${flight.from})` : '';
+ const toCode = flight.to ? ` (${flight.to})` : '';
+ return `${from}${fromCode} to ${to}${toCode}`;
+}
+
+function generateBookingReference() {
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+ let s = 'B';
+ for (let i = 0; i < 5; i += 1) s += chars[Math.floor(Math.random() * chars.length)];
+ return s;
+}
+
+function generateElectronicTicketNumber() {
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
+ let s = '';
+ for (let i = 0; i < 11; i += 1) s += chars[Math.floor(Math.random() * chars.length)];
+ return s;
+}
+
+function generateItineraryNumber() {
+ let s = '';
+ for (let i = 0; i < 12; i += 1) s += Math.floor(Math.random() * 10);
+ return s;
+}
+
+/** Generate 10 alphanumeric characters (e.g. "fa8e413cc7") for commerce.order.purchaseOrderNumber and order */
+function generate10AlphaNumeric() {
+ const chars = 'abcdefghjkmnpqrstuvwxyz23456789';
+ let s = '';
+ for (let i = 0; i < 10; i += 1) s += chars[Math.floor(Math.random() * chars.length)];
+ return s;
+}
+
+function collectCheckoutFormData(block) {
+ const data = {};
+ const inputs = block.querySelectorAll('input, select');
+ inputs.forEach((el) => {
+ const name = el.getAttribute('name');
+ if (!name) return;
+ if (el.type === 'checkbox') {
+ data[name] = el.checked;
+ } else {
+ data[name] = el.value || '';
+ }
+ });
+ return data;
+}
+
+function renderTripSummary(container, onRemove) {
+ const flights = getSelectedFlights();
+ container.innerHTML = '';
+ if (flights.length === 0) {
+ const empty = document.createElement('p');
+ empty.className = 'checkout-empty-trip';
+ empty.textContent = 'No flights in your trip. Add flights from the Flights block, then use Book Now to come here.';
+ container.appendChild(empty);
+ return 0;
+ }
+ const table = document.createElement('div');
+ table.className = 'checkout-trip-table';
+ const header = document.createElement('div');
+ header.className = 'checkout-trip-header';
+ header.innerHTML = 'NAME QTY PRICE ';
+ table.appendChild(header);
+ flights.forEach((flight) => {
+ const row = document.createElement('div');
+ row.className = 'checkout-trip-row';
+ const imgCell = document.createElement('div');
+ imgCell.className = 'checkout-trip-image';
+ if (flight.image) {
+ const pic = document.createElement('picture');
+ const img = document.createElement('img');
+ img.src = flight.image;
+ img.alt = formatRoute(flight);
+ pic.appendChild(img);
+ imgCell.appendChild(pic);
+ }
+ const routeCell = document.createElement('div');
+ routeCell.className = 'checkout-trip-route';
+ routeCell.textContent = formatRoute(flight);
+ const nameCell = document.createElement('div');
+ nameCell.className = 'checkout-trip-name';
+ nameCell.appendChild(imgCell);
+ nameCell.appendChild(routeCell);
+ const qtyCell = document.createElement('div');
+ qtyCell.className = 'checkout-trip-qty';
+ qtyCell.textContent = '1';
+ const priceCell = document.createElement('div');
+ priceCell.className = 'checkout-trip-price';
+ priceCell.textContent = formatPrice(flight.price);
+ const removeCell = document.createElement('div');
+ removeCell.className = 'checkout-trip-remove';
+ const removeBtn = document.createElement('button');
+ removeBtn.type = 'button';
+ removeBtn.className = 'checkout-remove-btn';
+ removeBtn.setAttribute('aria-label', 'Remove flight');
+ removeBtn.textContent = '×';
+ removeBtn.onclick = () => {
+ removeFlight(flight.id);
+ syncCartDataLayerAfterFlightRemoval(flight.id);
+ onRemove();
+ };
+ removeCell.appendChild(removeBtn);
+ row.appendChild(nameCell);
+ row.appendChild(qtyCell);
+ row.appendChild(priceCell);
+ row.appendChild(removeCell);
+ table.appendChild(row);
+ });
+ container.appendChild(table);
+ return getFlightsTotal(flights);
+}
+
+function renderUpgradeAndPreferences(mainCol) {
+ const section = document.createElement('div');
+ section.className = 'checkout-section';
+ section.innerHTML = `
+
Upgrade your trip
+ Upgrade class with points
+ Add extra piece of checked-in luggage
+ `;
+ mainCol.appendChild(section);
+ const prefs = document.createElement('div');
+ prefs.className = 'checkout-section';
+ prefs.innerHTML = `
+ Preferences
+
+ Seat Select... Window Aisle
+ Section Select... Forward Rear
+ Meal Select... Regular Vegetarian Low Calories Peanut-Free Non-Lactose Diabetic
+
+ `;
+ mainCol.appendChild(prefs);
+}
+
+/** Read value from input/select (text or checked for checkbox) */
+function getFieldValue(block, name) {
+ const el = block.querySelector(`[name="${name}"]`);
+ if (!el) return el === null ? '' : undefined;
+ if (el.type === 'checkbox') return el.checked;
+ return (el.value || '').trim();
+}
+
+/** Push all checkout form fields to datalayer (passenger, payment, upgrade, preferences) */
+function updateDataLayerFromCheckoutForm(block) {
+ if (typeof window.updateDataLayer !== 'function') return;
+ const v = (name) => getFieldValue(block, name);
+
+ const yesNo = (x) => (typeof window.getDataLayerYesNo === 'function' ? window.getDataLayerYesNo(x) : (x ? 'y' : 'n'));
+ const updates = {
+ emailConsent: v('promo') ?? false,
+ extraLuggage: v('upgrade-luggage') ?? false,
+ upgradeWithPoints: yesNo(v('upgrade-points')),
+ travelPreferences: {
+ seat: v('seat') || 'noPreference',
+ seatSection: v('section') || 'noPreference',
+ meal: v('meal') || 'regularMeal',
+ },
+ person: {
+ name: {
+ firstName: v('firstName') || '',
+ middleName: v('middleName') || '',
+ lastName: v('lastName') || '',
+ },
+ gender: v('gender') || 'not_specified',
+ birthDate: v('birthDate') || '',
+ isMember: yesNo(v('wknd-club')),
+ },
+ personalEmail: { address: v('email') || '' },
+ mobilePhone: { number: v('phone') || '' },
+ smsConsent: yesNo(v('sms')),
+ loyaltyConsent: yesNo(v('wknd-club')),
+ payment: {
+ nameOnCard: v('nameOnCard') || '',
+ cardExpiration: v('expiration') || '',
+ cardNumber: v('cardNumber') || '',
+ cvv: v('cvv') || '',
+ },
+ consents: {
+ marketing: {
+ email: { val: yesNo(v('promo')) },
+ },
+ },
+ };
+
+ updates._demosystem4 = {
+ identification: {
+ core: { loyaltyId: v('frequentFlyerId') || '' },
+ },
+ };
+
+ window.updateDataLayer(updates, true);
+}
+
+/** Attach listeners so datalayer stays in sync with all checkout form fields */
+function attachCheckoutDataLayerListeners(block) {
+ updateDataLayerFromCheckoutForm(block);
+ const inputs = block.querySelectorAll('input, select');
+ inputs.forEach((el) => {
+ const name = el.getAttribute('name');
+ if (!name) return;
+ const event = el.type === 'checkbox' ? 'change' : 'blur';
+ el.addEventListener(event, () => updateDataLayerFromCheckoutForm(block));
+ });
+}
+
+function renderPassengerForm(mainCol) {
+ const section = document.createElement('div');
+ section.className = 'checkout-section';
+ section.innerHTML = `
+ Passenger Information
+ Please make sure your full name is entered exactly as it appears on your government-issued identification. This information is required based on international regulations.
+
+ First Name
+ Middle Name (optional)
+ Last Name
+ Birth Date
+ Gender Not Specified Male Female
+ Frequent Flyer ID
+ Email Address
+ Phone Number
+ I want to sign up for WKND Fly Club
+ I want to get SMS with booking confirmation
+ I want to receive electronic mail with promotions and announcements
+
+ `;
+ mainCol.appendChild(section);
+}
+
+function renderPaymentForm(mainCol) {
+ const section = document.createElement('div');
+ section.className = 'checkout-section';
+ section.innerHTML = `
+ Payment Method
+ Only credit/debit card payments are accepted. Ignore this section if you chose to pay with Frequent Flyer program points.
+
+ Name on Card
+ Expiration
+ Card Number
+ CVV
+
+ `;
+ mainCol.appendChild(section);
+}
+
+function renderTripTotal(sidebar, total, config) {
+ sidebar.innerHTML = '';
+ const box = document.createElement('div');
+ box.className = 'checkout-total-box';
+ const flights = getSelectedFlights();
+ const passengerCount = 1;
+ const flightsTotal = total || 0;
+ box.innerHTML = `
+ Trip Total
+ ${passengerCount} Passenger
+ Flights ${formatPrice(flightsTotal)}
+ Taxes included
+
+ Total ${formatPrice(flightsTotal)}
+ Confirm Purchase
+ `;
+ const confirmBtn = box.querySelector('.checkout-confirm-btn');
+ if (confirmBtn) {
+ const block = document.querySelector('.checkout-block');
+ confirmBtn.onclick = async () => {
+ if (flights.length === 0) {
+ // eslint-disable-next-line no-alert
+ alert('Please add at least one flight to your trip before confirming.');
+ return;
+ }
+ const formData = block ? collectCheckoutFormData(block) : {};
+ const bookingRef = generateBookingReference();
+ const ticketNum = generateElectronicTicketNumber();
+ const itineraryNum = generateItineraryNumber();
+ const bookingData = {
+ bookingReference: bookingRef,
+ electronicTicketNumber: ticketNum,
+ itineraryNumber: itineraryNum,
+ total: flightsTotal,
+ passengerCount: 1,
+ flights: flights.map((f) => ({ ...f, route: formatRoute(f) })),
+ formData,
+ };
+ try {
+ sessionStorage.setItem(BOOKING_STORAGE_KEY, JSON.stringify(bookingData));
+ // So Launch "Profile - Email from Storage" and Identity Map resolve when Confirm Purchase rule runs
+ if (formData.email) {
+ localStorage.setItem("com.adobe.reactor.dataElements.Profile - Email", formData.email);
+ if (typeof window._satellite !== "undefined" && typeof window._satellite.setVar === "function") {
+ window._satellite.setVar("Profile - Email", formData.email);
+ }
+
+ localStorage.setItem(
+ "com.adobe.reactor.dataElements.Identities",
+ JSON.stringify({
+ Email: [
+ {
+ id: formData.email,
+ primary: true,
+ authenticatedState: "authenticated",
+ },
+ ],
+ })
+ );
+
+ sessionStorage.setItem(
+ "com.adobe.reactor.dataElements.Identity Map",
+ JSON.stringify({
+ Email: [
+ {
+ id: formData.email,
+ primary: true,
+ authenticatedState: "authenticated",
+ },
+ ],
+ })
+ );
+ }
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('Could not save booking to sessionStorage', e);
+ }
+ let orderId;
+ if (typeof window.updateDataLayer === 'function') {
+ orderId = generate10AlphaNumeric();
+ const firstFlight = flights[0];
+ updateDataLayerFromCheckoutForm(block);
+ const bookingUpdates = {
+ order: orderId,
+ commerce: { order: { purchaseOrderNumber: orderId } },
+ itineraryNumber: itineraryNum,
+ bookingReference: bookingRef,
+ ticketNumber: ticketNum,
+ cart: { ...(typeof window.getDataLayerProperty === 'function' ? window.getDataLayerProperty('cart') : {}), total: flightsTotal },
+ personalEmail: { address: formData.email || '' },
+ _demosystem4: {
+ identification: {
+ core: {
+ loyaltyId: formData.frequentFlyerId || (typeof window.getDataLayerProperty === 'function' ? (window.getDataLayerProperty('_demosystem4.identification.core')?.loyaltyId) : undefined) || '',
+ },
+ },
+ },
+ };
+ if (firstFlight) {
+ const dateVal = firstFlight.date;
+ const todayISO = typeof window.getDataLayerDate === 'function' ? window.getDataLayerDate(new Date().toISOString().slice(0, 10)) : '';
+ bookingUpdates.from = firstFlight.from || '';
+ bookingUpdates.to = firstFlight.to || '';
+ bookingUpdates.flightNumber = firstFlight.id || '';
+ bookingUpdates.class = (typeof window.getDataLayerFlightClass === 'function' ? window.getDataLayerFlightClass(firstFlight.class) : (firstFlight.class || '')) || '';
+ bookingUpdates.flightLength = (typeof window.getDataLayerFlightLength === 'function' ? window.getDataLayerFlightLength(firstFlight.flightLength) : (parseInt(firstFlight.flightLength, 10) || 0));
+ bookingUpdates.date = (typeof window.getDataLayerDate === 'function' ? (window.getDataLayerDate(dateVal) || todayISO) : (dateVal || todayISO)) || '';
+ }
+ window.updateDataLayer(bookingUpdates, true);
+ const buttonDataUrl = confirmBtn.dataset?.buttonData?.trim();
+ if (buttonDataUrl) {
+ const sheetData = await fetchButtonDataSheet(buttonDataUrl);
+ if (sheetData) window.updateDataLayer(sheetData);
+ }
+ dispatchCustomEvent(config.buttoneventtype);
+ }
+ setTimeout(() => { window.location.href = getConfirmationPath(config.confirmationpath) + '?order=' + orderId; }, 2000);
+ };
+ }
+ sidebar.appendChild(box);
+}
+
+/** Fill the form data from datalayer object available in local storage */
+function fillFormDataFromDataLayer(block) {
+ try {
+ const rawData = localStorage.getItem('project_registered_user');
+ if (!rawData) return;
+
+ const userData = JSON.parse(rawData);
+ const fields = [
+ { name: 'firstName', value: userData?.firstName },
+ { name: 'lastName', value: userData?.lastName },
+ { name: 'email', value: userData?.email },
+ { name: 'phone', value: userData?.phone },
+ ];
+
+ fields.forEach(({ name, value }) => {
+ const el = block.querySelector(`[name="${name}"]`);
+ if (!el) return;
+ if (el.type === 'checkbox') {
+ el.checked = !!value;
+ } else {
+ el.value = value || '';
+ }
+ });
+ } catch (e) {
+ console.warn('Could not fill form data from datalayer', e);
+ }
+}
+
+export default async function decorate(block) {
+ const config = readBlockConfig(block);
+ /* Hide all config rows on live */
+ [...block.children].forEach((row) => row.style.display = 'none');
+ block.classList.add('checkout-block');
+ const wrapper = document.createElement('div');
+ wrapper.className = 'checkout-wrapper';
+ const mainCol = document.createElement('div');
+ mainCol.className = 'checkout-main';
+ const sidebar = document.createElement('div');
+ sidebar.className = 'checkout-sidebar';
+
+ const header = document.createElement('div');
+ header.className = 'checkout-header';
+ header.innerHTML = 'Trip Summary ';
+ mainCol.appendChild(header);
+
+ const tripSection = document.createElement('div');
+ tripSection.className = 'checkout-section checkout-trip-section';
+ const tripTitle = document.createElement('h3');
+ tripTitle.className = 'checkout-section-title';
+ tripTitle.textContent = 'Trip Summary';
+ tripSection.appendChild(tripTitle);
+ const tripContainer = document.createElement('div');
+ tripContainer.className = 'checkout-trip-container';
+ tripSection.appendChild(tripContainer);
+ mainCol.appendChild(tripSection);
+
+ const refreshTripAndTotal = () => {
+ const total = renderTripSummary(tripContainer, refreshTripAndTotal);
+ renderTripTotal(sidebar, total, config);
+ };
+
+ refreshTripAndTotal();
+ renderUpgradeAndPreferences(mainCol);
+ renderPassengerForm(mainCol);
+ renderPaymentForm(mainCol);
+
+ wrapper.appendChild(mainCol);
+ wrapper.appendChild(sidebar);
+ block.appendChild(wrapper);
+
+ restrictNumericFieldsToDigits(block);
+ formatBirthDateInput(block);
+ fillFormDataFromDataLayer(block);
+ attachCheckoutDataLayerListeners(block);
+}
+
+/** Restrict phone, card number, CVV to digits only (strip non-numeric on input) */
+function restrictNumericFieldsToDigits(block) {
+ const numericNames = ['cardNumber', 'cvv'];
+ numericNames.forEach((name) => {
+ const el = block.querySelector(`[name="${name}"]`);
+ if (!el || el.type === 'hidden') return;
+ el.addEventListener('input', () => {
+ const digits = el.value.replace(/\D/g, '');
+ if (el.value !== digits) el.value = digits;
+ });
+ });
+}
+
+/** Birth date: accept digits only and auto-format to mm/dd/yyyy (works with or without slashes) */
+function formatBirthDateInput(block) {
+ const el = block.querySelector('[name="birthDate"]');
+ if (!el) return;
+ el.addEventListener('input', () => {
+ const digits = el.value.replace(/\D/g, '').slice(0, 8);
+ let formatted = '';
+ if (digits.length > 0) formatted = digits.slice(0, 2);
+ if (digits.length > 2) formatted += `/${digits.slice(2, 4)}`;
+ if (digits.length > 4) formatted += `/${digits.slice(4, 8)}`;
+ if (el.value !== formatted) el.value = formatted;
+ });
+}
\ No newline at end of file
diff --git a/blocks/confirmation/_confirmation.json b/blocks/confirmation/_confirmation.json
new file mode 100644
index 0000000..708d082
--- /dev/null
+++ b/blocks/confirmation/_confirmation.json
@@ -0,0 +1,35 @@
+{
+ "definitions": [
+ {
+ "title": "Confirmation",
+ "id": "confirmation",
+ "plugins": {
+ "xwalk": {
+ "page": {
+ "resourceType": "core/franklin/components/block/v1/block",
+ "template": {
+ "name": "Confirmation",
+ "model": "confirmation"
+ }
+ }
+ }
+ }
+ }
+ ],
+ "models": [
+ {
+ "id": "confirmation",
+ "fields": [
+ {
+ "component": "text",
+ "name": "custom-class",
+ "label": "Custom class",
+ "value": "",
+ "valueType": "string",
+ "description": "Space-separated class names to add to the block"
+ }
+ ]
+ }
+ ],
+ "filters": []
+}
\ No newline at end of file
diff --git a/blocks/confirmation/confirmation.css b/blocks/confirmation/confirmation.css
new file mode 100644
index 0000000..7af7700
--- /dev/null
+++ b/blocks/confirmation/confirmation.css
@@ -0,0 +1,107 @@
+/* Confirmation block – Electronic Ticket booked */
+.confirmation-block {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 0 1.5rem 3rem;
+ font-family: var(--body-font-family, system-ui, sans-serif);
+}
+
+.confirmation-title {
+ margin: 0 0 0.75rem 0;
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: #333;
+}
+
+.confirmation-subtitle {
+ margin: 0 0 2rem 0;
+ font-size: 1rem;
+ color: #555;
+ line-height: 1.5;
+}
+
+.confirmation-eticket-inline {
+ color: var(--link-color, #0563c1);
+ font-weight: 700;
+}
+
+.confirmation-empty {
+ margin: 0;
+ padding: 2rem;
+ color: #666;
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+.confirmation-card {
+ background: #f5f5f5;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ padding: 1.5rem 2rem;
+ margin-bottom: 1.5rem;
+}
+
+.confirmation-card-title {
+ margin: 0 0 1.25rem 0;
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: #333;
+}
+
+.confirmation-details {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.confirmation-detail-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 1rem;
+}
+
+.confirmation-detail-label {
+ font-size: 0.9375rem;
+ color: #333;
+}
+
+.confirmation-detail-value {
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: var(--link-color, #0563c1);
+}
+
+.confirmation-keep-in-mind {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 2rem;
+}
+
+@media (max-width: 600px) {
+ .confirmation-keep-in-mind {
+ grid-template-columns: 1fr;
+ }
+}
+
+.confirmation-keep-column {
+ min-width: 0;
+}
+
+.confirmation-keep-heading {
+ margin: 0 0 0.5rem 0;
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--link-color, #0563c1);
+}
+
+.confirmation-keep-text {
+ margin: 0 0 0.5rem 0;
+ font-size: 0.9375rem;
+ color: #333;
+ line-height: 1.5;
+}
+
+.confirmation-keep-text:last-child {
+ margin-bottom: 0;
+}
\ No newline at end of file
diff --git a/blocks/confirmation/confirmation.js b/blocks/confirmation/confirmation.js
new file mode 100644
index 0000000..ae8fa55
--- /dev/null
+++ b/blocks/confirmation/confirmation.js
@@ -0,0 +1,92 @@
+/**
+ * Confirmation block – shows Electronic Ticket booked after checkout.
+ * Data is stored in sessionStorage (project_booking_confirmation) when user clicks Confirm Purchase.
+ */
+
+const BOOKING_STORAGE_KEY = 'project_booking_confirmation';
+const TRIP_STORAGE_KEY = 'project_selected_flights';
+
+function getBookingData() {
+ try {
+ const raw = localStorage.getItem(BOOKING_STORAGE_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null;
+ }
+}
+
+function resetCartFromDataLayer() {
+ if (typeof window.updateDataLayer !== 'function') return;
+ window.updateDataLayer({
+ cart: {
+ products: {},
+ productCount: 0,
+ subTotal: 0,
+ total: 0,
+ },
+ }, false);
+}
+
+function renderConfirmation(block, data) {
+ if (!data) {
+ block.innerHTML = `
+
+
No booking found. Complete a purchase on the checkout page to see your confirmation here.
+
+ `;
+ return;
+ }
+
+ const {
+ bookingReference = '—',
+ electronicTicketNumber = '—',
+ itineraryNumber = '—',
+ } = data;
+
+ block.classList.add('confirmation-block');
+ block.innerHTML = `
+ Electronic Ticket booked
+ Your electronic ticket number is ${electronicTicketNumber} . Please refer this number in future communication with WKND Fly customer service.
+
+
+
Booking Details
+
+
+ Booking Reference
+ ${bookingReference}
+
+
+ Electronic Ticket #
+ ${electronicTicketNumber}
+
+
+ Itinerary #
+ ${itineraryNumber}
+
+
+
+
+
+
Keep in mind
+
+
+
Departure
+
Gate closes 30 minutes before planned take off.
+
Baggage Check-In closes 45 minutes before take off.
+
+
+
Arrival
+
Time of landing may depend on actual departure time and conditions at the airport.
+
+
+
+ `;
+}
+
+export default async function decorate(block) {
+ const data = getBookingData();
+ renderConfirmation(block, data);
+ localStorage.removeItem(TRIP_STORAGE_KEY);
+ localStorage.removeItem(BOOKING_STORAGE_KEY);
+ resetCartFromDataLayer();
+}
\ No newline at end of file
diff --git a/blocks/create-account/_create-account.json b/blocks/create-account/_create-account.json
new file mode 100644
index 0000000..daf74b0
--- /dev/null
+++ b/blocks/create-account/_create-account.json
@@ -0,0 +1,131 @@
+{
+ "definitions": [
+ {
+ "title": "Create Account",
+ "id": "create-account",
+ "plugins": {
+ "xwalk": {
+ "page": {
+ "resourceType": "core/franklin/components/block/v1/block",
+ "template": {
+ "name": "Create Account",
+ "model": "create-account",
+ "key-value": true
+ }
+ }
+ }
+ }
+ }
+ ],
+ "models": [
+ {
+ "id": "create-account",
+ "fields": [
+ {
+ "component": "select",
+ "name": "variant",
+ "label": "Variant",
+ "value": "default",
+ "valueType": "string",
+ "options": [
+ {
+ "name": "Default",
+ "value": "default"
+ },
+ {
+ "name": "Luma",
+ "value": "luma"
+ },
+ {
+ "name": "Frescopa",
+ "value": "frescopa"
+ },
+ {
+ "name": "WKND Fly",
+ "value": "wknd-fly"
+ }
+ ]
+ },
+ {
+ "component": "boolean",
+ "name": "showaddress",
+ "label": "Show Address Fields",
+ "value": "true",
+ "valueType": "boolean",
+ "description": "Show or hide the address, ZIP code, and city fields."
+ },
+ {
+ "component": "boolean",
+ "name": "showdateofbirth",
+ "label": "Show Date of Birth Field",
+ "value": "true",
+ "valueType": "boolean",
+ "description": "Show or hide the date of birth field."
+ },
+ {
+ "component": "boolean",
+ "name": "showloyaltyprogram",
+ "label": "Show Loyalty Program",
+ "value": "false",
+ "valueType": "boolean"
+ },
+ {
+ "component": "boolean",
+ "name": "showcommunicationpreferences",
+ "label": "Show Communication Preferences",
+ "value": "true",
+ "valueType": "boolean"
+ },
+ {
+ "component": "aem-content",
+ "valueType": "string",
+ "name": "redirectUrl",
+ "label": "Redirect URL",
+ "description": "Page to redirect to after successful account creation. Leave empty for no redirect."
+ },
+ {
+ "component": "text",
+ "name": "buttoneventtype",
+ "label": "Button Event Type",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "text",
+ "name": "buttonwebhookurl",
+ "label": "Button Webhook URL",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "text",
+ "name": "buttonformid",
+ "label": "Button Form ID",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "aem-content",
+ "name": "buttondata",
+ "label": "Button Data Sheet",
+ "valueType": "string",
+ "description": "AEM sheet path whose key/value rows are sent on submit"
+ },
+ {
+ "component": "text",
+ "name": "custom-class",
+ "label": "Custom class",
+ "value": "",
+ "valueType": "string",
+ "description": "Space-separated class names to add to the block"
+ }
+ ]
+ }
+ ],
+ "filters": [
+ {
+ "id": "create-account",
+ "components": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/blocks/create-account/create-account.css b/blocks/create-account/create-account.css
new file mode 100644
index 0000000..f1cb5d6
--- /dev/null
+++ b/blocks/create-account/create-account.css
@@ -0,0 +1,377 @@
+/* ============================================
+ Create Account Form Styling
+ ============================================ */
+
+main .create-account {
+ width: 100%;
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 60px 40px;
+ background: #fff;
+}
+
+main .create-account form {
+ display: block;
+}
+
+main .create-account .create-account-form {
+ --form-columns: 12;
+ --form-button-background-color: #2874F0;
+ --form-button-background-hover-color: #0052cc;
+ --form-input-border-color: #cbd5e1;
+ --form-label-color: #1e293b;
+ --form-label-font-weight: 500;
+ --form-label-font-size: 16px;
+ --form-field-vert-gap: 16px;
+ --form-field-horz-gap: 24px;
+}
+
+main .create-account .panel-wrapper {
+ display: grid !important;
+ grid-template-columns: repeat(12, minmax(0, 1fr)) !important;
+ gap: 16px !important;
+ padding: 0 !important;
+ border: none !important;
+ margin: 0 !important;
+}
+
+main .create-account .heading-wrapper label {
+ display: none !important;
+}
+
+main .create-account .heading-wrapper {
+ grid-column: span 12 !important;
+ margin: 0 !important;
+ padding: 0 !important;
+}
+
+main .create-account .field-wrapper.is-hidden,
+main .create-account .heading-wrapper.is-hidden {
+ display: none !important;
+}
+
+main .create-account h2#heading-create-account {
+ font-size: 2.25rem;
+ font-weight: 400;
+ margin: 0 0 32px;
+ padding: 0;
+ color: #1e293b;
+ line-height: 1.2;
+}
+
+main .create-account h2#communicationHeading {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 28px 0 0;
+ padding: 0;
+ color: #1e293b;
+ letter-spacing: 0.05em;
+}
+
+main .create-account h2#heading-know-you-better {
+ font-size: 2rem;
+ font-weight: 700;
+ margin: 28px 0 0;
+ padding: 0;
+ color: #1e293b;
+ letter-spacing: 0.01em;
+ line-height: 1.1;
+}
+
+body.citi-signal-theme main .create-account h2 {
+ font-size: 28px !important;
+ font-family: 'Adobe Clean Bold', sans-serif !important;
+}
+
+main .create-account .panel-wrapper > .heading-wrapper {
+ grid-column: span 12 !important;
+}
+
+main .create-account label {
+ color: #334155;
+ font-weight: 500;
+ font-size: 15px;
+ margin-bottom: 0;
+ display: inline-block;
+}
+
+main .create-account input[type="text"],
+main .create-account input[type="email"],
+main .create-account select {
+ width: 100%;
+ padding: 12px 16px;
+ border: 1px solid #cbd5e1;
+ border-radius: 6px;
+ font-size: 15px;
+ color: #1e293b;
+ background: #fff;
+ transition: all 0.2s ease;
+ margin-top: 6px;
+ margin-bottom: 0;
+}
+
+main .create-account input[type="text"]:focus,
+main .create-account input[type="email"]:focus,
+main .create-account select:focus {
+ outline: none;
+ border-color: #2874F0;
+ box-shadow: 0 0 0 3px rgba(40, 116, 240, 0.1);
+}
+
+main .create-account input[type="text"]:hover,
+main .create-account input[type="email"]:hover,
+main .create-account select:hover {
+ border-color: #94a3b8;
+}
+
+body.citi-signal-theme main .create-account input[type="text"],
+body.citi-signal-theme main .create-account input[type="email"],
+body.citi-signal-theme main .create-account select {
+ height: 32px;
+ min-height: 32px;
+ padding: 0.25rem 0.75rem;
+}
+
+main .create-account input::placeholder {
+ color: #94a3b8;
+ font-size: 14px;
+}
+
+main .create-account .field-wrapper {
+ margin: 0;
+}
+
+main .create-account .field-wrapper.col-12,
+main .create-account .field-wrapper[data-colspan="12"] {
+ grid-column: span 12 !important;
+}
+
+main .create-account .field-wrapper.col-6,
+main .create-account .field-wrapper[data-colspan="6"] {
+ grid-column: span 6 !important;
+}
+
+main .create-account .field-wrapper.col-4,
+main .create-account .field-wrapper[data-colspan="4"] {
+ grid-column: span 4 !important;
+}
+
+main .create-account .submit-wrapper.col-12 {
+ grid-column: span 12 !important;
+}
+
+main .create-account .field-wrapper.switch {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 0;
+ padding: 4px 0;
+}
+
+main .create-account .loyalty-program-field {
+ grid-column: span 12 !important;
+ margin-top: 4px;
+}
+
+main .create-account .know-you-better-heading {
+ margin-top: 12px !important;
+}
+
+main .create-account .field-wrapper.switch label {
+ order: 2;
+ font-size: 15px;
+ color: #334155;
+ cursor: pointer;
+ margin: 0;
+}
+
+main .create-account .field-wrapper.switch input[type="checkbox"] {
+ order: 1;
+ width: 44px;
+ height: 24px;
+ cursor: pointer;
+ margin: 0;
+ appearance: none;
+ background: #ccc;
+ border-radius: 12px;
+ position: relative;
+ transition: background 0.2s;
+}
+
+main .create-account .field-wrapper.switch input[type="checkbox"]::before {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 20px;
+ height: 20px;
+ background: #fff;
+ border-radius: 50%;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+ transition: transform 0.2s;
+}
+
+main .create-account .field-wrapper.switch input[type="checkbox"]:checked {
+ background: #2874F0;
+}
+
+main .create-account .field-wrapper.switch input[type="checkbox"]:checked::before {
+ transform: translateX(20px);
+}
+
+main .create-account .submit-wrapper {
+ margin-top: 24px;
+ grid-column: span 12 !important;
+}
+
+main .create-account .submit-wrapper button {
+ background: #2874F0;
+ color: #fff;
+ padding: 16px 60px;
+ font-size: 15px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ box-shadow: 0 2px 8px rgba(40, 116, 240, 0.2);
+}
+
+main .create-account .submit-wrapper button:hover {
+ background: #0052cc;
+ box-shadow: 0 4px 12px rgba(40, 116, 240, 0.3);
+ transform: translateY(-1px);
+}
+
+main .create-account .submit-wrapper button:active {
+ transform: translateY(0);
+ box-shadow: 0 1px 4px rgba(40, 116, 240, 0.2);
+}
+
+body.citi-signal-theme main .create-account .submit-wrapper button {
+ background: var(--main-accent-color) !important;
+ color: var(--text-light, #fff) !important;
+ border-color: var(--main-accent-color) !important;
+ box-shadow: 0 2px 8px rgb(0 0 0 / 18%);
+ width: auto;
+ min-width: 220px;
+ margin: 0 auto;
+ display: block;
+}
+
+body.citi-signal-theme main .create-account .submit-wrapper button:hover {
+ background: var(--main-accent-hover, var(--link-hover-color)) !important;
+ box-shadow: 0 4px 12px rgb(0 0 0 / 24%);
+}
+
+body.citi-signal-theme main .create-account .submit-wrapper button:active {
+ box-shadow: 0 1px 4px rgb(0 0 0 / 18%);
+}
+
+main .create-account [data-required="true"] > label::after {
+ content: " *";
+ color: #ef4444;
+ font-weight: 600;
+}
+
+.create-account input.error,
+.create-account select.error,
+.create-account textarea.error {
+ border-color: #f44336 !important;
+ background-color: #ffebee !important;
+}
+
+.create-account input.error:focus,
+.create-account select.error:focus,
+.create-account textarea.error:focus {
+ outline-color: #f44336;
+ border-color: #f44336;
+}
+
+.create-account .form-message {
+ padding: 15px;
+ margin: 20px 0;
+ border-radius: 4px;
+ text-align: center;
+ font-weight: bold;
+ animation: slideDown 0.3s ease-out;
+}
+
+.create-account .form-message.success {
+ background-color: #4caf50;
+ color: white;
+}
+
+.create-account .form-message.error {
+ background-color: #f44336;
+ color: white;
+}
+
+@keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.create-account button[type="submit"]:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.create-account [data-aue-prop="buttoneventtype"],
+.create-account [data-aue-prop="buttonwebhookurl"],
+.create-account [data-aue-prop="buttonformid"],
+.create-account [data-aue-prop="buttondata"] {
+ display: none !important;
+}
+
+.create-account div:has(> [data-aue-prop="buttoneventtype"]),
+.create-account div:has(> [data-aue-prop="buttonwebhookurl"]),
+.create-account div:has(> [data-aue-prop="buttonformid"]),
+.create-account div:has(> [data-aue-prop="buttondata"]) {
+ display: none !important;
+}
+
+@media (width <= 768px) {
+ main .create-account {
+ padding: 40px 20px;
+ }
+
+ main .create-account .panel-wrapper > .field-wrapper[class*="col-"] {
+ grid-column: span 12 !important;
+ }
+
+ main .create-account h2#heading-create-account {
+ font-size: 1.875rem;
+ margin-bottom: 32px;
+ }
+
+ main .create-account h2#communicationHeading {
+ font-size: 1.125rem;
+ margin: 32px 0 0;
+ }
+
+ main .create-account .submit-wrapper button {
+ width: 100%;
+ padding: 16px 32px;
+ }
+}
+
+@media (width <= 480px) {
+ main .create-account {
+ padding: 30px 16px;
+ }
+
+ main .create-account h2#heading-create-account {
+ font-size: 1.75rem;
+ margin-bottom: 28px;
+ }
+}
\ No newline at end of file
diff --git a/blocks/create-account/create-account.js b/blocks/create-account/create-account.js
new file mode 100644
index 0000000..866e4ca
--- /dev/null
+++ b/blocks/create-account/create-account.js
@@ -0,0 +1,517 @@
+import { readBlockConfig } from "../../scripts/aem.js";
+import { dispatchCustomEvent } from "../../scripts/custom-events.js";
+import { syncFormDataLayer, DEFAULT_FORM_FIELD_MAP, attachLiveFormSync, submitToWebhook, fetchButtonDataSheet } from "../../scripts/form-data-layer.js";
+import { normalizeAemPath } from "../../scripts/scripts.js";
+
+function isTruthy(value) {
+ return value === true || String(value).trim().toLowerCase() === "true";
+}
+
+function normalizeVariant(value) {
+ return String(value || "default").trim().toLowerCase();
+}
+
+function withConditionalClasses(baseClassName, isVisible) {
+ return isVisible ? baseClassName : `${baseClassName} is-hidden`;
+}
+
+function applyButtonConfigToSubmitButton(block, config) {
+ const submitButton = block.querySelector("form button[type='submit']");
+ if (!submitButton) return;
+ const eventType = config.buttoneventtype;
+ const normalizedEvent = eventType && String(eventType).trim();
+ if (normalizedEvent) submitButton.dataset.buttonEventType = normalizedEvent;
+ const webhookUrl = config.buttonwebhookurl;
+ if (webhookUrl && String(webhookUrl).trim()) submitButton.dataset.buttonWebhookUrl = String(webhookUrl).trim();
+ const formId = config.buttonformid;
+ if (formId && String(formId).trim()) submitButton.dataset.buttonFormId = String(formId).trim();
+ const buttonData = config.buttondata;
+ if (buttonData && String(buttonData).trim()) submitButton.dataset.buttonData = String(buttonData).trim();
+}
+
+function clearProductObject() {
+ if (typeof window.updateDataLayer === "function") {
+ window.updateDataLayer({ product: {} }, false);
+ }
+}
+
+function buildCreateAccountFormDef(config = {}) {
+ const isLumaVariant = normalizeVariant(config.variant) === "luma";
+ const isFrescopaVariant = normalizeVariant(config.variant) === "frescopa"
+ || document.body.classList.contains('frescopa-theme');
+ const isWkndFlyVariant = normalizeVariant(config.variant) === "wknd-fly";
+ const showLoyaltyProgram = isTruthy(config.showloyaltyprogram);
+ const showCommunicationPreferences = config.showcommunicationpreferences !== undefined
+ ? isTruthy(config.showcommunicationpreferences)
+ : true;
+ const showAddress = config.showaddress !== undefined ? isTruthy(config.showaddress) : true;
+ const showDateOfBirth = config.showdateofbirth !== undefined ? isTruthy(config.showdateofbirth) : true;
+ const shoeSizes = ["", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45"];
+ const shirtSizes = ["", "s", "m", "l", "xl", "xxl"];
+ const favoriteColors = ["", "black", "blue", "green", "orange", "pink", "purple", "red", "white", "yellow"];
+
+ return {
+ id: "create-account",
+ fieldType: "form",
+ appliedCssClassNames: "create-account-form",
+ items: [
+ {
+ id: "heading-create-account",
+ fieldType: "heading",
+ label: { value: "Create an account" },
+ appliedCssClassNames: "col-12",
+ },
+ {
+ id: "panel-main",
+ name: "main",
+ fieldType: "panel",
+ items: [
+ {
+ id: "firstName",
+ name: "firstName",
+ fieldType: "text-input",
+ label: { value: "First name" },
+ properties: { colspan: 6 },
+ },
+ {
+ id: "lastName",
+ name: "lastName",
+ fieldType: "text-input",
+ label: { value: "Last name" },
+ properties: { colspan: 6 },
+ },
+ {
+ id: "email",
+ name: "email",
+ fieldType: "text-input",
+ label: { value: "Email address" },
+ autoComplete: "email",
+ properties: { colspan: isFrescopaVariant ? 6 : 12 },
+ },
+ {
+ id: "phone",
+ name: "phone",
+ fieldType: "text-input",
+ label: { value: "Phone number" },
+ autoComplete: "tel",
+ properties: { colspan: isFrescopaVariant ? 6 : 12 },
+ },
+ ...(isWkndFlyVariant ? [{
+ id: "wkndFlyMember",
+ name: "wkndFlyMember",
+ fieldType: "drop-down",
+ label: { value: "WKND Fly Member" },
+ enum: ["", "member", "non-member"],
+ enumNames: ["Select...", "Member", "Non-member"],
+ type: "string",
+ properties: { colspan: 12 },
+ }] : []),
+ {
+ id: "address",
+ name: "streetAddress",
+ fieldType: "text-input",
+ label: { value: "Address" },
+ autoComplete: "street-address",
+ properties: { colspan: 12 },
+ ...(showAddress ? {} : { appliedCssClassNames: "is-hidden" }),
+ },
+ {
+ id: "zipCode",
+ name: "zipCode",
+ fieldType: "text-input",
+ label: { value: "ZIP code" },
+ autoComplete: "postal-code",
+ properties: { colspan: 6 },
+ ...(showAddress ? {} : { appliedCssClassNames: "is-hidden" }),
+ },
+ {
+ id: "city",
+ name: "city",
+ fieldType: "text-input",
+ label: { value: "City" },
+ autoComplete: "address-level2",
+ properties: { colspan: 6 },
+ ...(showAddress ? {} : { appliedCssClassNames: "is-hidden" }),
+ },
+ {
+ id: "dateOfBirth",
+ name: "dateOfBirth",
+ fieldType: "text-input",
+ label: { value: "Date of birth (YYYY-MM-DD)" },
+ placeholder: "YYYY-MM-DD",
+ properties: { colspan: 12 },
+ ...(showDateOfBirth ? {} : { appliedCssClassNames: "is-hidden" }),
+ },
+ {
+ id: "joinLoyaltyProgram",
+ name: "joinLoyaltyProgram",
+ fieldType: "checkbox",
+ label: { value: "I want to join loyalty program" },
+ enum: ["true"],
+ type: "string",
+ appliedCssClassNames: withConditionalClasses("col-12 loyalty-program-field", showLoyaltyProgram),
+ properties: {
+ variant: "switch",
+ alignment: "horizontal",
+ colspan: 12,
+ },
+ },
+ {
+ id: "communicationHeading",
+ fieldType: "heading",
+ label: { value: "Communication preferences" },
+ appliedCssClassNames: withConditionalClasses("col-12 communication-heading", showCommunicationPreferences),
+ },
+ {
+ id: "prefEmail",
+ name: "prefEmail",
+ fieldType: "checkbox",
+ label: { value: "Email" },
+ enum: ["true"],
+ type: "string",
+ properties: {
+ variant: "switch",
+ alignment: "horizontal",
+ colspan: 4,
+ },
+ ...(showCommunicationPreferences ? {} : { appliedCssClassNames: "is-hidden" }),
+ },
+ {
+ id: "prefPhone",
+ name: "prefPhone",
+ fieldType: "checkbox",
+ label: { value: "Phone" },
+ enum: ["true"],
+ type: "string",
+ properties: {
+ variant: "switch",
+ alignment: "horizontal",
+ colspan: 4,
+ },
+ ...(showCommunicationPreferences ? {} : { appliedCssClassNames: "is-hidden" }),
+ },
+ {
+ id: "prefSms",
+ name: "prefSms",
+ fieldType: "checkbox",
+ label: { value: "SMS" },
+ enum: ["true"],
+ type: "string",
+ properties: {
+ variant: "switch",
+ alignment: "horizontal",
+ colspan: 4,
+ },
+ ...(showCommunicationPreferences ? {} : { appliedCssClassNames: "is-hidden" }),
+ },
+ {
+ id: "prefWhatsapp",
+ name: "prefWhatsapp",
+ fieldType: "checkbox",
+ label: { value: "WhatsApp" },
+ enum: ["true"],
+ type: "string",
+ properties: {
+ variant: "switch",
+ alignment: "horizontal",
+ colspan: 4,
+ },
+ ...(showCommunicationPreferences ? {} : { appliedCssClassNames: "is-hidden" }),
+ },
+ {
+ id: "heading-know-you-better",
+ fieldType: "heading",
+ label: { value: "LET US KNOW YOU BETTER" },
+ appliedCssClassNames: withConditionalClasses("col-12 know-you-better-heading", isLumaVariant),
+ },
+ {
+ id: "shoeSize",
+ name: "shoeSize",
+ fieldType: "drop-down",
+ label: { value: "Shoe size" },
+ enum: shoeSizes,
+ enumNames: ["Select...", ...shoeSizes.slice(1)],
+ appliedCssClassNames: withConditionalClasses("col-6 luma-preference-field", isLumaVariant),
+ properties: { colspan: 6 },
+ },
+ {
+ id: "shirtSize",
+ name: "shirtSize",
+ fieldType: "drop-down",
+ label: { value: "Shirt size" },
+ enum: shirtSizes,
+ enumNames: ["Select...", "S", "M", "L", "XL", "XXL"],
+ appliedCssClassNames: withConditionalClasses("col-6 luma-preference-field", isLumaVariant),
+ properties: { colspan: 6 },
+ },
+ {
+ id: "favoriteColor",
+ name: "favoriteColor",
+ fieldType: "drop-down",
+ label: { value: "Favorite color" },
+ enum: favoriteColors,
+ enumNames: ["Select...", "Black", "Blue", "Green", "Orange", "Pink", "Purple", "Red", "White", "Yellow"],
+ appliedCssClassNames: withConditionalClasses("col-12 luma-preference-field", isLumaVariant),
+ properties: { colspan: 12 },
+ },
+ ...(isFrescopaVariant ? [{
+ id: "frescopaOwner",
+ name: "frescopaOwner",
+ fieldType: "drop-down",
+ label: { value: "Do you already have a Frescopa machine?" },
+ placeholder: "Do you already have a Frescopa machine?",
+ enum: ["yes", "no"],
+ enumNames: ["Yes", "No"],
+ type: "string",
+ properties: { colspan: 12 },
+ appliedCssClassNames: "frescopa-machine-field",
+ }] : []),
+ {
+ id: "submit-btn",
+ name: "submitButton",
+ fieldType: "button",
+ buttonType: "submit",
+ label: { value: "Submit" },
+ appliedCssClassNames: "submit-wrapper col-12",
+ },
+ ],
+ },
+ ],
+ };
+}
+
+export default async function decorate(block) {
+ const config = readBlockConfig(block) || {};
+ [...block.children].forEach((row) => { row.style.display = "none"; });
+
+ const formDef = buildCreateAccountFormDef(config);
+ const formContainer = document.createElement("div");
+ formContainer.className = "form";
+
+ const pre = document.createElement("pre");
+ const code = document.createElement("code");
+ code.textContent = JSON.stringify(formDef);
+ pre.append(code);
+ formContainer.append(pre);
+ block.replaceChildren(formContainer);
+
+ const formModule = await import("../form/form.js");
+ await formModule.default(formContainer);
+
+ setTimeout(() => {
+ applyButtonConfigToSubmitButton(block, config);
+ prePopulateFormFromDataLayer(block);
+ attachCreateAccountSubmitHandler(block, config);
+ const form = block.querySelector("form");
+ if (form) {
+ syncFormDataLayer(form, DEFAULT_FORM_FIELD_MAP);
+ attachLiveFormSync(form, DEFAULT_FORM_FIELD_MAP);
+ }
+
+ // Individual preference switches — ON by default
+ ['prefEmail', 'prefPhone', 'prefSms', 'prefWhatsapp'].forEach((name) => {
+ const input = block.querySelector(`input[name="${name}"]`);
+ if (input) input.checked = true;
+ });
+ }, 100);
+}
+
+
+function attachCreateAccountSubmitHandler(block, config) {
+ const form = block.querySelector("form");
+ if (!form) return;
+
+ const redirectUrl = config.redirecturl;
+ const isWkndFlyVariant = normalizeVariant(config.variant) === "wknd-fly";
+
+ form.addEventListener(
+ "submit",
+ async (event) => {
+ event.preventDefault();
+
+ const formData = {};
+ const allFields = form.querySelectorAll("input, select, textarea");
+ allFields.forEach((field) => {
+ const fieldName = field.name || field.id;
+ if (!fieldName) return;
+
+ if (field.type === "checkbox") {
+ formData[fieldName] = field.checked ? field.value || "true" : "";
+ } else {
+ formData[fieldName] = field.value;
+ }
+ });
+
+ const dobValue = String(formData.dateOfBirth || "").trim();
+ if (dobValue && !/^\d{4}-\d{2}-\d{2}$/.test(dobValue)) {
+ const dobField = form.querySelector('[name="dateOfBirth"]');
+ dobField?.classList.add("error");
+ return;
+ } else {
+ const dobField = form.querySelector('[name="dateOfBirth"]');
+ dobField?.classList.remove("error");
+ }
+
+ try {
+ const registrationData = {
+ ...formData,
+ communicationPreferences: {
+ email: formData.prefEmail === "true" ? "y" : "n",
+ phone: formData.prefPhone === "true" ? "y" : "n",
+ sms: formData.prefSms === "true" ? "y" : "n",
+ whatsapp: formData.prefWhatsapp === "true" ? "y" : "n",
+ },
+ registeredAt: new Date().toISOString(),
+ userId: generateUserId(),
+ };
+
+ localStorage.setItem(
+ "com.adobe.reactor.dataElements.Identities",
+ JSON.stringify({
+ Email: [
+ {
+ id: formData.email,
+ primary: true,
+ authenticatedState: "authenticated",
+ },
+ ],
+ })
+ );
+
+ // sessionStorage.setItem(
+ localStorage.setItem(
+ "com.adobe.reactor.dataElements.Identity Map",
+ JSON.stringify({
+ Email: [
+ {
+ id: formData.email,
+ primary: true,
+ authenticatedState: "authenticated",
+ },
+ ],
+ })
+ );
+
+ if (registrationData.email) {
+ try {
+ localStorage.setItem("com.adobe.reactor.dataElements.Profile - Email", registrationData.email);
+ if (typeof window._satellite !== "undefined" && typeof window._satellite.setVar === "function") {
+ window._satellite.setVar("Profile - Email", registrationData.email);
+ }
+ } catch (e) {
+ // ignore storage errors
+ }
+ }
+
+ localStorage.setItem("project_registered_user", JSON.stringify(registrationData));
+
+ if (window.dataLayer?.projectName === 'luma3') {
+ window.dataLayer.createAccountConsent = true;
+ }
+
+ if (isWkndFlyVariant && typeof window.updateDataLayer === "function") {
+ const isMember = (formData.wkndFlyMember || "").toLowerCase() === "member" ? "y" : "n";
+ window.updateDataLayer({
+ person: {
+ wkndFlyMember: formData.wkndFlyMember || "",
+ isMember: isMember === "y",
+ },
+ _demosystem4: {
+ identification: {
+ core: {
+ email: formData.email || null,
+ isMember,
+ },
+ },
+ },
+ });
+ }
+
+ syncFormDataLayer(form, DEFAULT_FORM_FIELD_MAP);
+ clearProductObject();
+
+ const submitBtn = form.querySelector("button[type='submit']");
+ const buttonDataUrl = submitBtn?.dataset?.buttonData?.trim();
+ if (buttonDataUrl && typeof window.updateDataLayer === 'function') {
+ const sheetData = await fetchButtonDataSheet(buttonDataUrl);
+ if (sheetData) window.updateDataLayer(sheetData);
+ }
+ const authoredEventType = submitBtn?.dataset?.buttonEventType?.trim();
+ if (authoredEventType) dispatchCustomEvent(authoredEventType);
+
+ const webhookUrl = submitBtn?.dataset?.buttonWebhookUrl?.trim();
+ const formId = submitBtn?.dataset?.buttonFormId?.trim();
+ if (webhookUrl) await submitToWebhook(form, webhookUrl, formId);
+
+ showSuccessMessage(form, "Account created successfully! Redirecting...");
+
+ const redirectTo = normalizeAemPath(redirectUrl);
+ if (redirectTo) setTimeout(() => { window.location.href = redirectTo; }, 2000);
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("Create account error:", error);
+ showErrorMessage(form, "Account creation failed. Please try again.");
+ }
+ }
+ );
+}
+
+function generateUserId() {
+ return `user_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
+}
+
+function showSuccessMessage(form, message) {
+ const existingMessages = form.querySelectorAll(".form-message");
+ existingMessages.forEach((msg) => msg.remove());
+
+ const messageEl = document.createElement("div");
+ messageEl.className = "form-message success";
+ messageEl.textContent = message;
+
+ const submitButton = form.querySelector('button[type="submit"]');
+ if (submitButton) {
+ submitButton.parentNode.insertBefore(messageEl, submitButton);
+ submitButton.disabled = true;
+ } else {
+ form.appendChild(messageEl);
+ }
+}
+
+function showErrorMessage(form, message) {
+ const existingMessages = form.querySelectorAll(".form-message");
+ existingMessages.forEach((msg) => msg.remove());
+
+ const messageEl = document.createElement("div");
+ messageEl.className = "form-message error";
+ messageEl.textContent = message;
+
+ const submitButton = form.querySelector('button[type="submit"]');
+ if (submitButton) {
+ submitButton.parentNode.insertBefore(messageEl, submitButton);
+ } else {
+ form.appendChild(messageEl);
+ }
+}
+
+function prePopulateFormFromDataLayer(block) {
+ if (!window.dataLayer) return;
+
+ const form = block.querySelector("form");
+ if (!form) return;
+
+ const getNestedProperty = (obj, path) => path.split(".").reduce((current, prop) => current?.[prop], obj);
+
+ Object.entries(DEFAULT_FORM_FIELD_MAP).forEach(([fieldName, path]) => {
+ const value = getNestedProperty(window.dataLayer, path);
+ if (value === undefined || value === null || value === "") return;
+
+ const field = form.querySelector(`[name="${fieldName}"]`);
+ if (!field) return;
+
+ if (field.type === "checkbox") {
+ field.checked = value === true || value === "true" || value === "y";
+ } else {
+ field.value = value;
+ }
+ });
+}
\ No newline at end of file
diff --git a/blocks/sign-in/_sign-in.json b/blocks/sign-in/_sign-in.json
new file mode 100644
index 0000000..cfa2b9d
--- /dev/null
+++ b/blocks/sign-in/_sign-in.json
@@ -0,0 +1,99 @@
+{
+ "definitions": [
+ {
+ "title": "Sign In",
+ "id": "sign-in",
+ "plugins": {
+ "xwalk": {
+ "page": {
+ "resourceType": "core/franklin/components/block/v1/block",
+ "template": {
+ "name": "Sign In",
+ "model": "sign-in",
+ "key-value": true
+ }
+ }
+ }
+ }
+ }
+ ],
+ "models": [
+ {
+ "id": "sign-in",
+ "fields": [
+ {
+ "component": "reference",
+ "valueType": "string",
+ "name": "logo-image",
+ "label": "Logo Image",
+ "multi": false,
+ "description": "Logo image to display above the sign-in form"
+ },
+ {
+ "component": "text",
+ "valueType": "string",
+ "name": "logo-image-alt",
+ "label": "Logo Image Alt Text",
+ "value": "",
+ "description": "Alt text for the logo image"
+ },
+ {
+ "component": "aem-content",
+ "valueType": "string",
+ "name": "sign-in-redirect-url",
+ "label": "Sign-In Redirect URL",
+ "description": "Page to redirect to after successful sign-in. Leave empty to stay on the home page."
+ },
+ {
+ "component": "aem-content",
+ "valueType": "string",
+ "name": "create-account-url",
+ "label": "Create Account URL",
+ "description": "Page for the 'Create an account' link. Use a full AEM path (e.g. /content//language-masters//registration)."
+ },
+ {
+ "component": "text",
+ "name": "buttoneventtype",
+ "label": "Button Event Type",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "text",
+ "name": "buttonwebhookurl",
+ "label": "Button Webhook URL",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "text",
+ "name": "buttonformid",
+ "label": "Button Form ID",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "aem-content",
+ "name": "buttondata",
+ "label": "Button Data Sheet",
+ "valueType": "string",
+ "description": "AEM sheet path whose key/value rows are sent on submit"
+ },
+ {
+ "component": "text",
+ "name": "custom-class",
+ "label": "Custom class",
+ "value": "",
+ "valueType": "string",
+ "description": "Space-separated class names to add to the block"
+ }
+ ]
+ }
+ ],
+ "filters": [
+ {
+ "id": "sign-in",
+ "components": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/blocks/sign-in/sign-in.css b/blocks/sign-in/sign-in.css
new file mode 100644
index 0000000..e328943
--- /dev/null
+++ b/blocks/sign-in/sign-in.css
@@ -0,0 +1,355 @@
+/* ============================================
+ Sign In Form Styling
+ ============================================ */
+
+main .sign-in {
+ width: 100%;
+ max-width: 480px;
+ margin: 60px auto;
+ padding: 60px 40px;
+ background: #fff;
+ border-radius: 12px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
+}
+
+main .sign-in .sign-in-form {
+ --form-columns: 12;
+ --form-button-background-color: #2874F0;
+ --form-button-background-hover-color: #0052cc;
+ --form-input-border-color: #cbd5e1;
+ --form-label-color: #1e293b;
+ --form-label-font-weight: 500;
+ --form-label-font-size: 16px;
+ --form-field-vert-gap: 16px;
+ --form-field-horz-gap: 24px;
+}
+
+main .sign-in .sign-in-logo {
+ margin-bottom: 24px;
+}
+
+main .sign-in .sign-in-logo img {
+ max-height: 48px;
+ width: auto;
+ display: block;
+}
+
+/* Ensure form element is block */
+main .sign-in form {
+ display: block;
+}
+
+/* Ensure panel (fieldset) is block layout for single column */
+main .sign-in .panel-wrapper {
+ display: block !important;
+ padding: 0 !important;
+ border: none !important;
+ margin: 0 !important;
+}
+
+/* Hide the label in heading wrappers */
+main .sign-in .heading-wrapper label {
+ display: none !important;
+}
+
+/* Heading wrapper */
+main .sign-in .heading-wrapper {
+ margin: 0 0 24px !important;
+ padding: 0 !important;
+ text-align: center;
+}
+
+/* Main heading - "Sign in to your account" */
+main .sign-in h2#heading-sign-in {
+ font-size: 2rem;
+ font-weight: 400;
+ margin: 0;
+ padding: 0;
+ color: #1e293b;
+ line-height: 1.2;
+ text-align: center;
+}
+
+/* Field wrappers */
+main .sign-in .field-wrapper {
+ margin: 0 0 16px;
+}
+
+/* Field labels */
+main .sign-in label {
+ color: #334155;
+ font-weight: 500;
+ font-size: 15px;
+ margin-bottom: 0;
+ display: inline-block;
+}
+
+/* Input fields */
+main .sign-in input[type="text"],
+main .sign-in input[type="email"],
+main .sign-in input[type="password"] {
+ width: 100%;
+ padding: 14px 16px;
+ border: 1px solid #cbd5e1;
+ border-radius: 6px;
+ font-size: 15px;
+ color: #1e293b;
+ background: #fff;
+ transition: all 0.2s ease;
+ margin-top: 6px;
+ margin-bottom: 0;
+}
+
+main .sign-in input[type="text"]:focus,
+main .sign-in input[type="email"]:focus,
+main .sign-in input[type="password"]:focus {
+ outline: none;
+ border-color: #2874F0;
+ box-shadow: 0 0 0 3px rgba(40, 116, 240, 0.1);
+}
+
+main .sign-in input[type="text"]:hover,
+main .sign-in input[type="email"]:hover,
+main .sign-in input[type="password"]:hover {
+ border-color: #94a3b8;
+}
+
+/* Placeholder styling */
+main .sign-in input::placeholder {
+ color: #94a3b8;
+ font-size: 14px;
+}
+
+/* Submit button */
+main .sign-in .submit-wrapper {
+ margin-top: 24px;
+}
+
+main .sign-in .form-message {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+main .sign-in .submit-wrapper button {
+ width: 100%;
+ background: #2874F0;
+ color: #fff;
+ padding: 16px 32px;
+ font-size: 15px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ box-shadow: 0 2px 8px rgba(40, 116, 240, 0.2);
+ text-align: center;
+}
+main .sign-in a.create-account-link::after {
+ display: none;
+}
+main .sign-in .submit-wrapper button:hover {
+ background: #0052cc;
+ box-shadow: 0 4px 12px rgba(40, 116, 240, 0.3);
+ transform: translateY(-1px);
+}
+
+main .sign-in .submit-wrapper button:active {
+ transform: translateY(0);
+ box-shadow: 0 1px 4px rgba(40, 116, 240, 0.2);
+}
+
+body.citi-signal-theme main .sign-in .submit-wrapper button {
+ background: var(--main-accent-color) !important;
+ color: var(--text-light, #fff) !important;
+ border-color: var(--main-accent-color) !important;
+ box-shadow: 0 2px 8px rgb(0 0 0 / 18%);
+ width: auto;
+ min-width: 220px;
+ margin: 0 auto;
+ display: block;
+ text-align: center;
+}
+
+body.citi-signal-theme main .sign-in .submit-wrapper button:hover {
+ background: var(--main-accent-hover, var(--link-hover-color)) !important;
+ box-shadow: 0 4px 12px rgb(0 0 0 / 24%);
+}
+
+body.citi-signal-theme main .sign-in .submit-wrapper button:active {
+ box-shadow: 0 1px 4px rgb(0 0 0 / 18%);
+}
+
+/* Required field indicator */
+main .sign-in [data-required="true"] > label::after {
+ content: " *";
+ color: #ef4444;
+ font-weight: 600;
+}
+
+/* Sign in links section */
+main .sign-in .sign-in-links {
+ margin-top: 28px;
+ text-align: center;
+}
+
+/* Divider */
+main .sign-in .sign-in-divider {
+ position: relative;
+ margin: 24px 0 16px;
+ text-align: center;
+}
+
+main .sign-in .sign-in-divider::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: #e2e8f0;
+}
+
+main .sign-in .sign-in-divider span {
+ position: relative;
+ display: inline-block;
+ padding: 0 16px;
+ background: #fff;
+ color: #64748b;
+ font-size: 14px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+/* Create account link */
+main .sign-in .create-account-link {
+ display: inline-block;
+ width: 100%;
+ padding: 14px 32px;
+ border: 2px solid #2874F0;
+ border-radius: 6px;
+ color: #2874F0;
+ font-size: 15px;
+ font-weight: 600;
+ text-decoration: none;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ transition: all 0.2s ease;
+ background: #fff;
+ text-align: center;
+}
+
+main .sign-in .create-account-link:hover {
+ background: #2874F0;
+ color: #fff;
+ box-shadow: 0 2px 8px rgba(40, 116, 240, 0.2);
+ transform: translateY(-1px);
+}
+
+main .sign-in .create-account-link:active {
+ transform: translateY(0);
+}
+
+body.citi-signal-theme main .sign-in .create-account-link {
+ background: #ffffff;
+ color: #000000;
+ border: 2px solid #000000;
+ text-align: center;
+ width: auto;
+ min-width: 220px;
+ max-width: 320px;
+ margin: 0 auto;
+ display: block;
+}
+
+body.citi-signal-theme main .sign-in .create-account-link:hover {
+ background: #000000;
+ color: #ffffff;
+ border-color: #000000;
+ box-shadow: 0 2px 8px rgb(0 0 0 / 20%);
+}
+
+body.carvelo-theme main .sign-in .sign-in-divider {
+ display: none;
+}
+
+body.carvelo-theme main .sign-in .sign-in-links {
+ margin-top: 16px;
+}
+
+body.carvelo-theme main .sign-in .create-account-link {
+ display: inline;
+ width: auto;
+ padding: 0;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ color: #000000;
+ font-size: 15px;
+ font-weight: 400;
+ text-transform: none;
+ letter-spacing: 0;
+ text-decoration: underline;
+ box-shadow: none;
+}
+
+body.carvelo-theme main .sign-in .create-account-link:hover {
+ background: transparent !important;
+ color: #000000 !important;
+ box-shadow: none;
+ transform: none;
+ text-decoration: underline;
+}
+
+/* Responsive: Mobile */
+@media (width <= 768px) {
+ main .sign-in {
+ margin: 40px 20px;
+ padding: 40px 24px;
+ border-radius: 8px;
+ }
+
+ main .sign-in h2#heading-sign-in {
+ font-size: 1.75rem;
+ }
+
+ main .sign-in .field-wrapper {
+ margin: 0 0 16px;
+ }
+
+ main .sign-in .sign-in-links {
+ margin-top: 24px;
+ }
+
+ main .sign-in .sign-in-divider {
+ margin: 20px 0 16px;
+ }
+}
+
+@media (width <= 480px) {
+ main .sign-in {
+ margin: 30px 16px;
+ padding: 30px 20px;
+ }
+
+ main .sign-in h2#heading-sign-in {
+ font-size: 1.5rem;
+ }
+}
+
+/* Hide button config fields so they don't appear in HTML */
+.sign-in [data-aue-prop="buttoneventtype"],
+.sign-in [data-aue-prop="buttonwebhookurl"],
+.sign-in [data-aue-prop="buttonformid"],
+.sign-in [data-aue-prop="buttondata"] {
+ display: none !important;
+}
+.sign-in div:has(> [data-aue-prop="buttoneventtype"]),
+.sign-in div:has(> [data-aue-prop="buttonwebhookurl"]),
+.sign-in div:has(> [data-aue-prop="buttonformid"]),
+.sign-in div:has(> [data-aue-prop="buttondata"]) {
+ display: none !important;
+}
\ No newline at end of file
diff --git a/blocks/sign-in/sign-in.js b/blocks/sign-in/sign-in.js
new file mode 100644
index 0000000..d84d529
--- /dev/null
+++ b/blocks/sign-in/sign-in.js
@@ -0,0 +1,375 @@
+import { normalizeAemPath } from "../../scripts/scripts.js";
+import { readBlockConfig } from "../../scripts/aem.js";
+import { dispatchCustomEvent } from "../../scripts/custom-events.js";
+import { syncFormDataLayer, DEFAULT_FORM_FIELD_MAP, attachLiveFormSync, submitToWebhook, fetchButtonDataSheet } from "../../scripts/form-data-layer.js";
+
+function applyButtonConfigToSubmitButton(block, config) {
+ const submitButton = block.querySelector("form button[type='submit']");
+ if (!submitButton) return;
+ const eventType = config.buttoneventtype ?? config['button-event-type'];
+ if (eventType && String(eventType).trim()) submitButton.dataset.buttonEventType = String(eventType).trim();
+ const webhookUrl = config.buttonwebhookurl ?? config['button-webhook-url'];
+ if (webhookUrl && String(webhookUrl).trim()) submitButton.dataset.buttonWebhookUrl = String(webhookUrl).trim();
+ const formId = config.buttonformid ?? config['button-form-id'];
+ if (formId && String(formId).trim()) submitButton.dataset.buttonFormId = String(formId).trim();
+ const buttonData = config.buttondata ?? config['button-data'];
+ if (buttonData && String(buttonData).trim()) submitButton.dataset.buttonData = String(buttonData).trim();
+}
+
+export default async function decorate(block) {
+ const config = readBlockConfig(block) || {};
+ /* Hide button config rows on published/live */
+ [...block.children].forEach((row) => { row.style.display = 'none'; });
+
+ // Prepare logo image if authored
+ const logoImage = config.logoImage ?? config['logo-image'];
+ const logoAlt = config.logoImageAlt ?? config['logo-image-alt'] ?? '';
+ let logoWrapper = null;
+ if (logoImage) {
+ logoWrapper = document.createElement('div');
+ logoWrapper.className = 'sign-in-logo';
+ const img = document.createElement('img');
+ img.src = logoImage;
+ img.alt = logoAlt;
+ logoWrapper.append(img);
+ }
+
+ // Set authorable redirect URLs
+ const signInRedirectUrl = normalizeAemPath(config['sign-in-redirect-url']);
+ block.dataset.signInRedirectUrl = signInRedirectUrl;
+
+ // Set authorable create account URL
+ const createAccountUrl = normalizeAemPath(config['create-account-url']);
+ block.dataset.createAccountUrl = createAccountUrl;
+
+ // Build Adaptive Form definition for Sign In
+ const formDef = {
+ id: "sign-in",
+ fieldType: "form",
+ appliedCssClassNames: "sign-in-form",
+ items: [
+ {
+ id: "heading-sign-in",
+ fieldType: "heading",
+ label: { value: "Sign in to your account" },
+ appliedCssClassNames: "col-12",
+ },
+ {
+ id: "panel-main",
+ name: "main",
+ fieldType: "panel",
+ items: [
+ {
+ id: "email",
+ name: "email",
+ fieldType: "text-input",
+ label: { value: "Email address" },
+ required: true,
+ autoComplete: "email",
+ properties: { colspan: 12 },
+ },
+ {
+ id: "password",
+ name: "password",
+ fieldType: "password-input",
+ label: { value: "Password" },
+ required: true,
+ autoComplete: "current-password",
+ properties: { colspan: 12 },
+ },
+ {
+ id: "sign-in-btn",
+ name: "signInButton",
+ fieldType: "button",
+ buttonType: "submit",
+ label: { value: "SIGN IN" },
+ appliedCssClassNames: "submit-wrapper col-12",
+ },
+ ],
+ },
+ ],
+ };
+
+ // Create a child form block that reuses the existing form renderer
+ const formContainer = document.createElement("div");
+ formContainer.className = "form";
+
+ const pre = document.createElement("pre");
+ const code = document.createElement("code");
+ code.textContent = JSON.stringify(formDef);
+ pre.append(code);
+ formContainer.append(pre);
+ block.replaceChildren(formContainer);
+ if (logoWrapper) {
+ block.prepend(logoWrapper);
+ }
+
+ const formModule = await import("../form/form.js");
+ await formModule.default(formContainer);
+
+ // Wait for form to be rendered before attaching handlers
+ setTimeout(() => {
+ applyButtonConfigToSubmitButton(block, config);
+ attachSignInHandler(block);
+ addCreateAccountLink(block, config);
+ }, 100);
+}
+
+/**
+ * Validates email format
+ * @param {string} email - Email to validate
+ * @returns {boolean} True if valid email
+ */
+function isValidEmail(email) {
+ if (!email || typeof email !== "string") return false;
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailRegex.test(email.trim());
+}
+
+/**
+ * Prefills the email field using stored registration data
+ * @param {HTMLFormElement} form - The sign-in form element
+ */
+function prefillEmail(form) {
+ if (!form) return;
+ const emailInput = form.querySelector('input[name="email"]');
+ if (!emailInput) return;
+
+ const candidateKeys = ["com.adobe.reactor.dataElements.Profile - Email"];
+
+ let storedEmail = "";
+
+ for (const key of candidateKeys) {
+ const value = localStorage.getItem(key);
+ if (value && typeof value === "string" && value.trim()) {
+ storedEmail = value;
+ break;
+ }
+ }
+
+ if (!storedEmail) {
+ try {
+ const reg = JSON.parse(localStorage.getItem("project_registered_user") || "{}");
+ if (reg.email) storedEmail = reg.email;
+ } catch (e) {
+ // Ignore JSON parse errors
+ }
+ }
+
+ if (storedEmail) {
+ const cleanedEmail = storedEmail.replace(/[\*\"\']/g, "").trim();
+ if (isValidEmail(cleanedEmail)) {
+ emailInput.value = cleanedEmail;
+ }
+ }
+}
+
+/**
+ * Attaches sign-in form submission handler
+ * @param {HTMLElement} block - The sign-in block
+ */
+function attachSignInHandler(block) {
+ let form = block.querySelector("form");
+ if (!form) {
+ console.warn("Sign-in form not found");
+ return;
+ }
+
+ // Clone the form to remove all existing event listeners (including form component's submit handler)
+ const newForm = form.cloneNode(true);
+ form.parentNode.replaceChild(newForm, form);
+ form = newForm; // Update reference to use the new form
+
+ prefillEmail(form);
+ syncFormDataLayer(form, DEFAULT_FORM_FIELD_MAP);
+ attachLiveFormSync(form, DEFAULT_FORM_FIELD_MAP);
+
+ // Now attach our custom submit handler to the clean form
+ form.addEventListener("submit", async (event) => {
+ event.preventDefault();
+
+ // Get form values
+ const emailInput = form.querySelector('input[name="email"]');
+ const passwordInput = form.querySelector('input[name="password"]');
+
+ if (!emailInput || !passwordInput) {
+ showErrorMessage(form, "Form fields not found");
+ return;
+ }
+
+ const enteredEmail = emailInput.value.trim();
+ const enteredPassword = passwordInput.value;
+
+ // Validate email format
+ if (!isValidEmail(enteredEmail)) {
+ showErrorMessage(form, "Please enter a valid email address");
+ emailInput.focus();
+ return;
+ }
+
+ // Validate password is not empty
+ if (!enteredPassword || enteredPassword.trim() === "") {
+ showErrorMessage(form, "Please enter your password");
+ passwordInput.focus();
+ return;
+ }
+
+ // Sign-in successful - Load user data from registration
+ try {
+ // Set authentication flag in localStorage
+ localStorage.setItem("project_user_logged_in", "true");
+
+ localStorage.setItem(
+ "com.adobe.reactor.dataElements.Identities",
+ JSON.stringify({
+ Email: [
+ {
+ id: enteredEmail,
+ primary: true,
+ authenticatedState: "authenticated",
+ },
+ ],
+ })
+ );
+
+ localStorage.setItem(
+ "com.adobe.reactor.dataElements.Identity Map",
+ JSON.stringify({
+ Email: [
+ {
+ id: enteredEmail,
+ primary: true,
+ authenticatedState: "authenticated",
+ },
+ ],
+ })
+ );
+
+ // If button has an authored event type, fire it (for Launch, same pattern as flight-search)
+ syncFormDataLayer(form, DEFAULT_FORM_FIELD_MAP);
+ const submitBtn = form.querySelector("button[type='submit']");
+ const buttonDataUrl = submitBtn?.dataset?.buttonData?.trim();
+ if (buttonDataUrl && typeof window.updateDataLayer === 'function') {
+ const sheetData = await fetchButtonDataSheet(buttonDataUrl);
+ if (sheetData) window.updateDataLayer(sheetData);
+ }
+ const authoredEventType = submitBtn?.dataset?.buttonEventType?.trim();
+ if (authoredEventType) {
+ dispatchCustomEvent(authoredEventType);
+ }
+
+ const webhookUrl = submitBtn?.dataset?.buttonWebhookUrl?.trim();
+ const formId = submitBtn?.dataset?.buttonFormId?.trim();
+ if (webhookUrl) await submitToWebhook(form, webhookUrl, formId);
+
+ // Show success message
+ showSuccessMessage(form, "Sign-in successful! Redirecting...");
+
+ // Redirect to authored URL or default to home page after delay (allows custom/analytics calls to complete)
+ const redirectUrl = block.dataset.signInRedirectUrl;
+ if (redirectUrl) setTimeout(() => { window.location.href = redirectUrl; }, 2000);
+ } catch (error) {
+ console.error("Sign-in error:", error);
+ showErrorMessage(form, "Sign-in failed. Please try again.");
+ }
+ });
+}
+
+/**
+ * Shows success message
+ * @param {HTMLFormElement} form - The form element
+ * @param {string} message - Success message
+ */
+function showSuccessMessage(form, message) {
+ // Remove any existing messages
+ const existingMessages = form.querySelectorAll(".form-message");
+ existingMessages.forEach((msg) => msg.remove());
+
+ const messageEl = document.createElement("div");
+ messageEl.className = "form-message success";
+ messageEl.textContent = message;
+ messageEl.style.cssText = `
+ padding: 15px;
+ margin: 20px 0;
+ background-color: #4caf50;
+ color: white;
+ border-radius: 4px;
+ text-align: center;
+ font-weight: bold;
+ animation: slideDown 0.3s ease-out;
+ `;
+
+ const submitButton = form.querySelector('button[type="submit"]');
+ if (submitButton) {
+ const submitWrapper = submitButton.closest('.submit-wrapper') || submitButton.parentNode;
+ submitWrapper.parentNode.insertBefore(messageEl, submitWrapper);
+ submitButton.disabled = true;
+ } else {
+ form.appendChild(messageEl);
+ }
+}
+
+/**
+ * Shows error message
+ * @param {HTMLFormElement} form - The form element
+ * @param {string} message - Error message
+ */
+function showErrorMessage(form, message) {
+ // Remove any existing messages
+ const existingMessages = form.querySelectorAll(".form-message");
+ existingMessages.forEach((msg) => msg.remove());
+
+ const messageEl = document.createElement("div");
+ messageEl.className = "form-message error";
+ messageEl.textContent = message;
+ messageEl.style.cssText = `
+ padding: 15px;
+ margin: 20px 0;
+ background-color: #f44336;
+ color: white;
+ border-radius: 4px;
+ text-align: center;
+ font-weight: bold;
+ animation: slideDown 0.3s ease-out;
+ `;
+
+ const submitButton = form.querySelector('button[type="submit"]');
+ if (submitButton) {
+ const submitWrapper = submitButton.closest('.submit-wrapper') || submitButton.parentNode;
+ submitWrapper.parentNode.insertBefore(messageEl, submitWrapper);
+ } else {
+ form.appendChild(messageEl);
+ }
+
+ // Auto-remove error message after 5 seconds
+ setTimeout(() => {
+ messageEl.remove();
+ }, 5000);
+}
+
+function addCreateAccountLink(block, config = {}) {
+ const formElement = block.querySelector("form");
+ if (!formElement) return;
+
+ // Create "Create account" link section
+ const linkSection = document.createElement("div");
+ linkSection.className = "sign-in-links";
+
+ // Divider
+ const divider = document.createElement("div");
+ divider.className = "sign-in-divider";
+ divider.innerHTML = "or ";
+
+ // Create account link only when a full AEM path is authored
+ const createAccountLink = document.createElement("a");
+ createAccountLink.className = "create-account-link";
+ const isCarveloTheme = document.body.classList.contains("carvelo-theme");
+ createAccountLink.textContent = isCarveloTheme ? "Don't have an account? Register" : "Create an account";
+
+ const registrationPath = (config['create-account-url'] ?? block.dataset.createAccountUrl ?? '').toString().trim();
+ if (!registrationPath.startsWith('/content/') && !/^https?:\/\//i.test(registrationPath)) return;
+ createAccountLink.href = normalizeAemPath(registrationPath);
+
+ linkSection.append(divider, createAccountLink);
+ formElement.parentElement.append(linkSection);
+}
\ No newline at end of file
diff --git a/component-definition.json b/component-definition.json
index 8f4edd3..cc1920f 100644
--- a/component-definition.json
+++ b/component-definition.json
@@ -962,6 +962,53 @@
}
}
},
+ {
+ "title": "Checkout",
+ "id": "checkout",
+ "plugins": {
+ "xwalk": {
+ "page": {
+ "resourceType": "core/franklin/components/block/v1/block",
+ "template": {
+ "name": "Checkout",
+ "model": "checkout",
+ "key-value": true
+ }
+ }
+ }
+ }
+ },
+ {
+ "title": "Confirmation",
+ "id": "confirmation",
+ "plugins": {
+ "xwalk": {
+ "page": {
+ "resourceType": "core/franklin/components/block/v1/block",
+ "template": {
+ "name": "Confirmation",
+ "model": "confirmation"
+ }
+ }
+ }
+ }
+ },
+ {
+ "title": "Sign In",
+ "id": "sign-in",
+ "plugins": {
+ "xwalk": {
+ "page": {
+ "resourceType": "core/franklin/components/block/v1/block",
+ "template": {
+ "name": "Sign In",
+ "model": "sign-in",
+ "key-value": true
+ }
+ }
+ }
+ }
+ },
{
"...": "../blocks/*/_*.json#/definitions"
}
diff --git a/component-filters.json b/component-filters.json
index e877ffa..b643437 100644
--- a/component-filters.json
+++ b/component-filters.json
@@ -35,7 +35,11 @@
"form",
"forex",
"embed-adaptive-form",
- "find-a-doctor"
+ "find-a-doctor",
+ "create-account"
+ "sign-in"
+ "confirmation"
+ "checkout"
]
},
{
diff --git a/component-models.json b/component-models.json
index 724eb16..2813ebe 100644
--- a/component-models.json
+++ b/component-models.json
@@ -7078,5 +7078,137 @@
"condition": { "==": [{ "var": "enableSubmitAction" }, true] }
}
]
+ },
+ {
+ "id": "checkout",
+ "fields": [
+ {
+ "component": "text",
+ "name": "buttoneventtype",
+ "label": "Button Event Type",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "text",
+ "name": "buttonwebhookurl",
+ "label": "Button Webhook URL",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "aem-content",
+ "name": "buttondata",
+ "label": "Button Data Sheet",
+ "valueType": "string",
+ "description": "AEM sheet path whose key/value rows are sent on submit"
+ },
+ {
+ "component": "text",
+ "name": "buttonformid",
+ "label": "Button Form ID",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "aem-content",
+ "valueType": "string",
+ "name": "confirmationpath",
+ "label": "Confirmation Page Path",
+ "description": "Page to redirect to after purchase confirmation. Defaults to /en/confirmation if not set."
+ },
+ {
+ "component": "text",
+ "name": "custom-class",
+ "label": "Custom class",
+ "value": "",
+ "valueType": "string",
+ "description": "Space-separated class names to add to the block"
+ }
+ ]
+ },
+ {
+ "id": "confirmation",
+ "fields": [
+ {
+ "component": "text",
+ "name": "custom-class",
+ "label": "Custom class",
+ "value": "",
+ "valueType": "string",
+ "description": "Space-separated class names to add to the block"
+ }
+ ]
+ },
+ {
+ "id": "sign-in",
+ "fields": [
+ {
+ "component": "reference",
+ "valueType": "string",
+ "name": "logo-image",
+ "label": "Logo Image",
+ "multi": false,
+ "description": "Logo image to display above the sign-in form"
+ },
+ {
+ "component": "text",
+ "valueType": "string",
+ "name": "logo-image-alt",
+ "label": "Logo Image Alt Text",
+ "value": "",
+ "description": "Alt text for the logo image"
+ },
+ {
+ "component": "aem-content",
+ "valueType": "string",
+ "name": "sign-in-redirect-url",
+ "label": "Sign-In Redirect URL",
+ "description": "Page to redirect to after successful sign-in. Leave empty to stay on the home page."
+ },
+ {
+ "component": "aem-content",
+ "valueType": "string",
+ "name": "create-account-url",
+ "label": "Create Account URL",
+ "description": "Page for the 'Create an account' link. Use a full AEM path (e.g. /content//language-masters//registration)."
+ },
+ {
+ "component": "text",
+ "name": "buttoneventtype",
+ "label": "Button Event Type",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "text",
+ "name": "buttonwebhookurl",
+ "label": "Button Webhook URL",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "text",
+ "name": "buttonformid",
+ "label": "Button Form ID",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "aem-content",
+ "name": "buttondata",
+ "label": "Button Data Sheet",
+ "valueType": "string",
+ "description": "AEM sheet path whose key/value rows are sent on submit"
+ },
+ {
+ "component": "text",
+ "name": "custom-class",
+ "label": "Custom class",
+ "value": "",
+ "valueType": "string",
+ "description": "Space-separated class names to add to the block"
+ }
+ ]
}
]
diff --git a/fstab.yaml b/fstab.yaml
index d6d8d73..34846f9 100644
--- a/fstab.yaml
+++ b/fstab.yaml
@@ -1,5 +1,5 @@
mountpoints:
/:
- url: "https://author-p153659-e1620914.adobeaemcloud.com/bin/franklin.delivery/AEMXSC/RefDemoEDS/main"
+ url: "https://author-p35437-e2207568.adobeaemcloud.com/bin/franklin.delivery/ankitasingh-cloud/Ankita-RefDemoEDS-Dev/main"
type: "markup"
suffix: ".html"
diff --git a/models/_component-filters.json b/models/_component-filters.json
index 7e23a69..4f48278 100644
--- a/models/_component-filters.json
+++ b/models/_component-filters.json
@@ -35,7 +35,11 @@
"text",
"title",
"title-block",
- "video"
+ "video",
+ "create-account"
+ "sign-in"
+ "confirmation"
+ "checkout"
]
},
{
diff --git a/models/_component-models.json b/models/_component-models.json
index 31459e8..c2f46e8 100644
--- a/models/_component-models.json
+++ b/models/_component-models.json
@@ -266,6 +266,138 @@
}
]
},
+ {
+ "id": "checkout",
+ "fields": [
+ {
+ "component": "text",
+ "name": "buttoneventtype",
+ "label": "Button Event Type",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "text",
+ "name": "buttonwebhookurl",
+ "label": "Button Webhook URL",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "aem-content",
+ "name": "buttondata",
+ "label": "Button Data Sheet",
+ "valueType": "string",
+ "description": "AEM sheet path whose key/value rows are sent on submit"
+ },
+ {
+ "component": "text",
+ "name": "buttonformid",
+ "label": "Button Form ID",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "aem-content",
+ "valueType": "string",
+ "name": "confirmationpath",
+ "label": "Confirmation Page Path",
+ "description": "Page to redirect to after purchase confirmation. Defaults to /en/confirmation if not set."
+ },
+ {
+ "component": "text",
+ "name": "custom-class",
+ "label": "Custom class",
+ "value": "",
+ "valueType": "string",
+ "description": "Space-separated class names to add to the block"
+ }
+ ]
+ },
+ {
+ "id": "confirmation",
+ "fields": [
+ {
+ "component": "text",
+ "name": "custom-class",
+ "label": "Custom class",
+ "value": "",
+ "valueType": "string",
+ "description": "Space-separated class names to add to the block"
+ }
+ ]
+ },
+ {
+ "id": "sign-in",
+ "fields": [
+ {
+ "component": "reference",
+ "valueType": "string",
+ "name": "logo-image",
+ "label": "Logo Image",
+ "multi": false,
+ "description": "Logo image to display above the sign-in form"
+ },
+ {
+ "component": "text",
+ "valueType": "string",
+ "name": "logo-image-alt",
+ "label": "Logo Image Alt Text",
+ "value": "",
+ "description": "Alt text for the logo image"
+ },
+ {
+ "component": "aem-content",
+ "valueType": "string",
+ "name": "sign-in-redirect-url",
+ "label": "Sign-In Redirect URL",
+ "description": "Page to redirect to after successful sign-in. Leave empty to stay on the home page."
+ },
+ {
+ "component": "aem-content",
+ "valueType": "string",
+ "name": "create-account-url",
+ "label": "Create Account URL",
+ "description": "Page for the 'Create an account' link. Use a full AEM path (e.g. /content//language-masters//registration)."
+ },
+ {
+ "component": "text",
+ "name": "buttoneventtype",
+ "label": "Button Event Type",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "text",
+ "name": "buttonwebhookurl",
+ "label": "Button Webhook URL",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "text",
+ "name": "buttonformid",
+ "label": "Button Form ID",
+ "value": "",
+ "valueType": "string"
+ },
+ {
+ "component": "aem-content",
+ "name": "buttondata",
+ "label": "Button Data Sheet",
+ "valueType": "string",
+ "description": "AEM sheet path whose key/value rows are sent on submit"
+ },
+ {
+ "component": "text",
+ "name": "custom-class",
+ "label": "Custom class",
+ "value": "",
+ "valueType": "string",
+ "description": "Space-separated class names to add to the block"
+ }
+ ]
+ },
{
"...": "../blocks/*/_*.json#/models"
}
diff --git a/paths.json b/paths.json
index fbc8a39..d7a2326 100644
--- a/paths.json
+++ b/paths.json
@@ -1,22 +1,14 @@
{
"mappings": [
- "/content/wknd-universal/language-masters/en:/",
- "/content/wknd-universal/language-masters/en/:/en/",
- "/content/wknd-universal/language-masters/fr/:/fr/",
- "/content/wknd-universal/language-masters/fr:/fr",
- "/content/wknd-universal/language-masters/es/:/es/",
- "/content/wknd-universal/language-masters/es:/es",
- "/content/wknd-universal/language-masters/ja/:/ja/",
- "/content/wknd-universal/language-masters/ja:/ja",
- "/content/wknd-universal/language-masters/de/:/de/",
- "/content/wknd-universal/language-masters/de:/de",
- "/content/wknd-universal/configuration:/.helix/config.json",
- "/content/wknd-universal/metadata:/metadata.json",
- "/content/wknd-universal/redirects:/redirects.json",
- "/content/wknd-universal/placeholders:/placeholders.json"
+ "/content/frescopa-demo1/language-masters/en:/",
+ "/content/frescopa-demo1/language-masters/en/:/en/",
+ "/content/frescopa-demo1/configuration:/.helix/config.json",
+ "/content/frescopa-demo1/metadata:/metadata.json",
+ "/content/frescopa-demo1/redirects:/redirects.json",
+ "/content/frescopa-demo1/placeholders:/placeholders.json"
],
"includes": [
- "/content/wknd-universal/",
- "/content/dam/wknd-universal/"
+ "/content/frescopa-demo1/",
+ "/content/dam/frescopa-demo1/"
]
}
diff --git a/scripts/custom-events.js b/scripts/custom-events.js
new file mode 100644
index 0000000..914a565
--- /dev/null
+++ b/scripts/custom-events.js
@@ -0,0 +1,113 @@
+const LAUNCH_QUEUE_STORAGE_KEY = 'project_launch_event_queue';
+const LAUNCH_QUEUE_TTL_MS = 30 * 60 * 1000;
+const LAUNCH_QUEUE_MAX_EVENTS = 100;
+const AT_VIEW_START_DELAY_MS = 1000;
+
+function isLaunchReady() {
+ return Boolean(
+ (typeof window._satellite !== 'undefined' && window._satellite)
+ || window._launchReady === true,
+ );
+}
+
+function getDataLayerSnapshot() {
+ try {
+ return typeof window.dataLayer !== 'undefined'
+ ? JSON.parse(JSON.stringify(window.dataLayer))
+ : null;
+ } catch (error) {
+ console.warn('[Launch custom event] Failed to clone dataLayer snapshot:', error);
+ return null;
+ }
+}
+
+function emitEvent(name, dataLayerSnapshot, meta = {}) {
+ console.debug('[Launch custom event] Firing:', name, '| meta:', meta, '| dataLayer:', dataLayerSnapshot);
+ document.dispatchEvent(
+ new CustomEvent(name, {
+ bubbles: true,
+ detail: {
+ dataLayer: dataLayerSnapshot,
+ ...meta,
+ },
+ }),
+ );
+}
+
+function readLaunchQueue() {
+ try {
+ const raw = sessionStorage.getItem(LAUNCH_QUEUE_STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ const now = Date.now();
+ return parsed
+ .filter((item) => item && typeof item.name === 'string' && item.timestamp)
+ .filter((item) => now - Number(item.timestamp) <= LAUNCH_QUEUE_TTL_MS);
+ } catch (error) {
+ console.warn('[Launch queue] Failed to read queue:', error);
+ return [];
+ }
+}
+
+function writeLaunchQueue(queue) {
+ try {
+ sessionStorage.setItem(LAUNCH_QUEUE_STORAGE_KEY, JSON.stringify(queue));
+ } catch (error) {
+ console.warn('[Launch queue] Failed to write queue:', error);
+ }
+}
+
+function enqueueLaunchEvent(name, dataLayerSnapshot) {
+ const queue = readLaunchQueue();
+ queue.push({
+ name,
+ dataLayer: dataLayerSnapshot,
+ timestamp: Date.now(),
+ path: `${window.location.pathname}${window.location.search}`,
+ });
+ const trimmedQueue = queue.slice(-LAUNCH_QUEUE_MAX_EVENTS);
+ writeLaunchQueue(trimmedQueue);
+ console.debug('[Launch queue] Queued event:', name, '| queue size:', trimmedQueue.length);
+}
+
+export function flushQueuedLaunchEvents() {
+ if (!isLaunchReady()) return 0;
+ const queue = readLaunchQueue();
+ if (!queue.length) return 0;
+ writeLaunchQueue([]);
+ queue.forEach((item) => {
+ emitEvent(item.name, item.dataLayer ?? null, {
+ replayed: true,
+ originalTimestamp: item.timestamp,
+ originalPath: item.path || '',
+ });
+ });
+ console.debug('[Launch queue] Flushed events:', queue.length);
+ return queue.length;
+}
+
+export function dispatchCustomEvent(eventName, options = {}) {
+ const name = eventName && String(eventName).trim();
+ if (!name) return false;
+
+ if (name === 'at-view-start' && options.skipAtViewStartDelay !== true) {
+ setTimeout(() => {
+ dispatchCustomEvent(name, { ...options, skipAtViewStartDelay: true });
+ }, AT_VIEW_START_DELAY_MS);
+ return false;
+ }
+
+ const dataLayerSnapshot = Object.prototype.hasOwnProperty.call(options, 'dataLayerSnapshot')
+ ? options.dataLayerSnapshot
+ : getDataLayerSnapshot();
+
+ if (!isLaunchReady()) {
+ if (options.allowQueue !== false) enqueueLaunchEvent(name, dataLayerSnapshot);
+ return false;
+ }
+
+ flushQueuedLaunchEvents();
+ emitEvent(name, dataLayerSnapshot, { replayed: false });
+ return true;
+}
\ No newline at end of file
diff --git a/scripts/form-data-layer.js b/scripts/form-data-layer.js
new file mode 100644
index 0000000..2e2d7db
--- /dev/null
+++ b/scripts/form-data-layer.js
@@ -0,0 +1,117 @@
+function hasData(value) {
+ if (value === null || value === undefined) return false;
+ if (Array.isArray(value)) return value.length > 0;
+ if (typeof value === 'string') return value.trim() !== '';
+ return true;
+}
+
+function normalizeValue(value) {
+ if (typeof value === 'string') return value.trim();
+ return value;
+}
+
+function setNestedValue(target, path, value) {
+ const parts = path.split('.');
+ let current = target;
+ for (let i = 0; i < parts.length; i += 1) {
+ const part = parts[i];
+ if (i === parts.length - 1) {
+ current[part] = value;
+ } else {
+ if (!current[part] || typeof current[part] !== 'object') {
+ current[part] = {};
+ }
+ current = current[part];
+ }
+ }
+}
+
+export const DEFAULT_FORM_FIELD_MAP = {
+ firstName: 'person.name.firstName',
+ lastName: 'person.name.lastName',
+ email: 'personalEmail.address',
+ phone: 'mobilePhone.number',
+ streetAddress: 'homeAddress.street1',
+ city: 'homeAddress.city',
+ zipCode: 'homeAddress.postalCode',
+ state: 'homeAddress.state',
+ country: 'homeAddress.country',
+ dateOfBirth: 'person.birthDate',
+ isMember: 'person.isMember',
+ wkndFlyMember: 'person.wkndFlyMember',
+ emailComm: 'consents.marketing.email.val',
+ prefEmail: 'consents.marketing.email.val',
+ prefPhone: 'consents.marketing.call.val',
+ prefSms: 'consents.marketing.sms.val',
+};
+
+export function buildFormDataLayerUpdates(form, fieldMap) {
+ if (!form || !fieldMap) return null;
+ const updates = {};
+ Object.entries(fieldMap).forEach(([fieldName, path]) => {
+ let element = form.querySelector(`[name="${fieldName}"]`);
+ if (!element && form.elements) {
+ element = form.elements[fieldName];
+ }
+ if (!element) return;
+ const rawValue = element.type === 'checkbox' ? (element.checked ? element.value : '') : element.value;
+ const value = normalizeValue(rawValue);
+ if (!hasData(value)) return;
+ setNestedValue(updates, path, value);
+ if (fieldName === 'isMember' && typeof value === 'string') {
+ setNestedValue(updates, 'person.isMember', value);
+ }
+ });
+ return Object.keys(updates).length ? updates : null;
+}
+
+export function syncFormDataLayer(form, fieldMap) {
+ if (!form || !fieldMap || typeof window === 'undefined' || !window.updateDataLayer) return;
+ const updates = buildFormDataLayerUpdates(form, fieldMap);
+ if (!updates) return;
+ window.updateDataLayer(updates);
+}
+
+export async function submitToWebhook(form, webhookUrl, formId) {
+ const payload = buildFormDataLayerUpdates(form, DEFAULT_FORM_FIELD_MAP) || {};
+ if (formId) payload['form-name'] = formId;
+ try {
+ await fetch(webhookUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error('Webhook submission failed:', e);
+ }
+}
+
+export async function fetchButtonDataSheet(url) {
+ if (!url) return null;
+ try {
+ const fetchUrl = url.endsWith('.json') ? url : `${url}.json`;
+ const resp = await fetch(fetchUrl);
+ if (!resp.ok) return null;
+ const json = await resp.json();
+ if (!Array.isArray(json.data)) return null;
+ const result = {};
+ json.data.forEach(({ key, value }) => { if (key) setNestedValue(result, key, value); });
+ return result;
+ } catch {
+ return null;
+ }
+}
+
+export function attachLiveFormSync(form, fieldMap) {
+ if (!form || !fieldMap) return;
+ const handler = () => syncFormDataLayer(form, fieldMap);
+ const fields = form.querySelectorAll('input, select, textarea');
+ fields.forEach((field) => {
+ const eventType = field.type === 'text' || field.type === 'email' || field.tagName.toLowerCase() === 'textarea' ? 'blur' : 'change';
+ field.addEventListener(eventType, handler);
+ if (eventType === 'change') {
+ field.addEventListener('input', handler);
+ }
+ });
+}
\ No newline at end of file
diff --git a/scripts/scripts.js b/scripts/scripts.js
index 922209a..c5b0b54 100644
--- a/scripts/scripts.js
+++ b/scripts/scripts.js
@@ -73,6 +73,23 @@ const experimentationConfig = {
}*/
//return false;
}
+
+ export function normalizeAemPath(path) {
+ if (!path) return path;
+ let pathname = path;
+ if (/^https?:\/\//i.test(path)) {
+ try {
+ pathname = new URL(path).pathname;
+ } catch {
+ return path;
+ }
+ }
+ if (!pathname.startsWith('/content/')) return pathname;
+ if (isAuthorEnvironment()) {
+ return pathname.endsWith('.html') ? pathname : `${pathname}.html`;
+ }
+ return pathname.replace(/^\/content\/[^/]+\/language-masters/, '').replace(/\.html$/, '');
+}
/**
* Move instrumentation attributes from a given element to another given element.