diff --git a/libs/blocks/catalog-marquee/catalog-marquee.css b/libs/blocks/catalog-marquee/catalog-marquee.css new file mode 100644 index 0000000000..8fedc76db5 --- /dev/null +++ b/libs/blocks/catalog-marquee/catalog-marquee.css @@ -0,0 +1,158 @@ +.catalog-marquee { + position: relative; + display: flex; + min-height: 360px; + color: var(--text-color); +} + +.catalog-marquee .foreground { + position: relative; + display: flex; + flex-direction: column; + gap: var(--spacing-m); + padding: var(--spacing-xl) 0; +} + +.catalog-marquee .mnemonic-list { + margin: 0 0 var(--spacing-s); +} + +.catalog-marquee .mnemonic-list .product-list { + justify-content: flex-start; + text-align: start; +} + +.catalog-marquee .action-area { + display: flex; + margin: 0; + margin-top: var(--spacing-s); + gap: var(--spacing-s); + flex-wrap: wrap; + align-items: center; +} + +.catalog-marquee .background img { + object-fit: cover; + height: 100%; + width: 100%; +} + +.catalog-marquee .background .tablet-only, +.catalog-marquee .background .desktop-only { + display: none; +} + +.catalog-marquee .background picture { + display: block; + position: absolute; + inset: 0; + line-height: 0; +} + +.catalog-marquee .background .expand-background { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; +} + +.catalog-marquee .text p { + margin: 0 0 var(--spacing-s); +} + +.catalog-marquee .text p:last-of-type { + margin-bottom: 0; +} + +.catalog-marquee .text .detail-m { + margin-bottom: var(--spacing-xs); +} + +.catalog-marquee .text .heading-l { + margin-bottom: var(--spacing-xs); +} + +@media screen and (min-width: 600px) { + .catalog-marquee { + text-align: center; + } + + .catalog-marquee .mnemonic-list .product-list { + justify-content: center; + text-align: center; + } + + .catalog-marquee .foreground, + .catalog-marquee .action-area { + justify-content: center; + } + + .catalog-marquee .action-area { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-s); + } + + .catalog-marquee .background .mobile-only, + .catalog-marquee .background .desktop-only { + display: none; + } + + .catalog-marquee .background .tablet-only { + display: block; + } + + .catalog-marquee .foreground .text { + margin: 0 auto; + } +} + +@media screen and (min-width: 1200px) { + .catalog-marquee { + min-height: 360px; + } + + .catalog-marquee .text { + padding: var(--spacing-l) 0; + } + + .catalog-marquee .background .mobile-only, + .catalog-marquee .background .tablet-only { + display: none; + } + + .catalog-marquee .background .desktop-only { + display: block; + } + + .catalog-marquee .foreground { + flex-direction: row; + align-items: center; + padding: 0; + gap: 100px; /* 1 column */ + } + + html[dir="rtl"] .catalog-marquee .foreground { + flex-direction: row-reverse; + } + + .catalog-marquee .foreground .text { + max-width: var(--grid-container-width); + } +} + +/* stylelint-disable no-descending-specificity */ +.catalog-marquee.static-links a:not(.con-button), +.catalog-marquee.static-links a:not(.con-button):hover, +.static-links .catalog-marquee a:not(.con-button), +.static-links .catalog-marquee a:not(.con-button):hover { + color: inherit; +} + +/* hide download/upgrade links except the last one */ +.catalog-marquee a[is="checkout-link"].download:not(:last-of-type), +.catalog-marquee a[is="checkout-link"].upgrade:not(:last-of-type) { + display: none; +} diff --git a/libs/blocks/catalog-marquee/catalog-marquee.js b/libs/blocks/catalog-marquee/catalog-marquee.js new file mode 100644 index 0000000000..3d2f31948e --- /dev/null +++ b/libs/blocks/catalog-marquee/catalog-marquee.js @@ -0,0 +1,72 @@ +import { decorateButtons, decorateBlockBg, loadCDT } from '../../utils/decorate.js'; +import { createTag, getConfig, loadStyle } from '../../utils/utils.js'; + +export function decorateText(el) { + const headings = el.querySelectorAll('h1, h2, h3, h4, h5, h6'); + const heading = headings[headings.length - 1]; + heading.classList.add('heading-l'); + heading.nextElementSibling?.classList.add('body-m'); + heading.previousElementSibling?.classList.add('detail-m'); +} + +export function extendButtonsClass(text) { + text.querySelectorAll('.con-button').forEach((button) => { + button.classList.add('button-justified-mobile'); + }); +} + +export function decorateFeatures(paragraphs, parentEl, lastChild) { + if (!paragraphs?.length || !parentEl || !lastChild) return; + const mnemonicList = createTag('div', { class: 'mnemonic-list' }); + const productList = createTag('div', { class: 'product-list' }); + [...paragraphs].forEach((paragraph) => { + const title = paragraph.querySelector('strong'); + const picture = paragraph.querySelector('picture'); + const product = createTag('div', { class: 'product-item' }); + if (picture) product.appendChild(picture); + if (title) product.appendChild(title); + productList.appendChild(product); + paragraph.replaceWith(productList); + }); + mnemonicList.appendChild(productList); + parentEl.insertBefore(mnemonicList, lastChild); +} + +export function appendFeatures(el, foreground, text, promiseArr) { + if (!el || !foreground || !text || !promiseArr) return; + const paragraphs = Array.from(foreground.querySelectorAll(':scope p:not([class])')); + const actionArea = text.querySelector('.action-area'); + const headingsIndexes = paragraphs.flatMap((elem, i) => (elem.querySelector('strong') && !elem.querySelector('picture') ? i : [])); + if (!headingsIndexes.length) return; + if (headingsIndexes.length === 1) { + decorateFeatures(paragraphs, text, actionArea); + } else { + const mnemonics = paragraphs.splice(...headingsIndexes); + const businessFeatures = paragraphs; + decorateFeatures(mnemonics, text, actionArea); + decorateFeatures(businessFeatures, text, actionArea); + } + promiseArr.push(loadStyle(`${getConfig().base}/blocks/mnemonic-list/mnemonic-list.css`)); +} + +export default async function init(el) { + const children = el.querySelectorAll(':scope > div'); + const foreground = children[children.length - 1]; + if (children.length > 1) { + children[0].classList.add('background'); + decorateBlockBg(el, children[0], { useHandleFocalpoint: true }); + } + foreground.classList.add('foreground', 'container'); + const headline = foreground.querySelector('h1, h2, h3, h4, h5, h6'); + const text = headline.closest('div'); + text.classList.add('text'); + decorateText(text); + decorateButtons(text, 'button-l'); + extendButtonsClass(text); + const promiseArr = []; + appendFeatures(el, foreground, text, promiseArr); + if (el.classList.contains('countdown-timer')) { + promiseArr.push(loadCDT(text, el.classList)); + } + await Promise.all(promiseArr); +} diff --git a/libs/utils/utils.js b/libs/utils/utils.js index c29960c7de..b58a439c24 100644 --- a/libs/utils/utils.js +++ b/libs/utils/utils.js @@ -24,6 +24,7 @@ const MILO_BLOCKS = [ 'card-horizontal', 'card-metadata', 'carousel', + 'catalog-marquee', 'chart', 'columns', 'editorial-card', diff --git a/nala/blocks/catalog-marquee/catalog-marquee.page.js b/nala/blocks/catalog-marquee/catalog-marquee.page.js new file mode 100644 index 0000000000..e3fa094a48 --- /dev/null +++ b/nala/blocks/catalog-marquee/catalog-marquee.page.js @@ -0,0 +1,69 @@ +export default class CatalogMarquee { + constructor(page) { + this.page = page; + this.catalogMarquee = page.locator('.catalog-marquee'); + + // Background + this.background = this.catalogMarquee.locator('.background'); + this.backgroundImage = this.background.locator('img').nth(2); + + // Foreground + this.foreground = this.catalogMarquee.locator('.foreground.container'); + this.text = this.foreground.locator('.text'); + this.textH2 = this.text.locator('h2'); + this.bodyM = this.text.locator('.body-m'); + + // Mnemonic list + this.mnemonicsList = this.text.locator('.mnemonic-list').nth(0); + this.mnemonics = this.mnemonicsList.locator('.product-list'); + this.mnemonicItems = this.mnemonics.locator('.product-item'); + this.mnemonicsHeading = this.mnemonics.locator('.product-item strong').nth(0); + + this.acrobatText = this.mnemonics.locator('.product-item strong').nth(1); + this.photoshopText = this.mnemonics.locator('.product-item strong').nth(2); + this.premiereProText = this.mnemonics.locator('.product-item strong').nth(3); + this.illustratorText = this.mnemonics.locator('.product-item strong').nth(4); + this.expressText = this.mnemonics.locator('.product-item strong').nth(5); + + this.acrobatImg = this.mnemonics.locator('.product-item img').nth(0); + this.photoshopImg = this.mnemonics.locator('.product-item img').nth(1); + this.premiereProImg = this.mnemonics.locator('.product-item img').nth(2); + this.illustratorImg = this.mnemonics.locator('.product-item img').nth(3); + this.expressImg = this.mnemonics.locator('.product-item img').nth(4); + + // Business features + this.businessFeaturesList = this.text.locator('.mnemonic-list').nth(1); + this.businessFeatures = this.businessFeaturesList.locator('.product-list'); + this.businessItems = this.businessFeatures.locator('.product-item'); + this.businessFeaturessHeading = this.businessFeatures.locator('.product-item strong').nth(0); + + this.dashboardText = this.businessFeatures.locator('.product-item strong').nth(1); + this.feedbackText = this.businessFeatures.locator('.product-item strong').nth(2); + this.filesText = this.businessFeatures.locator('.product-item strong').nth(3); + this.assetsText = this.businessFeatures.locator('.product-item strong').nth(4); + + this.dashboardImg = this.businessFeatures.locator('.product-item img').nth(0); + this.feedbackImg = this.businessFeatures.locator('.product-item img').nth(1); + this.filesImg = this.businessFeatures.locator('.product-item img').nth(2); + this.assetsImg = this.businessFeatures.locator('.product-item img').nth(3); + + // Action area + this.actionArea = this.text.locator('.action-area'); + this.outlineButtonL = this.actionArea.locator('.con-button.outline.button-l'); + this.blueButtonL = this.actionArea.locator('.con-button.blue.button-l'); + + this.attributes = { + backgroundImg: { + loading: 'lazy', + width: '750', + height: '141', + }, + acrobatImg: { alt: 'Acrobat Pro Product Icon' }, + photoshopImg: { alt: 'Photoshop Product Icon' }, + premiereProImg: { alt: 'Premiere Pro Product Iconn' }, + illustratorImg: { alt: 'Illustrator Product Icon' }, + expressImg: { alt: 'Express Product Icon' }, + checkmarkImg: { alt: 'Checkmark icon' }, + }; + } +} diff --git a/nala/blocks/catalog-marquee/catalog-marquee.spec.js b/nala/blocks/catalog-marquee/catalog-marquee.spec.js new file mode 100644 index 0000000000..cfa70a2be1 --- /dev/null +++ b/nala/blocks/catalog-marquee/catalog-marquee.spec.js @@ -0,0 +1,34 @@ +module.exports = { + name: 'Catalog Marquee Block', + features: [ + { + tcid: '0', + name: 'Catalog Marquee', + path: '/drafts/nala/blocks/catalog-marquee/catalog-marquee', + data: { + h2Text: 'Get 20+ creative cloud for less than the price of 3 apps.', + bodyText: 'All Apps plan includes 20+ apps and services plus 20,000 fonts, storage, templates, and tutorials for less than the price of acrobat, photoshop, and premiere pro sold separately.', + mnemonics: { + count: 6, + heading: 'Includes:', + acrobat: 'Acrobat', + photoshop: 'Photoshop', + premierePro: 'Premiere pro', + illustrator: 'Illustrator', + express: 'Express', + }, + businessFeatures: { + count: 5, + heading: 'Business features:', + dashboard: 'Easy-to-use admin dashboard', + feedback: 'Instant feedback', + files: 'Shared creative files', + assets: 'Protective creative assets', + }, + outlineButtonText: 'Free trial', + blueButtonText: 'Buy now', + }, + tags: '@catalog-marquee @smoke @regression @milo', + }, + ], +}; diff --git a/nala/blocks/catalog-marquee/catalog-marquee.test.js b/nala/blocks/catalog-marquee/catalog-marquee.test.js new file mode 100644 index 0000000000..a6c6399ba5 --- /dev/null +++ b/nala/blocks/catalog-marquee/catalog-marquee.test.js @@ -0,0 +1,75 @@ +import { expect, test } from '@playwright/test'; +import WebUtil from '../../libs/webutil.js'; +import { features } from './catalog-marquee.spec.js'; +import CatalogMarqueeBlock from './catalog-marquee.page.js'; +import { runAccessibilityTest } from '../../libs/accessibility.js'; + +let webUtil; +let catalogMarquee; + +const miloLibs = process.env.MILO_LIBS || ''; + +test.describe('Milo catalog marquee block test suite', () => { + test.beforeEach(async ({ page }) => { + webUtil = new WebUtil(page); + catalogMarquee = new CatalogMarqueeBlock(page); + }); + + test(`[Test Id - ${features[0].tcid}] ${features[0].name},${features[0].tags}`, async ({ page, baseURL }) => { + console.info(`[Test Page]: ${baseURL}${features[0].path}${miloLibs}`); + const { data } = features[0]; + + await test.step('step-1: Go to catalog marquee block test page', async () => { + await page.goto(`${baseURL}${features[0].path}${miloLibs}`); + await page.waitForLoadState('domcontentloaded'); + await expect(page).toHaveURL(`${baseURL}${features[0].path}${miloLibs}`); + }); + + await test.step('step-2: Verify catalog marquee specs', async () => { + await expect(await catalogMarquee.catalogMarquee).toBeVisible(); + await expect(await catalogMarquee.foreground).toBeVisible(); + await expect(await catalogMarquee.textH2).toContainText(data.h2Text); + await expect(await catalogMarquee.bodyM).toContainText(data.bodyText); + + await expect(await catalogMarquee.mnemonicsHeading).toContainText(data.mnemonics.heading); + await expect(await catalogMarquee.mnemonicItems).toHaveCount(data.mnemonics.count); + await expect(await catalogMarquee.acrobatText).toContainText(data.mnemonics.acrobat); + await expect(await catalogMarquee.photoshopText).toContainText(data.mnemonics.photoshop); + await expect(await catalogMarquee.premiereProText).toContainText(data.mnemonics.premierePro); + await expect(await catalogMarquee.illustratorText).toContainText(data.mnemonics.illustrator); + await expect(await catalogMarquee.expressText).toContainText(data.mnemonics.express); + + await expect(await catalogMarquee.acrobatImg).toBeVisible(); + await expect(await catalogMarquee.photoshopImg).toBeVisible(); + await expect(await catalogMarquee.premiereProImg).toBeVisible(); + await expect(await catalogMarquee.illustratorImg).toBeVisible(); + await expect(await catalogMarquee.expressImg).toBeVisible(); + + await expect(await catalogMarquee.businessFeaturessHeading).toContainText(data.businessFeatures.heading); + await expect(await catalogMarquee.businessItems).toHaveCount(data.businessFeatures.count); + await expect(await catalogMarquee.dashboardText).toContainText(data.businessFeatures.dashboard); + await expect(await catalogMarquee.feedbackText).toContainText(data.businessFeatures.feedback); + await expect(await catalogMarquee.filesText).toContainText(data.businessFeatures.files); + await expect(await catalogMarquee.assetsText).toContainText(data.businessFeatures.assets); + + await expect(await catalogMarquee.dashboardImg).toBeVisible(); + await expect(await catalogMarquee.feedbackImg).toBeVisible(); + await expect(await catalogMarquee.filesImg).toBeVisible(); + await expect(await catalogMarquee.assetsImg).toBeVisible(); + + await expect(await catalogMarquee.outlineButtonL).toContainText(data.outlineButtonText); + await expect(await catalogMarquee.blueButtonL).toContainText(data.blueButtonText); + expect(await webUtil.verifyAttributes(catalogMarquee.backgroundImage, catalogMarquee.attributes.backgroundImg)).toBeTruthy(); + }); + + await test.step('step-3: Verify analytics attributes', async () => { + await expect(await catalogMarquee.catalogMarquee).toHaveAttribute('daa-lh', await webUtil.getBlockDaalh('catalog-marquee', 1)); + await expect(await catalogMarquee.outlineButtonL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.outlineButtonText, 1, data.h2Text)); + await expect(await catalogMarquee.blueButtonL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 2, data.h2Text)); + }); + + await test.step('step-4: Verify the accessibility test on the marquee block', async () => { + await runAccessibilityTest({ page, testScope: catalogMarquee.catalogMarquee }); + }); + }); +}); diff --git a/test/blocks/catalog-marquee/catalog-marquee.test.js b/test/blocks/catalog-marquee/catalog-marquee.test.js new file mode 100644 index 0000000000..3f8ae913c8 --- /dev/null +++ b/test/blocks/catalog-marquee/catalog-marquee.test.js @@ -0,0 +1,99 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import { stub } from 'sinon'; +import { loadStyle, setConfig } from '../../../libs/utils/utils.js'; + +document.body.innerHTML = await readFile({ path: './mocks/body.html' }); +const { default: init, decorateText, extendButtonsClass, decorateFeatures, appendFeatures } = await import('../../../libs/blocks/catalog-marquee/catalog-marquee.js'); +const catalogMarquee = document.querySelector('#catalog-marquee'); +const catalogMarqueeMnemBusiness = document.querySelector('#mnemonics-and-features'); +const catalogMarqueeMnemonics = document.querySelector('#mnemonics'); +const catalogMarqueeText = document.querySelector('#text'); +const conf = { base: 'http://localhost:2000/' }; +setConfig(conf); +window.lana = { log: stub() }; + +describe('catalog-marquee', () => { + before(async () => { + await new Promise((resolve) => { + loadStyle('../../../../libs/styles/styles.css', resolve); + }); + await new Promise((resolve) => { + loadStyle('../../../../libs/blocks/marquee/marquee.css', resolve); + }); + }); + + it('sets a proper heading class', () => { + const headings = catalogMarqueeText.querySelectorAll('h1, h2, h3, h4, h5, h6'); + const heading = headings[headings.length - 1]; + decorateText(catalogMarqueeText); + expect(heading.classList.contains('heading-l')).to.be.true; + }); + + it('sets a proper button class', () => { + const buttons = catalogMarqueeText.querySelectorAll('.con-button'); + extendButtonsClass(catalogMarqueeText); + buttons.forEach((btn) => { + expect(btn.classList.contains('button-justified-mobile')).to.have.true; + }); + }); + + it('decorates features', async () => { + const children = catalogMarqueeMnemonics.querySelectorAll(':scope > div'); + const foreground = children[children.length - 1]; + const headline = foreground.querySelector('h1, h2, h3, h4, h5, h6'); + const text = headline.closest('div'); + const paragraphs = Array.from(foreground.querySelectorAll(':scope p:not([class])')); + const headingsIndexes = paragraphs.flatMap((elem, i) => (elem.querySelector('strong') && !elem.querySelector('picture') ? i : [])); + const mnemonics = paragraphs.splice(...headingsIndexes); + const actionArea = text.querySelector('.action-area'); + decorateFeatures(mnemonics, text, actionArea); + const mnemonicLists = text.querySelectorAll('.mnemonic-list'); + expect(mnemonicLists.length === 1).to.be.true; + const mnemonicList = mnemonicLists[0]; + expect(mnemonicList).to.exist; + const productList = mnemonicList.querySelector('.product-list'); + expect(productList).to.exist; + const [heading, ...productItems] = productList.querySelectorAll('.product-item'); + expect(heading.querySelector('strong')).to.exist; + expect(heading.querySelector('picture')).to.not.exist; + productItems.forEach((item) => { + expect(item.querySelector('strong')).to.exist; + expect(item.querySelector('picture')).to.exist; + }); + }); + + it('appends features', async () => { + const children = catalogMarqueeMnemBusiness.querySelectorAll(':scope > div'); + const foreground = children[children.length - 1]; + const headline = foreground.querySelector('h1, h2, h3, h4, h5, h6'); + const text = headline.closest('div'); + const promiseArr = []; + appendFeatures(catalogMarqueeMnemBusiness, foreground, text, promiseArr); + expect(promiseArr.length === 1).to.be.true; + const lists = catalogMarqueeMnemBusiness.querySelectorAll('.mnemonic-list'); + expect(lists.length === 2).to.be.true; + lists.forEach((list) => { + const productList = list.querySelector('.product-list'); + expect(productList).to.exist; + const [heading, ...productItems] = productList.querySelectorAll('.product-item'); + expect(heading.querySelector('strong')).to.exist; + expect(heading.querySelector('picture')).to.not.exist; + productItems.forEach((item) => { + expect(item.querySelector('strong')).to.exist; + expect(item.querySelector('picture')).to.exist; + }); + }); + }); + + it('sets `foreground`, `background` and `text` classes', () => { + init(catalogMarquee); + const children = catalogMarquee.querySelectorAll(':scope > div'); + expect(children[0].classList.contains('background')); + const foreground = children[children.length - 1]; + expect(foreground.classList.contains('foreground', 'container')).be.true; + const headline = foreground.querySelector('h1, h2, h3, h4, h5, h6'); + const text = headline.closest('div'); + expect(text.classList.contains('text')).to.be.true; + }); +}); diff --git a/test/blocks/catalog-marquee/mocks/body.html b/test/blocks/catalog-marquee/mocks/body.html new file mode 100644 index 0000000000..0bfdcc81e7 --- /dev/null +++ b/test/blocks/catalog-marquee/mocks/body.html @@ -0,0 +1,241 @@ +
+
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+
+
+

Get 20+ creative cloud for less than the price of 3 apps.

+

The All Apps plan includes 20+ apps and services plus 20,000 fonts, storage, templates, and tutorials for less than the price of acrobat, photoshop, and premiere pro sold separately.

+

Includes:

+

+ Acrobat Pro Product Icon + acrobat +

+

+ Photoshop Product Icon + photoshop +

+

+ Premiere Pro Product Icon + premiere pro +

+

+ Illustrator Product Icon + illustrator +

+

+ Express Product Icon + express +

+

Business features:

+

+ Checkmark icon + Easy-to-use admin dashboard +

+

+ Checkmark icon + Instant feedback +

+

+ Checkmark icon + Shared creative files +

+

+ Checkmark icon + Protective creative assets +

+

Free trial Buy now

+
+
+
+ + +
+
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+
+
+

Get 20+ creative cloud for less than the price of 3 apps.

+

The All Apps plan includes 20+ apps and services plus 20,000 fonts, storage, templates, and tutorials for less than the price of acrobat, photoshop, and premiere pro sold separately.

+

Includes:

+

+ Acrobat Pro Product Icon + acrobat +

+

+ Photoshop Product Icon + photoshop +

+

+ Premiere Pro Product Icon + premiere pro +

+

+ Illustrator Product Icon + illustrator +

+

+ Express Product Icon + express +

+

Business features:

+

+ Checkmark icon + Easy-to-use admin dashboard +

+

+ Checkmark icon + Instant feedback +

+

+ Checkmark icon + Shared creative files +

+

+ Checkmark icon + Protective creative assets +

+

Free trial Buy now

+
+
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+
+
+

Get 20+ creative cloud for less than the price of 3 apps.

+

The All Apps plan includes 20+ apps and services plus 20,000 fonts, storage, templates, and tutorials for less than the price of acrobat, photoshop, and premiere pro sold separately.

+

Includes:

+

+ Acrobat Pro Product Icon + acrobat +

+

+ Photoshop Product Icon + photoshop +

+

+ Premiere Pro Product Icon + premiere pro +

+

+ Illustrator Product Icon + illustrator +

+

+ Express Product Icon + express +

+

Free trial Buy now

+
+
+
+ +
+
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+
+
+

Get 20+ creative cloud for less than the price of 3 apps.

+

The All Apps plan includes 20+ apps and services plus 20,000 fonts, storage, templates, and tutorials for less than the price of acrobat, photoshop, and premiere pro sold separately.

+

Free trial Buy now

+
+
+