From 0d7b81f6934a6eb0473a0c8f82181180ba84ecc2 Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 22 Jul 2026 16:24:24 +0530 Subject: [PATCH 01/13] Update paths for frescopa-demo1 configuration --- paths.json | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/paths.json b/paths.json index fbc8a396..d7a23261 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/" ] } From 7bf7a535d1f52516d85cdfd8139ce346d6a92fd3 Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 22 Jul 2026 16:25:24 +0530 Subject: [PATCH 02/13] Update mountpoint URL in fstab.yaml --- fstab.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fstab.yaml b/fstab.yaml index d6d8d73e..34846f92 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" From 4eb0dff9a3025ae80ad2b7850dace890379f698f Mon Sep 17 00:00:00 2001 From: Ankita Singh Date: Wed, 22 Jul 2026 18:41:53 +0530 Subject: [PATCH 03/13] Add checkout block --- blocks/checkout/_checkout.json | 71 +++++ blocks/checkout/checkout.css | 365 +++++++++++++++++++++ blocks/checkout/checkout.js | 563 +++++++++++++++++++++++++++++++++ component-definition.json | 16 + component-filters.json | 3 +- component-models.json | 48 +++ models/_component-filters.json | 3 +- models/_component-models.json | 48 +++ scripts/custom-events.js | 113 +++++++ scripts/form-data-layer.js | 31 ++ scripts/scripts.js | 19 +- 11 files changed, 1277 insertions(+), 3 deletions(-) create mode 100644 blocks/checkout/_checkout.json create mode 100644 blocks/checkout/checkout.css create mode 100644 blocks/checkout/checkout.js create mode 100644 scripts/custom-events.js create mode 100644 scripts/form-data-layer.js diff --git a/blocks/checkout/_checkout.json b/blocks/checkout/_checkout.json new file mode 100644 index 00000000..13e7d2a5 --- /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 00000000..1233d319 --- /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 00000000..c2b45170 --- /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 = 'NAMEQTYPRICE'; + 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

+ + + `; + mainCol.appendChild(section); + const prefs = document.createElement('div'); + prefs.className = 'checkout-section'; + prefs.innerHTML = ` +

Preferences

+
+ + + +
+ `; + 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.

+
+ + + + + + + + + + + +
+ `; + 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.

