diff --git a/.eslintignore b/.eslintignore index 158f7d56..944d9b0a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,5 @@ helix-importer-ui /scripts/alloy*.js -/tools/actions/ \ No newline at end of file +/tools/actions/ +/tools/importer \ No newline at end of file diff --git a/blocks/cards/cards.css b/blocks/cards/cards.css index 07916ddb..9cdc7b83 100644 --- a/blocks/cards/cards.css +++ b/blocks/cards/cards.css @@ -1,3 +1,5 @@ +/* stylelint-disable no-descending-specificity */ + .cards { --heading-font-size-m: 1.125rem; } @@ -64,6 +66,7 @@ } .cards > ul > li img { + display: inline-block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; @@ -73,6 +76,91 @@ text-decoration: none; } +.cards-pagination ul { + list-style-type: none; + display: flex; + gap: 0.5rem; + padding: 0; + justify-content: center; +} + +.cards-pagination ul li > a { + font-family: var(--body-font-family); + font-size: .75rem; + line-height: 1.313rem; + color: var(--text-color); + text-decoration: none; + border: 2px solid var(--text-color); + border-radius: 10px; + padding: 5px 10px; +} + +.cards-pagination ul li.active > a { + background-color: var(--text-color); + color: var(--background-color); + pointer-events: none; +} + +.cards-searchbar { + padding: 0.9375rem; + box-shadow: 0 4px 3px #00000040; + border-radius: 10px; + margin-bottom: 40px; +} + +.cards-searchbar label { + display: none; +} + +.cards-searchbar input { + max-width: unset; + border: 0; + border-radius: 0; + border-bottom: 2px solid var(--text-color); +} + +.cards-filterselect { + display: flex; + gap: 0.5rem; + flex-direction: column; +} + +.cards-filterselect .total, .cards-filterselect label { + font-family: var(--heading-font-family); + font-weight: 700; + line-height: 1.625rem; +} + +.cards-filterselect select { + appearance: none; + background-color: transparent; + border: none; + padding: 1rem 1rem 1rem 0; + width: 100%; + font-size: 18px; +} + +.cards-filterselect .select-group { + display: flex; + align-items: center; + cursor: pointer; + border-bottom: 2px solid var(--text-color); + max-width: 300px; + margin: 0 0 2rem; +} + +.cards-filterselect .select-group::after { + content: ''; + justify-self: end; + width: 11px; + height: 11px; + margin-top: 13px; + background-image: url('/icons/arrow-down.png'); + background-repeat: no-repeat; + background-size: contain; + padding-bottom: 1rem; +} + @media (min-width: 768px) { .cards > ul { grid-template-columns: 1fr 1fr 1fr; diff --git a/blocks/cards/cards.js b/blocks/cards/cards.js index ec701b82..3855beb9 100644 --- a/blocks/cards/cards.js +++ b/blocks/cards/cards.js @@ -1,12 +1,86 @@ -import { createOptimizedPicture } from '../../scripts/lib-franklin.js'; +import { createOptimizedPicture, getMetadata } from '../../scripts/lib-franklin.js'; -const host = 'https://www.24petwatch.com'; -const sitesearchUrl = `https://${window.location.hostname.includes('aem-stage') ? 'aem-stage' : 'www'}.24petwatch.com/bin/24pethealth/sitesearch.json`; -const sitesearchPayload = JSON.stringify({ - context: '/content/24petwatch/us/en/blog/jcr:content/root/container/container_177885842/container_51910998/contentsearchresults', - resultsPerPage: 3, - requestedPage: 1, -}); +const isCanada = window.location.pathname.startsWith('/ca/'); + +async function getTagFilters() { + let index = new URL(`${isCanada ? '/ca' : ''}/blog/tag-filters.json`, window.location.origin); + if (!window.location.hostname.includes('24petwatch.com')) { + index = new URL(`https://main--24petwatch--hlxsites.hlx.live${isCanada ? '/ca' : ''}/blog/tag-filters.json`); + } + const response = await fetch(index); + const json = await response.json(); + return json.data; +} + +async function loadBlogPosts() { + let index = new URL(`${isCanada ? '/ca' : ''}/blog/query-index.json`, window.location.origin); + if (!window.location.hostname.includes('24petwatch.com')) { + index = new URL(`https://main--24petwatch--hlxsites.hlx.live${isCanada ? '/ca' : ''}/blog/query-index.json`); + } + const chunkSize = 100; + const loadChunk = async (offset) => { + index.searchParams.set('limit', chunkSize); + index.searchParams.set('offset', offset); + + const response = await fetch(index); + const json = await response.json(); + + // Check if more has to be loaded + if (json.total > offset + chunkSize) { + return { + data: [...json.data, ...(await loadChunk(offset + 100)).data], + total: json.total, + }; + } + return { + data: json.data, + total: json.total, + }; + }; + + if (!window.blogPosts) { + window.blogPosts = await loadChunk(0); + } + return window.blogPosts; +} + +// eslint-disable-next-line no-unused-vars +const fetchBlogPosts = async (page = 1, tags = [], searchTerm = '', pagesize = 9) => { + let { data, total } = await loadBlogPosts(); + + // Filter by tags + if (tags.length > 0) { + data = data.filter(({ tags: blogTag }) => tags.some((t) => blogTag.includes(t))); + total = data.length; + } + + // Filter by search term + if (searchTerm) { + data = data + .filter(({ title, description }) => title.toLowerCase().includes(searchTerm.toLowerCase()) + || description.toLowerCase().includes(searchTerm.toLowerCase())); + total = data.length; + } + + // Filter by page + const start = (page - 1) * pagesize; + const end = start + pagesize; + + let currentPage = page; + if (currentPage > Math.ceil(total / pagesize)) { + currentPage = Math.ceil(total / pagesize); + } + if (currentPage < 1) { + currentPage = 1; + } + + return { + items: data.slice(start, end), + pages: Math.ceil(total / pagesize), + currentPage, + total, + }; +}; function wrapInAnchor(element, href) { const anchor = document.createElement('a'); @@ -17,34 +91,165 @@ function wrapInAnchor(element, href) { } function createBlogCard(item = {}) { - const blogThumbnail = `${host}${item.url.replace('.html', '')}.thumb.319.319.png`; - const blogUrl = `${host}${item.url.substring(item.url.indexOf('/blog')).replace('.html', '')}`; - - return `
- ${item.name} -
-
-

${item.name}

-

${item.description}

-

Read more

-
`; + let { title, image, path } = item; + const { description } = item; + + if (!window.location.hostname.includes('24petwatch.com')) { + path = new URL(path, 'https://www.24petwatch.com').toString(); + } + try { + image = new URL(image, window.location); + image.hostname = window.location.hostname; + image.port = window.location.port; + image.protocol = window.location.protocol; + } catch (e) { /* ignore */ } + if (title.startsWith('24Petwatch: ')) { + title = title.replace('24Petwatch: ', ''); + } + + return document.createRange().createContextualFragment(` +
+ + ${title} + +
+
+

${title}

+

${description}

+

+ Read more +

+
+ `); +} + +function createPagination(block, pages, currentPage) { + let pageSet = new Set([1, pages, currentPage, currentPage - 1, currentPage + 1]); + pageSet = Array.from(pageSet) + .filter((a) => a > 0 && a <= pages) + .sort((a, b) => a - b); + + const onPaginate = (e) => { + const hrefPage = parseInt(new URL(e.target.href).searchParams.get('page'), 10); + const newUrl = new URL(window.location); + newUrl.searchParams.set('page', hrefPage); + window.history.pushState({}, '', newUrl.toString()); + e.preventDefault(); + // eslint-disable-next-line no-use-before-define + decorate(block); + }; + + const pagination = document.createRange().createContextualFragment(` +
+ +
`); + block.closest('.cards-wrapper').appendChild(pagination); + block.closest('.cards-wrapper').querySelectorAll('.cards-pagination a').forEach((a) => a.addEventListener('click', onPaginate)); +} + +function createSearchBox(block, searchTerm) { + const onSubmit = (e) => { + const newSearchTerm = e.target.querySelector('input').value; + const newUrl = new URL(window.location); + if (newSearchTerm !== '') { + newUrl.searchParams.set('search', newSearchTerm); + } else { + newUrl.searchParams.delete('search'); + } + newUrl.searchParams.set('page', 1); + window.history.pushState({}, '', newUrl.toString()); + e.preventDefault(); + // eslint-disable-next-line no-use-before-define + decorate(block); + }; + + const searchbar = document.createRange().createContextualFragment(` + + `); + block.closest('.cards-wrapper').prepend(searchbar); + block.closest('.cards-wrapper').querySelector('.cards-searchbar form').addEventListener('submit', onSubmit); +} + +async function createFilterSelect(block, total, currentTag) { + const tags = await getTagFilters(); + + const onChange = (e) => { + const newUrl = new URL(window.location); + if (e.target.value !== '') { + newUrl.searchParams.set('tag', e.target.value); + } else { + newUrl.searchParams.delete('tag'); + } + newUrl.searchParams.set('page', 1); + window.history.pushState({}, '', newUrl.toString()); + // eslint-disable-next-line no-use-before-define + decorate(block); + }; + + const filterselect = document.createRange().createContextualFragment(` +
+
${total} Results
+ +
+ +
+
+ `); + block.closest('.cards-wrapper').insertBefore(filterselect, block.closest('.cards-wrapper').querySelector('.block')); + block.closest('.cards-wrapper').querySelector('.cards-filterselect select').addEventListener('change', onChange); } async function populateBlogTeaser(block) { - const response = await fetch(sitesearchUrl, { - method: 'POST', - body: sitesearchPayload, - headers: { - 'Content-Type': 'application/json', - }, + const tags = getMetadata('article:tag').split(', '); + const response = await fetchBlogPosts(1, tags, '', 3); + response.items.forEach((item) => { + const card = document.createElement('div'); + card.appendChild(createBlogCard(item)); + block.appendChild(card); }); - const blogItems = await response.json(); +} - (blogItems.results || []).forEach((item) => { +async function populateBlogGrid(block) { + const searchParams = new URLSearchParams(window.location.search); + const page = parseInt(searchParams.get('page'), 10) || 1; + const searchTerm = searchParams.get('search') || ''; + const tags = (searchParams.get('tag') ? [searchParams.get('tag')] : []).map((t) => t.replace(/[^a-z0-9-]/g, '')); + const { + items, pages, currentPage, total, + } = await fetchBlogPosts(page, tags, searchTerm.replace(/[^a-zA-Z0-9 ]/g, ''), 9); + items.forEach((item) => { const card = document.createElement('div'); - card.innerHTML = createBlogCard(item); - block.append(card); + card.appendChild(createBlogCard(item)); + block.appendChild(card); }); + + if (items.length === 0) { + block.closest('.cards-wrapper').prepend(document.createRange().createContextualFragment(` +

Sorry, there are no results that match your search

+

Please check your spelling or try again using different keywords

+ `)); + } else { + createFilterSelect(block, total, tags.length > 0 ? tags[0] : null); + } + + createSearchBox(block, searchTerm); + createPagination(block, pages, currentPage); } export default async function decorate(block) { @@ -53,6 +258,13 @@ export default async function decorate(block) { await populateBlogTeaser(block); } + const isBlogGrid = block.classList.contains('blog-grid'); + if (isBlogGrid) { + block.textContent = ''; + block.closest('.cards-wrapper').querySelectorAll(':scope > *:not(.block)').forEach((e) => e.remove()); + await populateBlogGrid(block); + } + /* change to ul, li */ const ul = document.createElement('ul'); [...block.children].forEach((row) => { @@ -60,7 +272,6 @@ export default async function decorate(block) { li.innerHTML = row.innerHTML; [...li.children].forEach((div) => { const href = li.querySelector('a')?.href; - if (div.children.length === 1 && div.querySelector('picture')) { div.className = 'cards-card-image'; wrapInAnchor(div, href); @@ -74,7 +285,6 @@ export default async function decorate(block) { }); [...ul.querySelectorAll('img')] - .filter((img) => !(img.src || '').startsWith('http')) // do not optimize absolute images for now .forEach((img) => img.closest('picture').replaceWith(createOptimizedPicture(img.src, img.alt, false, [{ width: '750' }]))); block.textContent = ''; block.append(ul); diff --git a/blocks/columns/columns.css b/blocks/columns/columns.css index e4bb6a05..c4ce5c46 100644 --- a/blocks/columns/columns.css +++ b/blocks/columns/columns.css @@ -32,6 +32,63 @@ text-align: center; } +.columns.teaser { + background-color: #fff1d6; + border-radius: 0.625rem; + overflow: hidden; +} + +.columns.teaser > div, .columns.gray > div { + padding: 0; +} + +.columns.teaser p a.button.primary { + width: 100%; +} + +.columns.teaser > div > .columns-img-col { + order: 1; +} + +.columns.teaser > div > div { + font-size: 1rem; + line-height: 1.5625rem; +} + +.columns.teaser > div > div:last-child, .columns.gray > div > div:first-child { + margin-bottom: 1.25rem; + padding-left: 0.9375rem; + padding-right: 0.9375rem; +} + +.columns.teaser p.button-container, .columns.gray p.button-container { + margin: 0; +} + +.columns.teaser p.button-container a.buttonm, .columns.gray p.button-container a.button { + margin: 0.3125rem auto; +} + +.columns.gray p.button-container a.button { + display: inline-block; + border: 0.1875rem solid var(--text-color); + background: var(--background-color); + color: var(--text-color); +} + +.columns.gray p.button-container a.button:hover { + background: #d0dfe8; +} + +.columns.teaser > div > div h3 { + font-size: 1.3125rem; + line-height: 1.938rem; +} + +.columns.gray { + background-color: #e7e9ea; +} + @media (min-width: 768px) { .columns > div { align-items: center; @@ -44,7 +101,6 @@ .columns > div > div { flex: 1; order: unset; - } .columns > div > .columns-img-col { @@ -54,4 +110,12 @@ .columns h2 { text-align: left; } + + .columns.teaser > div > div:last-child, .columns.gray > div > div:first-child { + padding: 3.125rem; + } + + .columns.teaser > div > .columns-img-col { + order: unset; + } } diff --git a/blocks/fragment/fragment.css b/blocks/fragment/fragment.css new file mode 100644 index 00000000..46bab39e --- /dev/null +++ b/blocks/fragment/fragment.css @@ -0,0 +1,14 @@ +/* suppress nested section padding */ + +.fragment-wrapper > .section { + padding-left: 0; + padding-right: 0; +} + +.fragment-wrapper > .section:first-of-type { + padding-top: 0; +} + +.fragment-wrapper > .section:last-of-type { + padding-bottom: 0; +} diff --git a/blocks/fragment/fragment.js b/blocks/fragment/fragment.js new file mode 100644 index 00000000..b03b8f0d --- /dev/null +++ b/blocks/fragment/fragment.js @@ -0,0 +1,55 @@ +/* + * Fragment Block + * Include content on a page as a fragment. + * https://www.aem.live/developer/block-collection/fragment + */ + +import { + decorateMain, +} from '../../scripts/scripts.js'; + +import { + loadBlocks, +} from '../../scripts/lib-franklin.js'; + +/** + * Loads a fragment. + * @param {string} path The path to the fragment + * @returns {HTMLElement} The root element of the fragment + */ +export async function loadFragment(path) { + if (path && path.startsWith('/')) { + const resp = await fetch(`${path}.plain.html`); + if (resp.ok) { + const main = document.createElement('main'); + main.innerHTML = await resp.text(); + + // reset base path for media to fragment base + const resetAttributeBase = (tag, attr) => { + main.querySelectorAll(`${tag}[${attr}^="./media_"]`).forEach((elem) => { + elem[attr] = new URL(elem.getAttribute(attr), new URL(path, window.location)).href; + }); + }; + resetAttributeBase('img', 'src'); + resetAttributeBase('source', 'srcset'); + + decorateMain(main); + await loadBlocks(main); + return main; + } + } + return null; +} + +export default async function decorate(block) { + const link = block.querySelector('a'); + const path = link ? link.getAttribute('href') : block.textContent.trim(); + const fragment = await loadFragment(path); + if (fragment) { + const fragmentSection = fragment.querySelector(':scope .section'); + if (fragmentSection) { + block.closest('.section').classList.add(...fragmentSection.classList); + block.closest('.fragment-wrapper').replaceWith(...fragmentSection.childNodes); + } + } +} diff --git a/blocks/sharing/sharing.css b/blocks/sharing/sharing.css new file mode 100644 index 00000000..4b0dd9cb --- /dev/null +++ b/blocks/sharing/sharing.css @@ -0,0 +1,3 @@ +.sharing { + min-height: 48px; +} \ No newline at end of file diff --git a/blocks/sharing/sharing.js b/blocks/sharing/sharing.js new file mode 100644 index 00000000..d6ced3b3 --- /dev/null +++ b/blocks/sharing/sharing.js @@ -0,0 +1,3 @@ +export default async function decorate(block) { + block.append(document.createRange().createContextualFragment('
')); +} diff --git a/icons/arrow-down.png b/icons/arrow-down.png new file mode 100644 index 00000000..c99f1f37 Binary files /dev/null and b/icons/arrow-down.png differ diff --git a/scripts/scripts.js b/scripts/scripts.js index 6c2789c3..497abc34 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -44,13 +44,46 @@ function buildHeroBlock(main) { } } +function buildBlockPostPage(main) { + // Below h1 + const h1 = main.querySelector('h1'); + const socialMediaButtons = document.createRange().createContextualFragment('
'); + + if (h1) { + const author = h1.parentElement.querySelector('h1 + p > em'); + if (author) { + const authorElem = document.createRange().createContextualFragment(`

${author.innerText}

`); + author.parentElement.replaceWith(authorElem); + } + + h1.parentElement.insertBefore(socialMediaButtons.cloneNode(true), h1.nextSibling); + } + + // Below last content + const lastContentSection = main.querySelector('* > div:last-of-type'); + if (lastContentSection) { + lastContentSection.appendChild(socialMediaButtons.cloneNode(true)); + + const fragment = document.createRange().createContextualFragment('
/fragments/blog-footer
'); + lastContentSection.parentElement.appendChild(fragment); + } +} + /** * Builds all synthetic blocks in a container element. * @param {Element} main The container element */ function buildAutoBlocks(main) { try { - buildHeroBlock(main); + if (main.parentNode !== document.body) { + return; + } + + if (!document.body.classList.contains('blog-post')) { + buildHeroBlock(main); + } else { + buildBlockPostPage(main); + } } catch (error) { // eslint-disable-next-line no-console console.error('Auto Blocking failed', error); diff --git a/styles/styles.css b/styles/styles.css index ca7fb789..4870c915 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -235,7 +235,13 @@ a:hover { color: var(--link-hover-color); } -a[href*="//"]:not([href^="https://www.24petwatch.com"]):not([href^="https://24petwatch.com"]):not([href*="tel:"]):not([href^=localhost]):after { +a[href*="//"] + :not([href^="https://www.24petwatch.com"]) + :not([href^="https://24petwatch.com"]) + :not([href*="tel:"]) + :not([href^=localhost]) + :not(.cards a) + :after { font-family: 'Font Awesome 6 Pro', sans-serif; content: ""; margin:.3rem; @@ -380,6 +386,39 @@ main .section.highlight p { margin-bottom: 0; } +/* Blog post styles */ +body.blog-post main .section { + padding: 0.9375rem; +} + +body.blog-post main .section > div { + margin: 1.25rem auto; + font-size: 1.125rem; + padding: 0 0.9375rem; +} + +body.blog-post main h1, body.blog-post main h2, body.blog-post main h3, body.blog-post main h4, +body.blog-index main h1, body.blog-index main h2, body.blog-index main h3, body.blog-index main h4 { + text-align: left; +} + +body.blog-post main h1 { + font-size: 2.1875rem; + line-height: 2.813rem; + font-weight: 300; +} + +body.blog-post main h2 { + font-size: 1.625rem; + line-height: 2.25rem; +} + +body.blog-post .default-content-wrapper p.author { + font-family: var(--heading-font-family); + font-size: 1.3125rem; + font-weight: 300; +} + main .section.highlight p em { display: block; font-size: 2.5rem; @@ -394,6 +433,10 @@ main .section.heading-light h2 { } @media (min-width: 768px) { + body.blog-post main .section { + padding: 1.875rem; + } + main .section.highlight p { font-size: 1.625rem; line-height: 2.25rem;