diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..79e318ae --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +node_modules/* +deps/* +scripts/fallback.js +web-test-runner.config.js diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000..5856b626 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,4 @@ +name: "Milo Events CodeQL Config" + +paths-ignore: + - node_modules diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..20c787bd --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +Describe your specific features or fixes + +Resolves: [MWPW-NUMBER](https://jira.corp.adobe.com/browse/MWPW-NUMBER) + +Test URLs: +- Before: https://main--events-milo--adobecom.hlx.page/ +- After: https://--events-milo--adobecom.hlx.page/ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..6397ef8e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,64 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + schedule: + - cron: '18 6 * * 5' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'javascript' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + config-file: ./.github/codeql/codeql-config.yml + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml new file mode 100644 index 00000000..1319736f --- /dev/null +++ b/.github/workflows/run-tests.yaml @@ -0,0 +1,39 @@ +name: Unit Tests +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] +jobs: + run-tests: + name: Running tests + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x] + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + fetch-depth: 2 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Install XVFB + run: sudo apt-get install xvfb + + - name: Install dependencies + run: npm install + + - name: Run the tests + run: xvfb-run -a npm test + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/lcov.info diff --git a/.gitignore b/.gitignore index e639ba03..04e7d568 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ logs/* node_modules/* .DS_Store .idea +*.pem diff --git a/.kodiak/config.yaml b/.kodiak/config.yaml new file mode 100644 index 00000000..079996e8 --- /dev/null +++ b/.kodiak/config.yaml @@ -0,0 +1,28 @@ +version: 1.0 + +snow: + - id: 546343 # Milo Events https://adobe.service-now.com/service_registry_portal.do#/service/546343 + +notifications: + jira: + default: + project: MWPW # Mandatory + filters: + include: + risk_rating: R5 + fields: + assignee: + name: shkhan + customfield_11800: MWPW-140779 #epic link + watchers: + - casalino + - jmichnow + - mauchley + - cod87753 + - tek10248 + labels: + - "OriginatingProcess=Kodiak" + - "security" + - "kodiak-ticket" + components: + - name: "DevOps Security" diff --git a/blocks/event-editor/event-editor.css b/blocks/event-editor/event-editor.css new file mode 100644 index 00000000..dc4a4aae --- /dev/null +++ b/blocks/event-editor/event-editor.css @@ -0,0 +1,88 @@ +.event-editor { + display: grid; + grid-template-columns: 1fr; + gap: var(--spacing-s); + padding: var(--spacing-m); +} + +.event-editor form { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: var(--spacing-m); + background-color: var(--background-color); + border: 1px solid var(--color-gray-300); + box-shadow: 0 2px 4px var(--color-gray-100); + border-radius: 4px; + padding: var(--spacing-m); +} + +.event-editor form > div.sub-grid { + grid-column: 1 / -1; + display: grid; +} + +.event-editor fieldset { + grid-column: 1 / -1; /* Make fieldsets span all columns */ + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--spacing-s); + border: 1px solid var(--color-gray-300); + border-radius: 4px; + padding: var(--spacing-m); +} + +.event-editor legend { + grid-column: 1 / -1; + font-weight: bold; + color: var(--link-color); +} + +.event-editor label { + display: block; + margin-bottom: var(--spacing-xs); + color: var(--color-gray-600); +} + +.event-editor input[type="text"][name="url"] { + grid-column: 1 / 3; +} + +.event-editor input[type="text"], +.event-editor input[type="file"], +.event-editor textarea, +.event-editor button { + width: 100%; + padding: var(--spacing-xs); + border: 1px solid var(--color-gray-300); + border-radius: 4px; + box-sizing: border-box; +} + +.event-editor button, +.event-editor.add-participant-btn, +.event-editor .remove-participant-btn { + width: max-content; + box-sizing: border-box; + margin: 8px; + height: max-content; + background-color: var(--color-accent); + color: var(--color-white); + border: none; + padding: var(--spacing-xs); + cursor: pointer; + align-self: end; +} + +.event-editor .button[type="submit"] { + align-self: end; +} + +.event-editor button:hover { + opacity: 0.8; +} + +@media (min-width: 900px) { + .event-editor form { + grid-template-columns: repeat(2, 1fr); /* Two columns for larger screens */ + } +} diff --git a/blocks/event-editor/event-editor.js b/blocks/event-editor/event-editor.js new file mode 100644 index 00000000..6c8545c5 --- /dev/null +++ b/blocks/event-editor/event-editor.js @@ -0,0 +1,153 @@ +import { getLibs } from '../../scripts/utils.js'; + +const { html, render, useRef, useState } = await import(`${getLibs()}/deps/htm-preact.js`); + +export default function init(el) { + // Mock search function + const mockSearch = () => new Promise((resolve) => { + setTimeout(() => { + resolve({ + id: '6c0ce564-3335-5d20-95f6-cb35ccee571b', + styles: { + typeOverride: 'event', + backgroundImage: 'https://summit.adobe.com/_assets/images/home/speakers-promo@2x.jpg', + mnemonic: '', + }, + arbitrary: [ + { key: 'promoId', value: 'splash-that|458926431' }, + { key: 'timezone', value: 'America/Los_Angeles' }, + { key: 'venue', value: 'La Costa Resort and Spa' }, + ], + contentArea: { + detailText: 'detail', + title: 'TestTier3Event1', + url: 'https://main--events-milo--adobecom.hlx.page/t3/event/03-12-2024/chicago/il/adobe-events-seminar', + description: 'TestTier3Event1', + }, + footer: [{ + divider: false, + left: [], + center: [], + right: [{ + type: 'button', + style: '', + text: 'Read now', + href: 'https://main--events-milo--adobecom.hlx.page/t3/event/03-12-2024/chicago/il/adobe-events-seminar', + }], + }], + }); + }, 1000); + }); + + // DynamicForm Component + const DynamicForm = ({ data }) => { + // Updating state to handle both speakers and hosts + const [participants, setParticipants] = useState([]); + + const handleSubmit = (event) => { + event.preventDefault(); + // Prepare and log the data to be submitted + const formData = new FormData(event.target); + // Example of handling form data here + console.log(formData, 'Form submitted with updated data'); + alert('Check the console for submitted data.'); + }; + + const addParticipant = (type) => { + setParticipants([ + ...participants, + { id: Math.random().toString(16).slice(2), type }, + ]); + }; + + const removeParticipant = (id) => { + setParticipants((current) => current.filter((p) => p.id !== id)); + }; + + const renderInputField = (name, value, label) => html` +
+ + +
+ `; + + const renderParticipantInputs = (participant) => html` +
+ ${participant.type.charAt(0).toUpperCase() + participant.type.slice(1)} Details +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ `; + + return html` +
+ ${Object.entries(data).map(([key, value]) => { + if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + return Object.entries(value).map(([subKey, subValue]) => renderInputField(`${key}.${subKey}`, subValue, `${subKey.charAt(0).toUpperCase() + subKey.slice(1)}`)); + } if (typeof value === 'string') { + return renderInputField(key, value, `${key.charAt(0).toUpperCase() + key.slice(1)}`); + } + return null; + })} +
+ ${participants.map(renderParticipantInputs)} +
+ + +
+
+ +
+ `; + }; + + // App Component + const App = () => { + const [data, setData] = useState(null); + const inputRef = useRef(null); + + const handleSubmit = async (event) => { + event.preventDefault(); + const url = inputRef.current.value; + const result = await mockSearch(url); + setData(result); + }; + + return html` +
+ ${!data ? html` +
+ + +
+ ` : html` + <${DynamicForm} data=${data} /> + `} +
+ `; + }; + + // Render the App + render(html`<${App} />`, el); +} diff --git a/blocks/event-map/event-map.css b/blocks/event-map/event-map.css new file mode 100644 index 00000000..b4a5c339 --- /dev/null +++ b/blocks/event-map/event-map.css @@ -0,0 +1,30 @@ +.event-map { + display: flex; + flex-direction: column; + gap: 24px; + margin: 0 auto; +} + +.event-map > div:first-of-type { + width: var(--grid-container-width); + margin: 0 auto; +} + +.event-map .mapbox-container { + height: 300px; + min-width: calc(100vw - 20px); +} + +@media (min-width: 900px) { + .event-map { + flex: 1 1 0; + flex-direction: row; + max-width: var(--grid-container-width); + justify-content: center; + align-items: center; + } + + .event-map .mapbox-container { + min-width: 50%; + } +} diff --git a/blocks/event-map/event-map.js b/blocks/event-map/event-map.js new file mode 100644 index 00000000..ea337ae7 --- /dev/null +++ b/blocks/event-map/event-map.js @@ -0,0 +1,53 @@ +/* global mapboxgl */ + +import { getLibs } from '../../scripts/utils.js'; + +const { loadScript, loadStyle } = await import(`${getLibs()}/utils/utils.js`); + +const MAPBOX_API_TOKEN = 'cGsuZXlKMUlqb2ljV2w1ZFc1a1lXa2lMQ0poSWpvaVkydHJOSEp3ZVdsMk1XczRaVEp2YjNSck5IcDBiVFl5WVNKOS5QZ25PR0NWcVluU3VnUlBYb2ZKYWtR'; + +function loadMapboxConfigs(el) { + const configs = {}; + const configsDiv = el.querySelector(':scope > div:last-of-type'); + + if (!configsDiv) return null; + + Array.from(configsDiv.children).forEach((col, i) => { + if (i === 0) configs.mapStyle = col.textContent.trim(); + if (i === 1) configs.coordinates = col.textContent.split(','); + if (i === 2) configs.zoom = parseInt(col.textContent.trim(), 10); + }); + + configs.mapContainer = configsDiv; + return configs; +} + +function decorateMapContainer(configs) { + const { mapContainer } = configs; + mapContainer.innerHTML = ''; + mapContainer.classList.add('mapbox-container'); + mapContainer.id = 'mapbox-container'; +} + +export default async function init(el) { + const configs = loadMapboxConfigs(el); + if (!configs) return; + + decorateMapContainer(configs); + loadStyle('https://api.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.css'); + loadScript('https://api.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.js').then(() => { + window.mapboxgl.accessToken = window.atob(MAPBOX_API_TOKEN); + const map = new mapboxgl.Map({ + container: 'mapbox-container', + style: configs.mapStyle, + center: configs.coordinates, + zoom: configs.zoom, + }); + + const marker1 = new mapboxgl.Marker() + .setLngLat(configs.coordinates) + .addTo(map); + + console.log(marker1); + }); +} diff --git a/blocks/event-tags/event-tags.css b/blocks/event-tags/event-tags.css new file mode 100644 index 00000000..2100b0e5 --- /dev/null +++ b/blocks/event-tags/event-tags.css @@ -0,0 +1,38 @@ +.event-tags { + margin: 20px auto; + overflow: auto; +} + +.event-tags .tags-wrapper { + margin: auto; + padding: 8px 16px; + width: max-content; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.event-tags .tag { + display: flex; + align-items: center; + box-sizing: border-box; + padding: 4px 12px; + background: var(--color-gray-200); + font-size: 14px; + font-weight: 700; + border-radius: 50px; + white-space: nowrap; +} + +.event-tags .tag img { + margin-right: 8px; + height: 18px; +} + +@media (min-width: 900px) { + .event-tags .tags-wrapper { + display: flex; + } + + +} diff --git a/blocks/event-tags/event-tags.js b/blocks/event-tags/event-tags.js new file mode 100644 index 00000000..9e54c03b --- /dev/null +++ b/blocks/event-tags/event-tags.js @@ -0,0 +1,46 @@ +import { getLibs } from '../../scripts/utils.js'; + +const { createTag } = await import(`${getLibs()}/utils/utils.js`); + +function handlize(str) { + return str.toLowerCase().trim().replace(' ', '-'); +} + +function getTagIcon(tag) { + const availableIcons = [ + 'illustrator', + 'graphic-design', + 'photography', + 'social', + '3d-ar', + ]; + + const img = createTag('img', { class: 'icon icon-label', src: '/blocks/event-tags/icons/label.svg', alt: tag }); + + if (availableIcons.includes(tag)) { + img.className = `icon icon-${tag}`; + img.src = `/blocks/event-tags/icons/${tag}.svg`; + img.alt = tag; + } + + return img; +} + +export default function init(el) { + const tags = el.textContent.split(','); + el.innerHTML = ''; + const tagsWrapper = createTag('div', { class: 'tags-wrapper' }); + + tags.forEach((tag) => { + const tagEl = createTag('div', { class: 'tag' }); + // TODO: use localized text + const text = tag; + const icon = getTagIcon(handlize(tag)); + + tagEl.append(icon, text); + + tagsWrapper.append(tagEl); + }); + + el.append(tagsWrapper); +} diff --git a/blocks/event-tags/icons/3d-ar.svg b/blocks/event-tags/icons/3d-ar.svg new file mode 100644 index 00000000..23f2af6e --- /dev/null +++ b/blocks/event-tags/icons/3d-ar.svg @@ -0,0 +1,13 @@ + + + + + S 3DMaterials 18 N + + + + diff --git a/blocks/event-tags/icons/graphic-design.svg b/blocks/event-tags/icons/graphic-design.svg new file mode 100644 index 00000000..1827deb7 --- /dev/null +++ b/blocks/event-tags/icons/graphic-design.svg @@ -0,0 +1,11 @@ + + + + + S Graphic 18 N + + diff --git a/blocks/event-tags/icons/illustrator.svg b/blocks/event-tags/icons/illustrator.svg new file mode 100644 index 00000000..70c782fc --- /dev/null +++ b/blocks/event-tags/icons/illustrator.svg @@ -0,0 +1,13 @@ + + + + + S Actions 18 N + + + + diff --git a/blocks/event-tags/icons/label.svg b/blocks/event-tags/icons/label.svg new file mode 100644 index 00000000..02ddcbe8 --- /dev/null +++ b/blocks/event-tags/icons/label.svg @@ -0,0 +1,11 @@ + + + + + S Label 18 N + + diff --git a/blocks/event-tags/icons/photography.svg b/blocks/event-tags/icons/photography.svg new file mode 100644 index 00000000..7bf7d91f --- /dev/null +++ b/blocks/event-tags/icons/photography.svg @@ -0,0 +1,12 @@ + + + + + S Camera 18 N + + + diff --git a/blocks/event-tags/icons/social.svg b/blocks/event-tags/icons/social.svg new file mode 100644 index 00000000..d9d0aa05 --- /dev/null +++ b/blocks/event-tags/icons/social.svg @@ -0,0 +1,11 @@ + + + + + S SocialNetwork 18 N + + diff --git a/blocks/events-form/events-form.css b/blocks/events-form/events-form.css new file mode 100644 index 00000000..0664cfe4 --- /dev/null +++ b/blocks/events-form/events-form.css @@ -0,0 +1,332 @@ +.events-form { + --input-border-radius: 4px; + + padding: var(--spacing-xl); + border-radius: var(--card-border-radius-l); + filter: var(--image-filter-drop-shadow-small); + border: solid 1px var(--bg-color-grey); + box-sizing: border-box; + max-width: 800px; + margin: 0 auto; + color: #2C2C2C; + transition: opacity 0.4s; +} + +.events-form.loading { + opacity: 0; +} + +.events-form .event-form-hero { + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: 16px; +} + +.events-form .event-form-hero > div { + flex: 1 1 0; +} + +.events-form .event-form-hero img { + display: block; +} + +.events-form form { + width: 100%; + display: flex; + flex-wrap: wrap; + padding-top: 24px; +} + +.events-form form .first-form-section { + display: flex; + align-items: center; + flex-direction: column; + gap: 24px; +} + +.events-form h2 { + margin: 0; + padding: var(--spacing-s) 0; + font-size: var(--type-heading-l-lh); +} + +.events-form h3 { + padding-top: var(--spacing-s); + font-size: var(--type-heading-m-size); +} + +.events-form :is(input, textarea, select) { + border: solid 1px var(--color-gray-500); + padding: var(--spacing-xxs) var(--spacing-xs); + width: 100%; + box-sizing: border-box; + border-radius: var(--input-border-radius); + font-size: var(--type-body-xs-size); + line-height: var(--type-body-xs-lh); + font-family: var(--body-font-family); +} + +.events-form textarea { + min-height: 100px; + resize: vertical; +} + +.events-form :is(input, textarea, select):hover { + border-color: var(--color-gray-600); +} + +.events-form :is(input, textarea, select):is(:focus, :focus-visible, :focus-within, :active) { + border-color: var(--color-gray-700); + outline-color: var(--color-accent); + outline-offset: 4px; +} + +.events-form select { + appearance: none; + background: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii01OCAyOSAzMiAzNCIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAtNTggMjkgMzIgMzQiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxwYXRoIGQ9Im0tNTYgNDMgNi0xMiA2IDEyaC0xMnpNLTQ0IDQ5bC02IDEyLTYtMTJoMTJ6IiBmaWxsPSIjNTM1MzUzIi8+PC9zdmc+') no-repeat 128% 54%; + background-color: var(--color-white); + background-position: 98% 50%; + background-size: 1em 1em; +} + +.events-form label { + display: block; + padding-bottom: var(--spacing-xxs); + box-sizing: border-box; + font-size: var(--type-body-s-size); + line-height: var(--type-body-s-lh); + font-weight: 600; +} + +.events-form label.required::after { + content: "*"; + color: var(--color-black); + padding-left: var(--spacing-xxxs); +} + +.events-form .field-wrapper { + margin-bottom: var(--spacing-xxs); + width: 100%; + display: flex; + flex-wrap: wrap; + padding: var(--spacing-xs) 0; +} + +.events-form .field-group-wrapper { + flex-direction: column; +} + +.events-form .events-form-heading-wrapper { + justify-content: center; + margin: 0; +} + +.events-form .field-wrapper.hidden { + display: none; +} + +.events-form .check-item-wrap { + position: relative; + display: inline-flex; + align-items: center; + width: 100%; +} + +.events-form .check-item-input { + opacity: 0; + position: absolute; + inset: 0; + cursor: pointer; + z-index: 1; +} + +.events-form .check-item-button { + position: relative; + inline-size: 14px; + block-size: 14px; +} + +.events-form .check-item-button::before { + content: ''; + display: block; + background-color: var(--color-white); + box-sizing: border-box; + border-style: solid; + border-width: 2px; + border-color: var(--color-font-grey); + block-size: 14px; + inline-size: 14px; + position: absolute; + transition: border 0.13s ease-in-out, box-shadow 0.13s ease-in-out; +} + +.events-form .radio-button::before { + border-radius: 50%; +} + +.events-form .checkbox-button::before { + border-radius: 2px; +} + +.events-form .checkbox-button::after { + content: ''; + display: block; + position: absolute; + box-sizing: border-box; + block-size: 10px; + inline-size: 10px; + inset: 0; + margin: auto; + scale: 0; + opacity: 0; + transition: scale 0.13s ease-in-out, opacity 0.13s; +} + +.events-form .checkbox-input:checked + .checkbox-button::after { + opacity: 1; + scale: 1; + background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI2LjUuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHBhdGggZD0iTTMuNSA5LjVhLjk5OS45OTkgMCAwMS0uNzc0LS4zNjhsLTIuNDUtM2ExIDEgMCAxMTEuNTQ4LTEuMjY0bDEuNjU3IDIuMDI4IDQuNjgtNi4wMUExIDEgMCAwMTkuNzQgMi4xMTRsLTUuNDUgN2ExIDEgMCAwMS0uNzc3LjM4NnoiIGZpbGw9IiNmZmYiPjwvcGF0aD4KPC9zdmc+Cg=="); + background-size: contain; +} + +.events-form .radio-input:checked + .radio-button::before { + border-width: 5px; + border-color: var(--color-gray-700); +} + +.events-form .checkbox-input:checked + .checkbox-button::before { + border-width: 7px; + border-color: var(--color-gray-700); +} + +.events-form .check-item-label { + font-size: var(--type-body-xs-size); + font-weight: normal; + margin-left: var(--spacing-xxs); + padding-bottom: 0; +} + +.events-form .events-form-legal-wrapper p { + font-size: var(--type-body-xxs-size); + line-height: var(--type-body-xxs-lh); + font-style: italic; +} + +.events-form .field-button-wrapper { + margin-bottom: 0; +} + +.events-form button { + font-family: var(--body-font-family); + font-size: var(--type-body-s-size); + background-color: var(--link-color); + min-width: 114px; + height: 40px; + line-height: 20px; + padding: 9px 16px; + border: 0; + border-radius: 24px; + color: #fff; + font-weight: 700; + width: 100%; + box-shadow: none; + margin: 0 auto; + cursor: pointer; + transition: background-color 0.13s, color 0.13s; +} + +.events-form button:disabled { + background-color: var(--color-gray-200); + color: var(--color-gray-400); + cursor: progress; +} + +.events-form button.outline { + background-color: transparent; + border: 2px solid var(--link-color); + color: var(--link-color); +} + +.events-form .divider { + border-top: 1px var(--color-gray-200) solid; + margin: 16px 0; + padding: 0; +} + +.events-form .thank-you { + text-align: center; +} + +.events-form .rsvp-status-label { + padding: 16px; + border-radius: 8px; + background: var(--color-gray-200); +} + +@media screen and (min-width: 600px) { + .events-form form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 var(--spacing-l); + align-items: start; + } + + .events-form form .first-form-section { + flex-direction: row; + width: 100%; + } + + .events-form form .first-form-section .first-form-section-input-wrapper { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 var(--spacing-l); + align-items: start; + width: 100%; + } + + .events-form :is(.events-form-submit-wrapper:last-child, .events-form-heading-wrapper, .thank-you, .events-form-full-width) { + grid-column: 1 / 3; + justify-self: center; + } + + .events-form .events-form-checkbox-group-wrapper.events-form-full-width .group-container.checkbox-group-container { + display: flex; + gap: 24px; + flex-wrap: wrap; + } + + .events-form .events-form-checkbox-group-wrapper.events-form-full-width .group-container.checkbox-group-container .check-item-wrap.checkbox-input-wrap { + width: auto; + } + + .events-form .field-button-wrapper { + grid-column: 1; + justify-self: self-end; + width: auto; + margin-top: var(--spacing-s); + } + + .events-form .field-button-wrapper + .field-button-wrapper { + grid-column: 2; + justify-self: self-start; + } + + .events-form button { + width: initial; + } +} + +@media screen and (min-width: 900px) { + .events-form .event-form-hero { + flex-direction: row; + } + + .events-form .field-wrapper { + display: flex; + } + + .events-form label { + width: 72%; + } +} diff --git a/blocks/events-form/events-form.js b/blocks/events-form/events-form.js new file mode 100644 index 00000000..ea9e4f7d --- /dev/null +++ b/blocks/events-form/events-form.js @@ -0,0 +1,416 @@ +import { getLibs } from '../../scripts/utils.js'; +import { getAttendeeData, getProfile, getEventId, submitToSplashThat } from '../../utils/event-apis.js'; + +const { createTag } = await import(`${getLibs()}/utils/utils.js`); +const { default: sanitizeComment } = await import(`${getLibs()}/utils/sanitizeComment.js`); + +const RULE_OPERATORS = { + equal: '=', + notEqual: '!=', + lessThan: '<', + lessThanOrEqual: '<=', + greaterThan: '>', + greaterThanOrEqual: '>=', + includes: 'inc', + excludes: 'exc', +}; + +function snakeToCamel(str) { + return str + .split('_') + .map((word, index) => (index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1))) + .join(''); +} + +function createSelect({ field, placeholder, options, defval, required }) { + const select = createTag('select', { id: field }); + if (placeholder) select.append(createTag('option', { selected: '', disabled: '' }, placeholder)); + options.split(',').forEach((o) => { + const text = o.trim(); + const option = createTag('option', { value: text }, text); + select.append(option); + if (defval === text) select.value = text; + }); + if (required === 'x') select.setAttribute('required', 'required'); + return select; +} + +function constructPayload(form) { + const payload = {}; + [...form.elements].filter((el) => el.tagName !== 'BUTTON').forEach((fe) => { + if (fe.type.match(/(?:checkbox|radio)/)) { + if (fe.checked) { + payload[fe.name] = payload[fe.name] ? `${fe.value}, ${payload[fe.name]}` : fe.value; + } else { + payload[fe.name] = payload[fe.name] || ''; + } + return; + } + payload[fe.id] = fe.value; + }); + return payload; +} + +async function submitForm(form) { + const payload = constructPayload(form); + payload.timestamp = new Date().toJSON(); + Object.keys(payload).forEach((key) => { + const field = form.querySelector(`[data-field-id=${key}]`); + if (!payload[key] && field.querySelector('.group-container.required')) { + const el = form.querySelector(`input[name="${key}"]`); + el.setCustomValidity('A selection is required'); + el.reportValidity(); + const cb = () => { + el.setCustomValidity(''); + el.reportValidity(); + field.removeEventListener('input', cb); + }; + field.addEventListener('input', cb); + return false; + } + payload[key] = sanitizeComment(payload[key]); + return true; + }); + + const response = await submitToSplashThat(payload); + + return response; +} + +function clearForm(form) { + [...form.elements].forEach((fe) => { + if (fe.type.match(/(?:checkbox|radio)/)) { + fe.checked = false; + } else { + fe.value = ''; + } + }); +} + +function createButton({ type, label }, thankYou) { + const button = createTag('button', { class: 'button' }, label); + if (type === 'submit') { + button.addEventListener('click', async (event) => { + const form = button.closest('form'); + if (form.checkValidity()) { + event.preventDefault(); + button.setAttribute('disabled', ''); + const submission = await submitForm(form); + button.removeAttribute('disabled'); + if (!submission) return; + clearForm(form); + const handleThankYou = thankYou.querySelector('a') ? thankYou.querySelector('a').href : thankYou.innerHTML; + if (!thankYou.innerHTML.includes('href')) { + const thanksText = createTag('h4', { class: 'thank-you' }, handleThankYou); + form.append(thanksText); + setTimeout(() => thanksText.remove(), 2000); + /* c8 ignore next 3 */ + } else { + window.location.href = handleThankYou; + } + } + }); + } + if (type === 'clear') { + button.classList.add('outline'); + button.addEventListener('click', (e) => { + e.preventDefault(); + const form = button.closest('form'); + clearForm(form); + }); + } + return button; +} + +function createHeading({ label }, el) { + return createTag(el, {}, label); +} + +function createInput({ type, field, placeholder, required, defval }) { + const input = createTag('input', { type, id: field, placeholder, value: defval }); + if (required === 'x') input.setAttribute('required', 'required'); + return input; +} + +function createTextArea({ field, placeholder, required, defval }) { + const input = createTag('textarea', { id: field, placeholder, value: defval }); + if (required === 'x') input.setAttribute('required', 'required'); + return input; +} + +function createlabel({ field, label, required }) { + return createTag('label', { for: field, class: required ? 'required' : '' }, label); +} + +function createCheckItem(item, type, id, def) { + const itemKebab = item.toLowerCase().replaceAll(' ', '-'); + const defList = def.split(',').map((defItem) => defItem.trim()); + const pseudoEl = createTag('span', { class: `check-item-button ${type}-button` }); + const label = createTag('label', { class: `check-item-label ${type}-label`, for: `${id}-${itemKebab}` }, item); + const input = createTag( + 'input', + { type, name: id, value: item, class: `check-item-input ${type}-input`, id: `${id}-${itemKebab}` }, + ); + if (item && defList.includes(item)) input.setAttribute('checked', ''); + return createTag('div', { class: `check-item-wrap ${type}-input-wrap` }, [input, pseudoEl, label]); +} + +function createCheckGroup({ options, field, defval, required }, type) { + const optionsMap = options.split(',').map((item) => createCheckItem(item.trim(), type, field, defval)); + return createTag( + 'div', + { class: `group-container ${type}-group-container${required === 'x' ? ' required' : ''}` }, + optionsMap, + ); +} + +function createDivider() { + return ''; +} + +function processNumRule(tf, operator, a, b) { + /* c8 ignore next 3 */ + if (!tf.dataset.type.match(/(?:number|date)/)) { + throw new Error(`Comparison field must be of type number or date for ${operator} rules`); + } + const { type } = tf.dataset; + const a2 = type === 'number' ? parseInt(a, 10) : Date.parse(a); + const b2 = type === 'number' ? parseInt(b, 10) : Date.parse(b); + return [a2, b2]; +} + +function processRule(tf, operator, payloadKey, value, comparisonFunction) { + if (payloadKey === '') return true; + try { + const [a, b] = processNumRule(tf, operator, payloadKey, value); + return comparisonFunction(a, b); + /* c8 ignore next 5 */ + } catch (e) { + // eslint-disable-next-line no-console + console.warn(`Invalid rule, ${e}`); + return false; + } +} + +function applyRules(form, rules) { + const payload = constructPayload(form); + rules.forEach((field) => { + const { type, condition: { key, operator, value } } = field.rule; + const fw = form.querySelector(`[data-field-id=${field.fieldId}]`); + const tf = form.querySelector(`[data-field-id=${key}]`); + let force = false; + switch (operator) { + case RULE_OPERATORS.equal: + force = (payload[key] === value); + break; + case RULE_OPERATORS.notEqual: + force = (payload[key] !== value); + break; + case RULE_OPERATORS.includes: + force = (payload[key].split(',').map((s) => s.trim()).includes(value)); + break; + case RULE_OPERATORS.excludes: + force = (!payload[key].split(',').map((s) => s.trim()).includes(value)); + break; + case RULE_OPERATORS.lessThan: + force = processRule(tf, operator, payload[key], value, (a, b) => a < b); + break; + case RULE_OPERATORS.lessThanOrEqual: + force = processRule(tf, operator, payload[key], value, (a, b) => a <= b); + break; + case RULE_OPERATORS.greaterThan: + force = processRule(tf, operator, payload[key], value, (a, b) => a > b); + break; + case RULE_OPERATORS.greaterThanOrEqual: + force = processRule(tf, operator, payload[key], value, (a, b) => a >= b); + break; + default: + // eslint-disable-next-line no-console + console.warn(`Unsupported operator ${operator}`); + return false; + } + fw.classList.toggle(type, force); + return false; + }); +} + +function lowercaseKeys(obj) { + return Object.keys(obj).reduce((acc, key) => { + acc[key.toLowerCase() === 'default' ? 'defval' : key.toLowerCase()] = obj[key]; + return acc; + }, {}); +} + +function insertAvatar(form, avatar) { + if (!avatar) return; + + const firstFormDivider = form.querySelector('.divider'); + const oldAvatarCont = avatar.parentElement; + if (!firstFormDivider) { + form.append(avatar); + } else { + const firstSec = createTag('div', { class: 'first-form-section events-form-full-width' }); + const inputsWrapper = createTag('div', { class: 'first-form-section-input-wrapper' }); + const firstFormSecEls = []; + let previousNode = firstFormDivider.previousElementSibling; + + while (previousNode && firstFormSecEls.length <= 4) { + if (['text', 'email', 'phone'].includes(previousNode.querySelector('input')?.type)) firstFormSecEls.push(previousNode); + previousNode = previousNode.previousElementSibling; + } + + firstFormSecEls.forEach((el) => { + inputsWrapper.prepend(el); + }); + + form.prepend(firstSec); + firstSec.append(avatar, inputsWrapper); + } + + if (!oldAvatarCont?.innerHTML?.trim() && !oldAvatarCont?.className) oldAvatarCont.remove(); +} + +async function createForm(formURL, thankYou, formData, avatar, actionUrl = '') { + const { pathname } = new URL(formURL); + let json = formData; + /* c8 ignore next 4 */ + if (!formData) { + const resp = await fetch(pathname); + json = await resp.json(); + } + json.data = json.data.map((obj) => lowercaseKeys(obj)); + const form = createTag('form'); + const rules = []; + const [action] = pathname.split('.json'); + form.dataset.action = actionUrl || action; + + const typeToElement = { + select: { fn: createSelect, params: [], label: true, classes: [] }, + heading: { fn: createHeading, params: ['h3'], label: false, classes: [] }, + legal: { fn: createHeading, params: ['p'], label: false, classes: [] }, + checkbox: { fn: createCheckGroup, params: ['checkbox'], label: true, classes: ['field-group-wrapper'] }, + 'checkbox-group': { fn: createCheckGroup, params: ['checkbox'], label: true, classes: ['field-group-wrapper'] }, + 'radio-group': { fn: createCheckGroup, params: ['radio'], label: true, classes: ['field-group-wrapper'] }, + 'text-area': { fn: createTextArea, params: [], label: true, classes: [] }, + submit: { fn: createButton, params: [thankYou], label: false, classes: ['field-button-wrapper'] }, + clear: { fn: createButton, params: [thankYou], label: false, classes: ['field-button-wrapper'] }, + divider: { fn: createDivider, params: [], label: false, classes: ['divider'] }, + default: { fn: createInput, params: [], label: true, classes: [] }, + }; + + json.data.forEach((fd) => { + fd.type = fd.type || 'text'; + const style = fd.extra ? ` events-form-${fd.extra}` : ''; + const fieldWrapper = createTag( + 'div', + { class: `field-wrapper events-form-${fd.type}-wrapper${style}`, 'data-field-id': fd.field, 'data-type': fd.type }, + ); + + const elParams = typeToElement[fd.type] || typeToElement.default; + if (elParams.label) fieldWrapper.append(createlabel(fd)); + fieldWrapper.append(elParams.fn(fd, ...elParams.params)); + fieldWrapper.classList.add(...elParams.classes); + + if (fd.rules?.length) { + try { + rules.push({ fieldId: fd.field, rule: JSON.parse(fd.rules) }); + /* c8 ignore next 4 */ + } catch (e) { + // eslint-disable-next-line no-console + console.warn(`Invalid Rule ${fd.rules}: ${e}`); + } + } + form.append(fieldWrapper); + }); + + form.addEventListener('input', () => applyRules(form, rules)); + applyRules(form, rules); + + insertAvatar(form, avatar); + return form; +} + +function personalizeForm(form, resp) { + if (!resp || !form) return; + + Object.entries(resp).forEach(([key, value]) => { + const matchedInput = form.querySelector(`#${snakeToCamel(key)}`); + if (matchedInput) matchedInput.value = value; + }); +} + +function decorateHero(heroEl) { + heroEl.classList.add('event-form-hero'); +} + +async function decorateRSVPStatus(bp, profile) { + const data = await getAttendeeData(profile.email, getEventId()); + if (!data) return; + + if (data.registered) { + const successLabel = createTag('div', { class: 'rsvp-status-label' }, 'You have previously registered for this event. Feel free to use the form below to RSVP for another guest.'); + bp.formContainer.before(successLabel); + } +} + +async function updateDynamicContent(bp) { + const { block, eventHero } = bp; + await Promise.all([ + import(`${getLibs()}/utils/getUuid.js`), + import('../../utils/event-apis.js'), + import('../page-server/page-server.js'), + ]).then(async ([{ default: getUuid }, caasApiMod, { autoUpdateContent }]) => { + const hash = await getUuid(window.location.pathname); + let profile; + + try { + profile = await getProfile(); + } catch (e) { + eventHero.querySelectorAll('p')?.forEach((p) => p.remove()); + } + + if (profile) { + await decorateRSVPStatus(bp, profile); + } + + await autoUpdateContent(block, { ...await caasApiMod.default(hash), ...profile }, true); + eventHero.classList.remove('loading'); + personalizeForm(block, profile); + }); +} + +async function buildEventform(bp, formData) { + if (!bp.formContainer || !bp.form) return; + bp.formContainer.classList.add('form-container'); + const avatar = bp.formContainer.querySelector('div:first-of-type > picture'); + const constructedForm = await createForm( + bp.form.href, + bp.thankYou, + formData, + avatar, + bp.eventAction?.href, + ); + if (constructedForm) bp.form.replaceWith(constructedForm); +} + +export default async function decorate(block, formData = null) { + block.style.opacity = 0; + const bp = { + block, + eventHero: block.querySelector(':scope > div:nth-of-type(1)'), + formContainer: block.querySelector(':scope > div:nth-of-type(2)'), + form: block.querySelector(':scope > div:nth-of-type(2) a[href$=".json"]'), + thankYou: block.querySelector(':scope > div:nth-of-type(3) > div'), + eventAction: block.querySelector(':scope > div:last-of-type > div > a'), + }; + + bp.thankYou?.remove(); + decorateHero(bp.eventHero); + buildEventform(bp, formData) + .then(async () => { await updateDynamicContent(bp); }) + .then(() => { + block.style.opacity = 1; + }).catch(() => { + block.innerHTML = 'Failed to load registration form. Please refresh the page.'; + }); +} diff --git a/blocks/page-server/page-server.css b/blocks/page-server/page-server.css index e69de29b..e2010647 100644 --- a/blocks/page-server/page-server.css +++ b/blocks/page-server/page-server.css @@ -0,0 +1,3 @@ +.page-server { + display: none; +} diff --git a/blocks/page-server/page-server.js b/blocks/page-server/page-server.js index ad882c2d..609fa7f5 100644 --- a/blocks/page-server/page-server.js +++ b/blocks/page-server/page-server.js @@ -1,69 +1,143 @@ -import HtmlSanitizer from '../../deps/html-sanitizer.js'; -import { getMetadata, yieldToMain } from '../../utils/utils.js'; - -const ignoredMeta = [ - 'serp-content-type', - 'description', - 'primaryproductname', - 'theme', - 'show-free-plan', - 'sheet-powered', - 'viewport', -]; - -async function sanitizeMeta(meta) { - if (meta.property || meta.name.includes(':') || ignoredMeta.includes(meta.name)) return; - await yieldToMain(); - meta.content = HtmlSanitizer.SanitizeHtml(meta.content); -} - -export function titleCase(str) { - const splitStr = str.toLowerCase().split('-'); - for (let i = 0; i < splitStr.length; i += 1) { - splitStr[i] = splitStr[i].charAt(0).toUpperCase() + splitStr[i].substring(1); - } - return splitStr.join(' '); -} - -// metadata -> dom blades -async function autoUpdatePage(main) { - if (!main) { - window.lana?.log('page server block cannot find it\'s parent main'); - return; - } - - const regex = /\[\[([a-zA-Z0-9_-]+)]]/g; - - const metaTags = document.head.querySelectorAll('meta'); +import { getMetadata } from '../../utils/utils.js'; +import fetchPageData, { flattenObject } from '../../utils/event-apis.js'; +import { getLibs } from '../../scripts/utils.js'; - await Promise.all(Array.from(metaTags).map((meta) => sanitizeMeta(meta))); +const PLACEHOLDER_REG = /\[\[(.*?)\]\]/g; - main.innerHTML = main.innerHTML.replaceAll(regex, (_match, p1) => { - // todo: instead of using metadata, try using event metadata fetched from Event Service Layer - const { pathname } = window.location; - - if (p1 === 'event-name') { - const pathArray = pathname.split('/'); - return titleCase(pathArray[pathArray.length - 1]); +function handleRegisterButton(a) { + const signIn = () => { + if (typeof window.adobeIMS?.signIn !== 'function') { + window?.lana.log({ message: 'IMS signIn method not available', tags: 'errorType=warn,module=gnav' }); + return; } - return getMetadata(p1); + window.adobeIMS.signIn(); + }; + + a.addEventListener('click', (e) => { + e.preventDefault(); + signIn(); }); +} - // handle link replacement - main.querySelectorAll('a[href*="#"]').forEach((a) => { +function autoUpdateLinks(scope) { + scope.querySelectorAll('a[href*="#"]').forEach(async (a) => { try { let url = new URL(a.href); if (getMetadata(url.hash.replace('#', ''))) { a.href = getMetadata(url.hash.replace('#', '')); url = new URL(a.href); } + + if (a.href.endsWith('#rsvp-form')) { + const profile = window.bm8tr.get('imsProfile'); + if (profile?.noProfile) { + handleRegisterButton(a); + } else if (!profile) { + window.bm8tr.subscribe('imsProfile', ({ newValue }) => { + if (newValue?.noProfile) { + handleRegisterButton(a); + } + }); + } + } } catch (e) { window.lana?.log(`Error while attempting to replace link ${a.href}: ${e}`); } }); } -export default function init(el) { - autoUpdatePage(el.closest('main')); +function updateImgTag(child, matchCallback, parentElement) { + const parentPic = child.closest('picture'); + const originalAlt = child.alt; + const replacedSrc = originalAlt.replace(PLACEHOLDER_REG, matchCallback); + + if (replacedSrc && parentPic && replacedSrc !== originalAlt) { + parentPic.querySelectorAll('source').forEach((el) => { + try { + el.srcset = el.srcset.replace(/.*\?/, `${replacedSrc}?`); + } catch (e) { + window.lana?.log(`failed to convert optimized picture source from ${el} with dynamic data: ${e}`); + } + }); + + parentPic.querySelectorAll('img').forEach((el) => { + try { + el.src = el.src.replace(/.*\?/, `${replacedSrc}?`); + } catch (e) { + window.lana?.log(`failed to convert optimized img from ${el} with dynamic data: ${e}`); + } + }); + } else if (originalAlt.match(PLACEHOLDER_REG)) { + parentElement.remove(); + } +} + +function updateTextNode(child, matchCallback) { + const originalText = child.nodeValue; + const replacedText = originalText.replace(PLACEHOLDER_REG, matchCallback); + if (replacedText !== originalText) child.nodeValue = replacedText; +} + +function autoUpdateMetadata(res) { + if (!res) return; + + if (res['contentArea.title']) document.title = res['contentArea.title']; + + if (!res['contentArea.description']) return; + + const metaDescription = document.querySelector("meta[name='description']"); + if (metaDescription) { + metaDescription.setAttribute('content', res['contentArea.description']); + } else { + const newMetaDescription = document.createElement('meta'); + newMetaDescription.setAttribute('name', 'description'); + newMetaDescription.setAttribute('content', res['contentArea.description']); + document.head.appendChild(newMetaDescription); + } +} + +// data -> dom gills +export async function autoUpdateContent(parent, data, isStructured = false) { + if (!parent) { + window.lana?.log('page server block cannot find its parent element'); + return null; + } + + if (!data) { + document.body.style.display = 'none'; + window.location.replace('/404'); + } + + const res = isStructured ? flattenObject(data) : data; + console.log('Replacing content with:', res); + const findRegexMatch = (_match, p1) => res[p1] || ''; + const allElements = parent.querySelectorAll('*'); + + allElements.forEach((element) => { + if (element.childNodes.length) { + element.childNodes.forEach((child) => { + if (child.tagName === 'IMG' && child.nodeType === 1) { + updateImgTag(child, findRegexMatch, element); + } + + if (child.nodeType === 3) { + updateTextNode(child, findRegexMatch); + } + }); + } + }); + + // handle link replacement. To keep when switching to metadata based rendering + autoUpdateLinks(parent); + + // TODO: handle Metadata + autoUpdateMetadata(res); + return res; +} + +export default async function init(el) { + const { default: getUuid } = await import(`${getLibs()}/utils/getUuid.js`); + const hash = await getUuid(window.location.pathname); + await autoUpdateContent(el.closest('main'), await fetchPageData(hash, true), true); } diff --git a/deps/block-mediator.min.js b/deps/block-mediator.min.js new file mode 100644 index 00000000..f2a0fa17 --- /dev/null +++ b/deps/block-mediator.min.js @@ -0,0 +1 @@ +const BlockMediator=(()=>{const l={};const a=c=>{l[c]={callbacks:[],value:undefined}};const r=c=>c in l;const c=()=>Object.keys(l);const u=c=>l[c]?.value;const s=(c,s)=>{if(!r(c)){a(c)}const t=u(c);l[c].value=s;const e=[];for(const o of l[c].callbacks){try{o({oldValue:t,newValue:s})}catch(c){e.push(c)}}if(e.length>0){const n=new Error(e.map(c=>c.message).join("\n"));n.errors=e;throw n}};const t=(c,s)=>{if(!r(c)){a(c)}const t=l[c];if(t.callbacks.includes(s))return()=>{};t.callbacks.push(s);const e=()=>{t.callbacks=t.callbacks.filter(c=>c!==s)};return e};return{hasStore:r,listStores:c,get:u,set:s,subscribe:t}})();export default BlockMediator; diff --git a/head.html b/head.html index 5b847f1d..cab53649 100644 --- a/head.html +++ b/head.html @@ -2,5 +2,6 @@ + diff --git a/scripts/scripts.js b/scripts/scripts.js index 60332e9d..bc72ecc9 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -22,7 +22,8 @@ const LIBS = '/libs'; const CONFIG = { // codeRoot: '', // contentRoot: '', - // imsClientId: 'college', + // TODO: we need client ID for Events Milo + imsClientId: 'adobedotcomdx', // imsScope: 'AdobeID,openid,gnav', // geoRouting: 'off', // fallbackRouting: 'off', @@ -45,6 +46,8 @@ decorateArea(); const miloLibs = setLibs(LIBS); +window.bm8tr = await import('../deps/block-mediator.min.js').then((mod) => mod.default); + (function loadStyles() { const paths = [`${miloLibs}/styles/styles.css`]; if (STYLES) { paths.push(STYLES); } @@ -57,8 +60,9 @@ const miloLibs = setLibs(LIBS); }()); (async function loadPage() { - const { loadArea, setConfig } = await import(`${miloLibs}/utils/utils.js`); + const { loadArea, setConfig, loadDelayed } = await import(`${miloLibs}/utils/utils.js`); const config = setConfig({ ...CONFIG, miloLibs }); console.log(config); await loadArea(); + loadDelayed(); }()); diff --git a/scripts/utils.js b/scripts/utils.js index b98aedb6..1b53d73d 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -44,12 +44,12 @@ export function decorateArea(area = document) { }; (async function loadLCPImage() { - const marquee = document.querySelector('.marquee'); + const marquee = area.querySelector('.marquee'); if (!marquee) { - eagerLoad(document, 'img'); + eagerLoad(area, 'img'); return; } - + // First image of first row eagerLoad(marquee, 'div:first-child img'); // Last image of last column of last row @@ -57,6 +57,6 @@ export function decorateArea(area = document) { }()); } -export async function useMiloSample() { - const { createTag } = await import(`${getLibs()}/utils/utils.js`); +export async function importMiloUtils() { + return import(`${getLibs()}/utils/utils.js`); } diff --git a/styles/styles.css b/styles/styles.css index e6e7755c..94b405f7 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -6,3 +6,9 @@ * * */ + + main a.con-button.no-event { + user-select: none; + pointer-events: none; + opacity: 0.5; + } diff --git a/test/scripts/mocks/body-with-marquee.html b/test/scripts/mocks/body-with-marquee.html new file mode 100644 index 00000000..90d068d0 --- /dev/null +++ b/test/scripts/mocks/body-with-marquee.html @@ -0,0 +1,30 @@ +
+
+
+ +
+