+
+ + + + +
+ `; + 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)}
+
Taxesincluded
+
+
Total${formatPrice(flightsTotal)}
+ + `; + 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/component-definition.json b/component-definition.json index 8f4edd34..3b58e821 100644 --- a/component-definition.json +++ b/component-definition.json @@ -962,6 +962,22 @@ } } }, + { + "title": "Checkout", + "id": "checkout", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Checkout", + "model": "checkout", + "key-value": true + } + } + } + } + }, { "...": "../blocks/*/_*.json#/definitions" } diff --git a/component-filters.json b/component-filters.json index e877ffac..cc057baf 100644 --- a/component-filters.json +++ b/component-filters.json @@ -35,7 +35,8 @@ "form", "forex", "embed-adaptive-form", - "find-a-doctor" + "find-a-doctor", + "checkout" ] }, { diff --git a/component-models.json b/component-models.json index 724eb169..a3331e06 100644 --- a/component-models.json +++ b/component-models.json @@ -7078,5 +7078,53 @@ "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" + } + ] } ] diff --git a/models/_component-filters.json b/models/_component-filters.json index 7e23a69d..780a907f 100644 --- a/models/_component-filters.json +++ b/models/_component-filters.json @@ -35,7 +35,8 @@ "text", "title", "title-block", - "video" + "video", + "checkout" ] }, { diff --git a/models/_component-models.json b/models/_component-models.json index 31459e87..21b21ff5 100644 --- a/models/_component-models.json +++ b/models/_component-models.json @@ -266,6 +266,54 @@ } ] }, + { + "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" + } + ] + }, { "...": "../blocks/*/_*.json#/models" } diff --git a/scripts/custom-events.js b/scripts/custom-events.js new file mode 100644 index 00000000..914a5657 --- /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 00000000..9011d19b --- /dev/null +++ b/scripts/form-data-layer.js @@ -0,0 +1,31 @@ +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 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; + } +} \ No newline at end of file diff --git a/scripts/scripts.js b/scripts/scripts.js index 922209a6..317041a3 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. @@ -821,4 +838,4 @@ const experimentationConfig = { loadDelayed(); } - loadPage(); + loadPage(); \ No newline at end of file From ea9a7e6b10ab99876671a88a6af67a3ea684bd88 Mon Sep 17 00:00:00 2001 From: Ankita Singh Date: Wed, 22 Jul 2026 18:51:35 +0530 Subject: [PATCH 04/13] Add confirmation block --- blocks/confirmation/_confirmation.json | 35 ++++++++ blocks/confirmation/confirmation.css | 107 +++++++++++++++++++++++++ blocks/confirmation/confirmation.js | 92 +++++++++++++++++++++ component-definition.json | 15 ++++ component-filters.json | 3 +- component-models.json | 13 +++ models/_component-filters.json | 3 +- models/_component-models.json | 13 +++ 8 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 blocks/confirmation/_confirmation.json create mode 100644 blocks/confirmation/confirmation.css create mode 100644 blocks/confirmation/confirmation.js diff --git a/blocks/confirmation/_confirmation.json b/blocks/confirmation/_confirmation.json new file mode 100644 index 00000000..708d0826 --- /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 00000000..7af77007 --- /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 00000000..ae8fa554 --- /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/component-definition.json b/component-definition.json index 8f4edd34..899900b1 100644 --- a/component-definition.json +++ b/component-definition.json @@ -962,6 +962,21 @@ } } }, + { + "title": "Confirmation", + "id": "confirmation", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Confirmation", + "model": "confirmation" + } + } + } + } + }, { "...": "../blocks/*/_*.json#/definitions" } diff --git a/component-filters.json b/component-filters.json index e877ffac..8b3ae631 100644 --- a/component-filters.json +++ b/component-filters.json @@ -35,7 +35,8 @@ "form", "forex", "embed-adaptive-form", - "find-a-doctor" + "find-a-doctor", + "confirmation" ] }, { diff --git a/component-models.json b/component-models.json index 724eb169..18b82ac3 100644 --- a/component-models.json +++ b/component-models.json @@ -7078,5 +7078,18 @@ "condition": { "==": [{ "var": "enableSubmitAction" }, true] } } ] + }, + { + "id": "confirmation", + "fields": [ + { + "component": "text", + "name": "custom-class", + "label": "Custom class", + "value": "", + "valueType": "string", + "description": "Space-separated class names to add to the block" + } + ] } ] diff --git a/models/_component-filters.json b/models/_component-filters.json index 7e23a69d..52987145 100644 --- a/models/_component-filters.json +++ b/models/_component-filters.json @@ -35,7 +35,8 @@ "text", "title", "title-block", - "video" + "video", + "confirmation" ] }, { diff --git a/models/_component-models.json b/models/_component-models.json index 31459e87..4237ff93 100644 --- a/models/_component-models.json +++ b/models/_component-models.json @@ -266,6 +266,19 @@ } ] }, + { + "id": "confirmation", + "fields": [ + { + "component": "text", + "name": "custom-class", + "label": "Custom class", + "value": "", + "valueType": "string", + "description": "Space-separated class names to add to the block" + } + ] + }, { "...": "../blocks/*/_*.json#/models" } From d0ab9dfbcc04a01349eee736b83b0c2624c7d696 Mon Sep 17 00:00:00 2001 From: Ankita Singh Date: Wed, 22 Jul 2026 18:56:35 +0530 Subject: [PATCH 05/13] Add sign-in block --- blocks/sign-in/_sign-in.json | 99 +++++++++ blocks/sign-in/sign-in.css | 355 +++++++++++++++++++++++++++++++ blocks/sign-in/sign-in.js | 375 +++++++++++++++++++++++++++++++++ component-definition.json | 16 ++ component-filters.json | 3 +- component-models.json | 71 +++++++ models/_component-filters.json | 3 +- models/_component-models.json | 71 +++++++ scripts/custom-events.js | 113 ++++++++++ scripts/form-data-layer.js | 117 ++++++++++ scripts/scripts.js | 19 +- 11 files changed, 1239 insertions(+), 3 deletions(-) create mode 100644 blocks/sign-in/_sign-in.json create mode 100644 blocks/sign-in/sign-in.css create mode 100644 blocks/sign-in/sign-in.js create mode 100644 scripts/custom-events.js create mode 100644 scripts/form-data-layer.js diff --git a/blocks/sign-in/_sign-in.json b/blocks/sign-in/_sign-in.json new file mode 100644 index 00000000..cfa2b9d7 --- /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 00000000..e328943c --- /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 00000000..d84d529a --- /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 8f4edd34..0dec0d38 100644 --- a/component-definition.json +++ b/component-definition.json @@ -962,6 +962,22 @@ } } }, + { + "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 e877ffac..85794374 100644 --- a/component-filters.json +++ b/component-filters.json @@ -35,7 +35,8 @@ "form", "forex", "embed-adaptive-form", - "find-a-doctor" + "find-a-doctor", + "sign-in" ] }, { diff --git a/component-models.json b/component-models.json index 724eb169..50c5ff32 100644 --- a/component-models.json +++ b/component-models.json @@ -7078,5 +7078,76 @@ "condition": { "==": [{ "var": "enableSubmitAction" }, true] } } ] + }, + { + "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/models/_component-filters.json b/models/_component-filters.json index 7e23a69d..5aa5384e 100644 --- a/models/_component-filters.json +++ b/models/_component-filters.json @@ -35,7 +35,8 @@ "text", "title", "title-block", - "video" + "video", + "sign-in" ] }, { diff --git a/models/_component-models.json b/models/_component-models.json index 31459e87..60e55968 100644 --- a/models/_component-models.json +++ b/models/_component-models.json @@ -266,6 +266,77 @@ } ] }, + { + "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/scripts/custom-events.js b/scripts/custom-events.js new file mode 100644 index 00000000..914a5657 --- /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 00000000..2e2d7dbf --- /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 922209a6..317041a3 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. @@ -821,4 +838,4 @@ const experimentationConfig = { loadDelayed(); } - loadPage(); + loadPage(); \ No newline at end of file From f2034432088b1406a3e155b808d724e03514c7a1 Mon Sep 17 00:00:00 2001 From: Ankita Singh Date: Wed, 22 Jul 2026 19:02:55 +0530 Subject: [PATCH 06/13] Add create account block --- blocks/create-account/_create-account.json | 131 ++++++ blocks/create-account/create-account.css | 377 +++++++++++++++ blocks/create-account/create-account.js | 517 +++++++++++++++++++++ 3 files changed, 1025 insertions(+) create mode 100644 blocks/create-account/_create-account.json create mode 100644 blocks/create-account/create-account.css create mode 100644 blocks/create-account/create-account.js diff --git a/blocks/create-account/_create-account.json b/blocks/create-account/_create-account.json new file mode 100644 index 00000000..daf74b05 --- /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 00000000..f1cb5d63 --- /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 00000000..866e4ca2 --- /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 From 05e43c47bd8063610b6f1dd67d5fc116f0f19a75 Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 22 Jul 2026 19:16:57 +0530 Subject: [PATCH 07/13] Add Confirmation component definition --- component-definition.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/component-definition.json b/component-definition.json index 3b58e821..a08d2567 100644 --- a/component-definition.json +++ b/component-definition.json @@ -977,6 +977,21 @@ } } } + }, + { + "title": "Confirmation", + "id": "confirmation", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Confirmation", + "model": "confirmation" + } + } + } + } }, { "...": "../blocks/*/_*.json#/definitions" From b03a0af687cd1858910df1277d17d4e55ef3815d Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 22 Jul 2026 19:18:50 +0530 Subject: [PATCH 08/13] Add confirmation component with custom class field --- component-models.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/component-models.json b/component-models.json index a3331e06..fadccdf2 100644 --- a/component-models.json +++ b/component-models.json @@ -7126,5 +7126,18 @@ "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" + } + ] } ] From ba75e7a9f51b41337afea90ea256a1172a917b4f Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 22 Jul 2026 19:19:40 +0530 Subject: [PATCH 09/13] Add confirmation component model with custom class field Added a new component model for confirmation with custom class fields. --- models/_component-models.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/models/_component-models.json b/models/_component-models.json index 21b21ff5..f5c11ebb 100644 --- a/models/_component-models.json +++ b/models/_component-models.json @@ -314,6 +314,19 @@ } ] }, + { + "id": "confirmation", + "fields": [ + { + "component": "text", + "name": "custom-class", + "label": "Custom class", + "value": "", + "valueType": "string", + "description": "Space-separated class names to add to the block" + } + ] + }, { "...": "../blocks/*/_*.json#/models" } From d4e4958240a02dd7a5e0df0833a875dcbb8b43da Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 22 Jul 2026 19:31:12 +0530 Subject: [PATCH 10/13] Add fields for sign-in component configuration --- models/_component-models.json | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/models/_component-models.json b/models/_component-models.json index f5c11ebb..c2f46e85 100644 --- a/models/_component-models.json +++ b/models/_component-models.json @@ -327,6 +327,77 @@ } ] }, + { + "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" } From 2d55af9c8eb9feb46f08dc1ae770f36201fd377f Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 22 Jul 2026 19:32:25 +0530 Subject: [PATCH 11/13] Add Sign In component definition --- component-definition.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/component-definition.json b/component-definition.json index a08d2567..cc1920f6 100644 --- a/component-definition.json +++ b/component-definition.json @@ -992,6 +992,22 @@ } } } + }, + { + "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" From 24ee9bf6c46fd7b5be8f7a0881368355a54fe2d9 Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 22 Jul 2026 19:33:31 +0530 Subject: [PATCH 12/13] Add sign-in component with various fields --- component-models.json | 71 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/component-models.json b/component-models.json index fadccdf2..2813ebe2 100644 --- a/component-models.json +++ b/component-models.json @@ -7139,5 +7139,76 @@ "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" + } + ] } ] From 675c22da08baa6499586e35da88e35062cc3cb78 Mon Sep 17 00:00:00 2001 From: Ankita Singh Date: Wed, 22 Jul 2026 21:37:11 +0530 Subject: [PATCH 13/13] create account dependencies --- component-definition.json | 16 +++++ component-filters.json | 3 +- component-models.json | 103 ++++++++++++++++++++++++++++ models/_component-filters.json | 3 +- models/_component-models.json | 103 ++++++++++++++++++++++++++++ scripts/custom-events.js | 119 +++++++++++++++++++++++++++++++++ scripts/form-data-layer.js | 117 ++++++++++++++++++++++++++++++++ scripts/scripts.js | 17 +++++ 8 files changed, 479 insertions(+), 2 deletions(-) create mode 100644 scripts/custom-events.js create mode 100644 scripts/form-data-layer.js diff --git a/component-definition.json b/component-definition.json index 8f4edd34..30f63476 100644 --- a/component-definition.json +++ b/component-definition.json @@ -962,6 +962,22 @@ } } }, + { + "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 + } + } + } + } + }, { "...": "../blocks/*/_*.json#/definitions" } diff --git a/component-filters.json b/component-filters.json index e877ffac..346cabb2 100644 --- a/component-filters.json +++ b/component-filters.json @@ -35,7 +35,8 @@ "form", "forex", "embed-adaptive-form", - "find-a-doctor" + "find-a-doctor", + "create-account" ] }, { diff --git a/component-models.json b/component-models.json index 724eb169..4ec1b916 100644 --- a/component-models.json +++ b/component-models.json @@ -7078,5 +7078,108 @@ "condition": { "==": [{ "var": "enableSubmitAction" }, true] } } ] + }, + { + "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" + } + ] } ] diff --git a/models/_component-filters.json b/models/_component-filters.json index 7e23a69d..d6bfda03 100644 --- a/models/_component-filters.json +++ b/models/_component-filters.json @@ -35,7 +35,8 @@ "text", "title", "title-block", - "video" + "video", + "create-account" ] }, { diff --git a/models/_component-models.json b/models/_component-models.json index 31459e87..001f5a9b 100644 --- a/models/_component-models.json +++ b/models/_component-models.json @@ -266,6 +266,109 @@ } ] }, + { + "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" + } + ] + }, { "...": "../blocks/*/_*.json#/models" } diff --git a/scripts/custom-events.js b/scripts/custom-events.js new file mode 100644 index 00000000..2706bad5 --- /dev/null +++ b/scripts/custom-events.js @@ -0,0 +1,119 @@ +// ========================================== +// Custom Events for Launch +// dispatchCustomEvent(eventName) lets blocks fire custom events; events are queued until Launch is ready. +// ========================================== + +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 00000000..2e2d7dbf --- /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 922209a6..8ddf7820 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -822,3 +822,20 @@ const experimentationConfig = { } loadPage(); + +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$/, ''); +} \ No newline at end of file