mock-image

+
+
+
+ +

mock-image

+

mock-image

+ + +

Milo Test

+

Milo Test

+

Milo Test

+ +

Adobe TV

+

Open modal

+ +

https://milo.adobe.com/img/favicon.svg

+

https://milo.adobe.com/img/favicon.svg

+
+ +
+

I'm not a blockhead.

+
+
+ diff --git a/test/scripts/mocks/body-without-marquee.html b/test/scripts/mocks/body-without-marquee.html new file mode 100644 index 00000000..db5b71db --- /dev/null +++ b/test/scripts/mocks/body-without-marquee.html @@ -0,0 +1,30 @@ +
+
+
+ +
+

mock-image

+
+
+
+ +

mock-image

+

mock-image

+ + +

Milo Test

+

Milo Test

+

Milo Test

+ +

Adobe TV

+

Open modal

+ +

https://milo.adobe.com/img/favicon.svg

+

https://milo.adobe.com/img/favicon.svg

+
+ +
+

I'm not a blockhead.

+
+
+ diff --git a/test/scripts/mocks/head.html b/test/scripts/mocks/head.html new file mode 100644 index 00000000..6956dd54 --- /dev/null +++ b/test/scripts/mocks/head.html @@ -0,0 +1,5 @@ + + + + + diff --git a/test/scripts/utils.test.js b/test/scripts/utils.test.js index ccc0ac95..e79aa7ed 100644 --- a/test/scripts/utils.test.js +++ b/test/scripts/utils.test.js @@ -1,45 +1,22 @@ +import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; -import { setLibs } from '../../scripts/utils.js'; +import { decorateArea } from '../../scripts/utils.js'; -describe('Libs', () => { - it('Default Libs', () => { - const libs = setLibs('/libs'); - expect(libs).to.equal('https://main--milo--adobecom.hlx.live/libs'); - }); - - it('Does not support milolibs query param on prod', () => { - const location = { - hostname: 'business.adobe.com', - search: '?milolibs=foo', - }; - const libs = setLibs('/libs', location); - expect(libs).to.equal('/libs'); - }); - - it('Supports milolibs query param', () => { - const location = { - hostname: 'localhost', - search: '?milolibs=foo', - }; - const libs = setLibs('/libs', location); - expect(libs).to.equal('https://foo--milo--adobecom.hlx.live/libs'); - }); +document.head.innerHTML = await readFile({ path: './mocks/head.html' }); +const marqueeMain = await readFile({ path: './mocks/body-with-marquee.html' }); +const heroMain = await readFile({ path: './mocks/body-without-marquee.html' }); - it('Supports local milolibs query param', () => { - const location = { - hostname: 'localhost', - search: '?milolibs=local', - }; - const libs = setLibs('/libs', location); - expect(libs).to.equal('http://localhost:6456/libs'); +describe('Decorating LCP', () => { + it('with marquee', () => { + document.body.innerHTML = marqueeMain; + decorateArea(document.querySelector('main')); + console.log(document.body.querySelector('img')?.loading); + expect(document.body.querySelector('img').getAttribute('loading')).to.equal(null); }); - it('Supports forked milolibs query param', () => { - const location = { - hostname: 'localhost', - search: '?milolibs=awesome--milo--forkedowner', - }; - const libs = setLibs('/libs', location); - expect(libs).to.equal('https://awesome--milo--forkedowner.hlx.live/libs'); + it('without marquee', () => { + document.body.innerHTML = heroMain; + decorateArea(document.querySelector('main')); + expect(document.body.querySelector('img').getAttribute('loading')).to.equal(null); }); }); diff --git a/utils/event-apis.js b/utils/event-apis.js new file mode 100644 index 00000000..33f1dd60 --- /dev/null +++ b/utils/event-apis.js @@ -0,0 +1,192 @@ +const CAAS_API_ENDPOINT = 'https://14257-chimera-dev.adobeioruntime.net/api/v1/web/chimera-0.0.1/sm-collection'; +const API_QUERY_PARAM = 'featuredCards'; + +const pageDataCache = {}; + +export function getEventId() { + return window.bm8tr.get('eventData')?.arbitrary?.[0]?.value?.split('|')?.[1]; +} + +export function flattenObject(obj, parentKey = '', result = {}) { + Object.keys(obj).forEach((key) => { + const value = obj[key]; + const newKey = parentKey ? `${parentKey}.${key}` : key; + + if (key === 'arbitrary' && Array.isArray(value)) { + value.forEach((item) => { + const itemKey = `${newKey}.${item.key}`; + result[itemKey] = item.value; + }); + } else if (value && typeof value === 'object' && !Array.isArray(value)) { + flattenObject(value, newKey, result); + } else if (Array.isArray(value)) { + value.forEach((item, index) => { + if (typeof item === 'object' && !Array.isArray(item) && key !== 'arbitrary') { + flattenObject(item, `${newKey}[${index}]`, result); + } else { + result[`${newKey}[${index}]`] = item; + } + }); + } else { + result[newKey] = value; + } + }); + + return result; +} + +export async function fetchAvatar() { + const te = await window.adobeIMS.tokenService.getTokenAndProfile(); + const myHeaders = new Headers(); + myHeaders.append('Authorization', `Bearer ${te.tokenFields.tokenValue}`); + + const requestOptions = { + method: 'GET', + headers: myHeaders, + redirect: 'follow', + }; + + const avatar = await fetch('https://cc-collab-stage.adobe.io/profile', requestOptions) + .then((response) => response.json()) + .then((result) => result) + .catch((error) => console.error(error)); + + return avatar?.user?.avatar; +} + +export async function getProfile() { + const { feds, adobeProfile, fedsConfig, adobeIMS } = window; + + const getUserProfile = () => { + if (fedsConfig?.universalNav) { + return feds?.services?.universalnav?.interface?.adobeProfile?.getUserProfile() + || adobeProfile?.getUserProfile(); + } + + return ( + feds?.services?.profile?.interface?.adobeProfile?.getUserProfile() + || adobeProfile?.getUserProfile() + || adobeIMS?.getProfile() + ); + }; + + const [profile, avatar] = await Promise.all([ + getUserProfile(), + fetchAvatar(), + ]); + + if (profile) { + profile.avatar = avatar; + console.log('Fetched user profile:', profile); + return profile; + } + + return {}; +} + +export async function getAttendeeData(email, eventId) { + if (!email || !eventId) return null; + + const myHeaders = new Headers(); + myHeaders.append('x-api-key', 'CCHomeWeb1'); + + const requestOptions = { + method: 'GET', + headers: myHeaders, + redirect: 'follow', + }; + + const data = await fetch(`https://cchome-stage.adobe.io/lod/v1/events/st-${eventId}/attendees/${email}`, requestOptions) + .then((response) => response.json()) + .then((result) => result) + .catch((error) => console.error(error)); + + console.log('Fetched attendee data:', data); + return data; +} + +export async function submitToSplashThat(payload) { + const myHeaders = new Headers(); + myHeaders.append('x-api-key', 'CCHomeWeb1'); + myHeaders.append('Content-Type', 'application/json'); + + const raw = JSON.stringify(payload); + + const requestOptions = { + method: 'POST', + headers: myHeaders, + body: raw, + redirect: 'follow', + }; + + const eventId = getEventId(); + + if (!eventId) return false; + // TODO: use real event ID when ready + const resp = await fetch('https://cchome-stage.adobe.io/lod/v1/events/st-458926431/attendees', requestOptions).then((response) => response); + // const resp = await fetch(`https://cchome-stage.adobe.io/lod/v1/events/st-${eventId}/attendees`, requestOptions).then((response) => response); + + console.log('Submitted registration to SplashThat:', payload); + resp.json().then((json) => { + console.log('Event Service Layer response:', json); + }); + + if (!resp.ok) return false; + + return payload; +} + +function lazyCaptureProfile() { + let attempCounter = 0; + const profileRetryer = setInterval(async () => { + if (!window.adobeIMS) { + attempCounter += 1; + return; + } + + if (attempCounter >= 10) { + clearInterval(profileRetryer); + } + + try { + const profile = await getProfile(); + window.bm8tr.set('imsProfile', profile); + clearInterval(profileRetryer); + } catch { + if (window.adobeIMS) { + clearInterval(profileRetryer); + window.bm8tr.set('imsProfile', { noProfile: true }); + } + + attempCounter += 1; + } + }, 1000); +} + +export default async function fetchPageData(hash, lazyLoadProfile = false) { + if (pageDataCache[hash]) { + return pageDataCache[hash]; + } + + try { + const response = await fetch(`${CAAS_API_ENDPOINT}?${API_QUERY_PARAM}=${hash}`); + if (!response.ok) { + window.lana?.log('Error while attempting to fetch event data event service layer'); + return null; + } + + const json = await response.json(); + + if (!json) return null; + + const [pageData] = json.cards; + pageDataCache[hash] = pageData; + + if (lazyLoadProfile) lazyCaptureProfile(); + window.bm8tr.set('eventData', pageData); + return pageData; + } catch (error) { + window.lana?.log('Fetch error:', error); + return null; + } +} diff --git a/utils/utils.js b/utils/utils.js index c83a1d96..1663de76 100644 --- a/utils/utils.js +++ b/utils/utils.js @@ -1,8 +1,7 @@ - export function getMetadata(name) { const attr = name && name.includes(':') ? 'property' : 'name'; - const $meta = document.head.querySelector(`meta[${attr}="${name}"]`); - return ($meta && $meta.content) || ''; + const meta = document.head.querySelector(`meta[${attr}="${name}"]`); + return (meta && meta.content) || ''; } export function yieldToMain() {