From 8a5fbcb9eb2b1706631f179374db04a54166a43a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 15 Jul 2026 01:05:01 +0000 Subject: [PATCH 1/2] feat(totalwireless): migrate /m/home page, design system, header & footer Migrate the Total Wireless mobile home page to Edge Delivery Services: - Add 9 block variants (hero-promo, columns-promo, columns-benefits, cards-options, cards-plans, cards-bento, product-grid-picks, teaser-promo, faq-home) with JS, CSS, and xwalk models - Add import infrastructure: page-templates, 9 parsers, 3 transformers (cleanup, sections, DM/Scene7 images) and import-home.js - Wire DM/Scene7 dynamic-media handling: client-side auto-block in scripts.js + createOptimizedPicture dispatcher in aem.js so Scene7 template params survive rendering - Add brand.css design tokens (navy/cream palette, Montserrat fallback for GalanoGrotesque) and re-assert them in styles.css - Instrument Total Wireless header (nav.plain.html) and footer with brand styling Co-Authored-By: Claude Opus 4.7 --- .eslintrc.js | 1 + .gitignore | 1 + .../totalwireless-home-page-migration.md | 36 +++ .migration/project.json | 10 + blocks/cards-bento/_cards-bento.json | 76 ++++++ blocks/cards-bento/cards-bento.css | 234 ++++++++++++++++++ blocks/cards-bento/cards-bento.js | 54 ++++ blocks/cards-bento/metadata.json | 37 +++ blocks/cards-options/_cards-options.json | 76 ++++++ blocks/cards-options/cards-options.css | 112 +++++++++ blocks/cards-options/cards-options.js | 74 ++++++ blocks/cards-options/metadata.json | 38 +++ blocks/cards-plans/_cards-plans.json | 74 ++++++ blocks/cards-plans/cards-plans.css | 148 +++++++++++ blocks/cards-plans/cards-plans.js | 104 ++++++++ blocks/cards-plans/metadata.json | 38 +++ .../columns-benefits/_columns-benefits.json | 53 ++++ blocks/columns-benefits/columns-benefits.css | 63 +++++ blocks/columns-benefits/columns-benefits.js | 17 ++ blocks/columns-benefits/metadata.json | 37 +++ blocks/columns-promo/_columns-promo.json | 54 ++++ blocks/columns-promo/columns-promo.css | 138 +++++++++++ blocks/columns-promo/columns-promo.js | 26 ++ blocks/columns-promo/metadata.json | 37 +++ blocks/faq-home/_faq-home.json | 68 +++++ blocks/faq-home/faq-home.css | 89 +++++++ blocks/faq-home/faq-home.js | 61 +++++ blocks/faq-home/metadata.json | 36 +++ blocks/footer/footer.css | 68 ++++- blocks/header/header.css | 34 +++ blocks/hero-promo/_hero-promo.json | 48 ++++ blocks/hero-promo/hero-promo.css | 202 +++++++++++++++ blocks/hero-promo/hero-promo.js | 26 ++ blocks/hero-promo/metadata.json | 38 +++ .../_product-grid-picks.json | 34 +++ blocks/product-grid-picks/metadata.json | 37 +++ .../product-grid-picks/product-grid-picks.css | 144 +++++++++++ .../product-grid-picks/product-grid-picks.js | 92 +++++++ blocks/teaser-promo/_teaser-promo.json | 48 ++++ blocks/teaser-promo/metadata.json | 37 +++ blocks/teaser-promo/teaser-promo.css | 154 ++++++++++++ blocks/teaser-promo/teaser-promo.js | 30 +++ scripts/aem.js | 7 + scripts/scripts.js | 220 ++++++++++++++++ styles/brand.css | 127 ++++++++++ styles/styles.css | 23 ++ tools/importer/import-home.js | 220 ++++++++++++++++ tools/importer/page-templates.json | 228 +++++++++++++++++ tools/importer/parsers/cards-bento.js | 108 ++++++++ tools/importer/parsers/cards-options.js | 110 ++++++++ tools/importer/parsers/cards-plans.js | 99 ++++++++ tools/importer/parsers/columns-benefits.js | 68 +++++ tools/importer/parsers/columns-promo.js | 96 +++++++ tools/importer/parsers/faq-home.js | 87 +++++++ tools/importer/parsers/hero-promo.js | 100 ++++++++ tools/importer/parsers/product-grid-picks.js | 73 ++++++ tools/importer/parsers/teaser-promo.js | 91 +++++++ .../transformers/totalwireless-cleanup.js | 105 ++++++++ .../transformers/totalwireless-dm-images.js | 117 +++++++++ .../transformers/totalwireless-sections.js | 56 +++++ 60 files changed, 4712 insertions(+), 7 deletions(-) create mode 100644 .migration/plans/totalwireless-home-page-migration.md create mode 100644 .migration/project.json create mode 100644 blocks/cards-bento/_cards-bento.json create mode 100644 blocks/cards-bento/cards-bento.css create mode 100644 blocks/cards-bento/cards-bento.js create mode 100644 blocks/cards-bento/metadata.json create mode 100644 blocks/cards-options/_cards-options.json create mode 100644 blocks/cards-options/cards-options.css create mode 100644 blocks/cards-options/cards-options.js create mode 100644 blocks/cards-options/metadata.json create mode 100644 blocks/cards-plans/_cards-plans.json create mode 100644 blocks/cards-plans/cards-plans.css create mode 100644 blocks/cards-plans/cards-plans.js create mode 100644 blocks/cards-plans/metadata.json create mode 100644 blocks/columns-benefits/_columns-benefits.json create mode 100644 blocks/columns-benefits/columns-benefits.css create mode 100644 blocks/columns-benefits/columns-benefits.js create mode 100644 blocks/columns-benefits/metadata.json create mode 100644 blocks/columns-promo/_columns-promo.json create mode 100644 blocks/columns-promo/columns-promo.css create mode 100644 blocks/columns-promo/columns-promo.js create mode 100644 blocks/columns-promo/metadata.json create mode 100644 blocks/faq-home/_faq-home.json create mode 100644 blocks/faq-home/faq-home.css create mode 100644 blocks/faq-home/faq-home.js create mode 100644 blocks/faq-home/metadata.json create mode 100644 blocks/hero-promo/_hero-promo.json create mode 100644 blocks/hero-promo/hero-promo.css create mode 100644 blocks/hero-promo/hero-promo.js create mode 100644 blocks/hero-promo/metadata.json create mode 100644 blocks/product-grid-picks/_product-grid-picks.json create mode 100644 blocks/product-grid-picks/metadata.json create mode 100644 blocks/product-grid-picks/product-grid-picks.css create mode 100644 blocks/product-grid-picks/product-grid-picks.js create mode 100644 blocks/teaser-promo/_teaser-promo.json create mode 100644 blocks/teaser-promo/metadata.json create mode 100644 blocks/teaser-promo/teaser-promo.css create mode 100644 blocks/teaser-promo/teaser-promo.js create mode 100644 styles/brand.css create mode 100644 tools/importer/import-home.js create mode 100644 tools/importer/page-templates.json create mode 100644 tools/importer/parsers/cards-bento.js create mode 100644 tools/importer/parsers/cards-options.js create mode 100644 tools/importer/parsers/cards-plans.js create mode 100644 tools/importer/parsers/columns-benefits.js create mode 100644 tools/importer/parsers/columns-promo.js create mode 100644 tools/importer/parsers/faq-home.js create mode 100644 tools/importer/parsers/hero-promo.js create mode 100644 tools/importer/parsers/product-grid-picks.js create mode 100644 tools/importer/parsers/teaser-promo.js create mode 100644 tools/importer/transformers/totalwireless-cleanup.js create mode 100644 tools/importer/transformers/totalwireless-dm-images.js create mode 100644 tools/importer/transformers/totalwireless-sections.js diff --git a/.eslintrc.js b/.eslintrc.js index 34c6b71..2899cd8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -14,5 +14,6 @@ module.exports = { 'import/extensions': ['error', { js: 'always' }], // require js file extensions in imports 'linebreak-style': ['error', 'unix'], // enforce unix linebreaks 'no-param-reassign': [2, { props: false }], // allow modifying properties of param + 'no-underscore-dangle': ['error', { allow: ['__dmRender__'] }], }, }; diff --git a/.gitignore b/.gitignore index 55fcf8e..a7cb1cc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ helix-importer-ui !AGENTS.md tasks/lessons.md tasks/todo.md +migration-work/navigation-validation/ diff --git a/.migration/plans/totalwireless-home-page-migration.md b/.migration/plans/totalwireless-home-page-migration.md new file mode 100644 index 0000000..89b1a15 --- /dev/null +++ b/.migration/plans/totalwireless-home-page-migration.md @@ -0,0 +1,36 @@ +# Total Wireless /m/home Migration — Import Infrastructure + Content Import + +## Where things stand (verified on disk) +- **Project setup** — `.migration/project.json` = `xwalk` + library URL. ✅ +- **Site analysis** — `tools/importer/page-templates.json` with the `home` template. ✅ +- **Page analysis** — `migration-work/` has `authoring-analysis.json`, `cleaned.html`, `metadata.json`, screenshot, 116 images, `page-structure.json`. ✅ +- **Block mapping** — 9 block variants mapped to DOM selectors + section metadata. ✅ +- **Block variant code** — all 9 block folders created. ✅ +- **Import infrastructure (partial)** — 9 parsers + 3 transformers (`cleanup`, `sections`, `dm-images`) generated. ✅ + +## Scope of this request: finish Import Infrastructure + run Content Import +Source has **55 Scene7 Dynamic Media URLs**, so DM wiring (5a–5c) must land before import, then generate the import script and run it. + +1. **DM auto-block (Step 5a)** — install `buildDynamicMediaImages` + `window.__dmRender__` in `scripts/scripts.js`, call it in `buildAutoBlocks(main)`, allow-list `__dmRender__` in `.eslintrc.js`. +2. **aem.js dispatcher (Step 5b)** — 5-line DM dispatch at top of `createOptimizedPicture` in `scripts/aem.js`. +3. **xwalk model override (Step 5c)** — flip `reference`→`richtext` on image-pattern fields in the 9 blocks' `_*.json` models. +4. **Import script (Step 6.3)** — generate `tools/importer/import-home.js` binding the 9 parsers + 3 transformers. +5. **Content import (Step 6.4)** — bundle + run import for home URL → `content/*.plain.html` + `tools/importer/reports/home.report.xlsx`. +6. **Verify** — content file present, lint/syntax clean, DM anchors present. + +## Checklist +- [ ] Step 5a: Install DM/Scene7 auto-block in `scripts/scripts.js` + eslint allow-list +- [ ] Step 5b: Patch DM dispatcher into `scripts/aem.js` `createOptimizedPicture` +- [ ] Step 5c: Apply `reference`→`richtext` model override across the 9 block `_*.json` files +- [ ] Step 6.3: Generate `tools/importer/import-home.js` +- [ ] Step 6.4: Bundle + run content import → home `content/*.plain.html` + report +- [ ] Verify import output (content file present, lint/syntax clean, DM anchors present) + +## Deferred (later, not this request) +- [ ] Design migration: extract tokens/styles, apply to blocks +- [ ] Instrument header/navigation (desktop + mobile) +- [ ] Instrument footer +- [ ] Visual comparison against original; iterate + +## Note +**Execution requires Execute mode**, which I cannot toggle — please approve this plan / enable accept-edits from the UI. As soon as the session is in Execute mode, send any short message ("go") and I'll run Steps 5a → 6.4 end-to-end and report the import results, no further questions. diff --git a/.migration/project.json b/.migration/project.json new file mode 100644 index 0000000..a17d39a --- /dev/null +++ b/.migration/project.json @@ -0,0 +1,10 @@ +{ + "type": "xwalk", + "sites": { + "totalwireless": { + "previewOrg": "scdemos", + "previewSite": "totalwireless", + "libraryUrl": "https://main--sta-xwalk-boilerplate--aemysites.aem.page/tools/sidekick/library.json" + } + } +} diff --git a/blocks/cards-bento/_cards-bento.json b/blocks/cards-bento/_cards-bento.json new file mode 100644 index 0000000..b56bc8e --- /dev/null +++ b/blocks/cards-bento/_cards-bento.json @@ -0,0 +1,76 @@ +{ + "definitions": [ + { + "title": "Cards Bento", + "id": "cards-bento", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Cards Bento", + "model": "cards-bento", + "filter": "cards-bento" + } + } + } + } + }, + { + "title": "Bento Card", + "id": "cards-bento-item", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block/item", + "template": { + "name": "Bento Card", + "model": "cards-bento-item" + } + } + } + } + } + ], + "models": [ + { + "id": "cards-bento", + "fields": [] + }, + { + "id": "cards-bento-item", + "fields": [ + { + "component": "richtext", + "valueType": "string", + "name": "image", + "label": "Image", + "multi": false, + "description": "Optional. Card renders text-only when empty." + }, + { + "component": "text", + "valueType": "string", + "name": "imageAlt", + "label": "Alt Text", + "value": "" + }, + { + "component": "richtext", + "name": "text", + "label": "Text", + "valueType": "string", + "description": "Optional leading label paragraph, heading, description and a CTA link." + } + ] + } + ], + "filters": [ + { + "id": "cards-bento", + "components": [ + "cards-bento-item" + ] + } + ] +} diff --git a/blocks/cards-bento/cards-bento.css b/blocks/cards-bento/cards-bento.css new file mode 100644 index 0000000..9c0d240 --- /dev/null +++ b/blocks/cards-bento/cards-bento.css @@ -0,0 +1,234 @@ +/* ========================================================================== + Cards Bento — "Get more with Total Wireless" value-add grid. + Migrated from totalwireless.com/m/home bento box. + + Source layout (desktop, 1236px content): 6 cards in a 12-column grid. + Row 1: three equal tall cards (span 4 each) — 396x576 + Row 2: one wide card (span 6) + two small cards (span 3 each) — 606/291/291 x 335 + All cards: 24px radius, 24px gap. Card headings 24px/700/32px. + Links "Learn more": 16px/700, no underline. + Palette (exact source values, mapped to brand tokens): + navy rgb(0,3,48) -> var(--tw-navy) + white rgb(255,255,255) -> var(--tw-white) + teal rgb(0,200,183) -> --cards-bento-teal (source-specific accent) + ========================================================================== */ + +.cards-bento { + --cards-bento-radius: 24px; + --cards-bento-gap: 24px; + --cards-bento-teal: #00c8b7; + --cards-bento-pad: 32px; +} + +.cards-bento > ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: 1fr; + gap: var(--cards-bento-gap); +} + +/* --- Card shell --- */ +.cards-bento > ul > li { + position: relative; + display: flex; + flex-direction: column; + border-radius: var(--cards-bento-radius); + overflow: hidden; + background-color: var(--tw-cream); + color: var(--tw-navy); + min-height: 320px; +} + +/* --- Image: full-bleed cover at top of the card --- */ +.cards-bento .cards-bento-card-image { + margin: 0; + line-height: 0; + overflow: hidden; +} + +.cards-bento .cards-bento-card-image img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +/* --- Body: content sits below image, padded, vertically centered --- */ +.cards-bento .cards-bento-card-body { + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + gap: 20px; + padding: var(--cards-bento-pad); +} + +.cards-bento .cards-bento-card-body > div { + display: flex; + flex-direction: column; + gap: 20px; +} + +/* JS leaves an empty leading
in the body; collapse it */ +.cards-bento .cards-bento-card-body > div:empty { + display: none; +} + +/* --- Heading --- */ +.cards-bento .cards-bento-card-body :is(h2, h3) { + margin: 0; + font-family: var(--heading-font-family); + font-size: 24px; + font-weight: 700; + line-height: 32px; + color: inherit; +} + +/* --- Body copy --- */ +.cards-bento .cards-bento-card-body p:not(.button-container) { + margin: 0; + font-size: 16px; + line-height: 24px; + color: inherit; +} + +/* --- Link ("Learn more" / "Shop bundles") --- */ +.cards-bento .cards-bento-card-body a:any-link { + font-size: 16px; + font-weight: 700; + text-decoration: none; + color: var(--tw-navy); +} + +/* --- EDS auto-decorated button reset: keep "Learn more" as a plain bold link, + not a filled pill button (matches source text-link treatment) --- */ +.cards-bento .cards-bento-card-body a.button:any-link, +.cards-bento .cards-bento-card-body .button-container a.button:any-link { + display: inline; + padding: 0; + border: none; + border-radius: 0; + background: none; + background-color: transparent; + color: var(--tw-navy); + font-size: 16px; + font-weight: 700; + letter-spacing: normal; + text-transform: none; +} + +.cards-bento .cards-bento-card-body .button-container { + margin: 0; +} + +/* ========================================================================== + Per-card color themes (nth-child order matches authored source order): + 1 FIFA (image), 2 Disney+ (image), 3 UFC (solid navy), + 4 App (light), 5 Bundles (teal), 6 Total Spark (image/navy) + ========================================================================== */ + +/* Row 1 image cards (FIFA, Disney+, UFC) — navy text band with white text, + matching the source's dark footer bands over the imagery. */ +.cards-bento > ul > li:nth-child(1), +.cards-bento > ul > li:nth-child(2), +.cards-bento > ul > li:nth-child(3) { + background-color: var(--tw-navy); + color: var(--tw-white); +} + +.cards-bento > ul > li:nth-child(1) a:any-link, +.cards-bento > ul > li:nth-child(2) a:any-link, +.cards-bento > ul > li:nth-child(3) a:any-link { + color: var(--tw-white); +} + +/* Bundles — teal card, navy text */ +.cards-bento > ul > li:nth-child(5) { + background-color: var(--cards-bento-teal); + color: var(--tw-navy); +} + +/* Total Spark — navy card (no image in migrated content), white text */ +.cards-bento > ul > li:nth-child(6) { + background-color: var(--tw-navy); + color: var(--tw-white); +} + +.cards-bento > ul > li:nth-child(6) a:any-link { + color: var(--tw-white); +} + +/* Text-only cards get more breathing room since they have no image */ +.cards-bento > ul > li.cards-bento-card-text-only .cards-bento-card-body { + padding: 40px var(--cards-bento-pad); +} + +/* ========================================================================== + Responsive: 2-up at tablet, full 12-column bento at desktop + ========================================================================== */ + +@media (width >= 600px) { + .cards-bento > ul { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (width >= 900px) { + .cards-bento { + --cards-bento-pad: 40px; + } + + .cards-bento > ul { + grid-template-columns: repeat(12, 1fr); + grid-auto-flow: row; + } + + /* Row 1: three equal tall cards */ + .cards-bento > ul > li:nth-child(1), + .cards-bento > ul > li:nth-child(2), + .cards-bento > ul > li:nth-child(3) { + grid-column: span 4; + min-height: 560px; + } + + /* Row 2: wide card (image + text side by side) + two small cards */ + .cards-bento > ul > li:nth-child(4) { + grid-column: span 6; + min-height: 320px; + flex-direction: row; + } + + .cards-bento > ul > li:nth-child(5), + .cards-bento > ul > li:nth-child(6) { + grid-column: span 3; + min-height: 320px; + } + + /* Tall cards (row 1): image fills upper portion, text pinned to lower area */ + .cards-bento > ul > li:nth-child(1) .cards-bento-card-body, + .cards-bento > ul > li:nth-child(2) .cards-bento-card-body, + .cards-bento > ul > li:nth-child(3) .cards-bento-card-body { + flex: 0 0 auto; + justify-content: flex-end; + margin-top: auto; + } + + .cards-bento > ul > li:nth-child(1) .cards-bento-card-image, + .cards-bento > ul > li:nth-child(2) .cards-bento-card-image, + .cards-bento > ul > li:nth-child(3) .cards-bento-card-image { + flex: 1; + min-height: 0; + } + + /* Wide app card (row 2, child 4): image occupies left, text right */ + .cards-bento > ul > li:nth-child(4) .cards-bento-card-image { + flex: 0 0 45%; + } + + .cards-bento > ul > li:nth-child(4) .cards-bento-card-body { + flex: 1; + } +} diff --git a/blocks/cards-bento/cards-bento.js b/blocks/cards-bento/cards-bento.js new file mode 100644 index 0000000..a566700 --- /dev/null +++ b/blocks/cards-bento/cards-bento.js @@ -0,0 +1,54 @@ +import { createTag } from '../../scripts/shared.js'; + +/** + * Cards Bento – bento-grid value-add card layout. + * Each authored row becomes a card. The first

in each card is treated + * as a tag/label, and the first card is marked as the featured (primary) card. + */ +export default function decorate(block) { + const ul = createTag('ul'); + + [...block.children].forEach((row, idx) => { + const li = createTag('li'); + if (idx === 0) li.classList.add('cards-bento-card-featured'); + while (row.firstElementChild) li.append(row.firstElementChild); + + // Unwrap the single wrapper div if present + const wrapper = li.firstElementChild; + if (wrapper && wrapper.tagName === 'DIV' && li.children.length === 1) { + while (wrapper.firstChild) li.append(wrapper.firstChild); + wrapper.remove(); + } + + // Separate image into its own wrapper + const picture = li.querySelector('picture'); + if (picture) { + const imageDiv = createTag('div', { class: 'cards-bento-card-image' }); + const pictureParent = picture.parentElement; + imageDiv.append(picture); + li.prepend(imageDiv); + if (pictureParent && pictureParent.tagName === 'A' && !pictureParent.children.length) { + pictureParent.remove(); + } + } else { + li.classList.add('cards-bento-card-text-only'); + } + + // Find and mark the tag/label + const firstP = li.querySelector('p'); + if (firstP && !firstP.querySelector('picture') && !firstP.classList.contains('button-container')) { + firstP.classList.add('cards-bento-card-tag'); + } + + // Wrap remaining non-image content in a body div + const body = createTag('div', { class: 'cards-bento-card-body' }); + [...li.children].forEach((child) => { + if (!child.classList.contains('cards-bento-card-image')) body.append(child); + }); + li.append(body); + + ul.append(li); + }); + + block.replaceChildren(ul); +} diff --git a/blocks/cards-bento/metadata.json b/blocks/cards-bento/metadata.json new file mode 100644 index 0000000..e6e58a6 --- /dev/null +++ b/blocks/cards-bento/metadata.json @@ -0,0 +1,37 @@ +{ + "variantName": "cards-bento", + "baseBlock": "cards", + "version": "1.0.0", + "created": "2026-07-14T16:47:35.000Z", + "sourceContext": { + "originUrl": "https://www.totalwireless.com/m/home", + "originPage": "Total Wireless home page", + "blockPosition": "bottom" + }, + "visualCharacteristics": { + "colorScheme": "colored", + "density": "spacious", + "purpose": "value-add-grid", + "imagePattern": "large" + }, + "contentPattern": { + "structure": "6 cards: image/logo + heading + CTA, featured first card, bento sizing", + "buttonCount": 6, + "imageCount": 6 + }, + "reuseGuidance": { + "suitableFor": [ + "value-add / 'get more with' benefit grids", + "mixed-size bento promo card layouts with a featured card" + ], + "notSuitableFor": [ + "compact icon option choosers (use cards-options)", + "product picks (use product-grid-picks)" + ] + }, + "usage": { + "pagesUsing": ["/m/home"], + "reuseCount": 1, + "lastUsed": "2026-07-14T16:47:35.000Z" + } +} diff --git a/blocks/cards-options/_cards-options.json b/blocks/cards-options/_cards-options.json new file mode 100644 index 0000000..cc228c4 --- /dev/null +++ b/blocks/cards-options/_cards-options.json @@ -0,0 +1,76 @@ +{ + "definitions": [ + { + "title": "Cards Options", + "id": "cards-options", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Cards Options", + "model": "cards-options", + "filter": "cards-options" + } + } + } + } + }, + { + "title": "Option", + "id": "cards-options-item", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block/item", + "template": { + "name": "Option", + "model": "cards-options-item" + } + } + } + } + } + ], + "models": [ + { + "id": "cards-options", + "fields": [] + }, + { + "id": "cards-options-item", + "fields": [ + { + "component": "richtext", + "valueType": "string", + "name": "image", + "label": "Icon", + "multi": false, + "description": "Optional icon or graphic shown above the heading." + }, + { + "component": "text", + "valueType": "string", + "name": "imageAlt", + "label": "Alt Text", + "value": "" + }, + { + "component": "richtext", + "name": "text", + "label": "Text", + "valueType": "string", + "description": "Heading and optional description. Include a link to make the option clickable." + } + ] + } + ], + "filters": [ + { + "id": "cards-options", + "components": [ + "cards-options-item" + ] + } + ] +} diff --git a/blocks/cards-options/cards-options.css b/blocks/cards-options/cards-options.css new file mode 100644 index 0000000..198e852 --- /dev/null +++ b/blocks/cards-options/cards-options.css @@ -0,0 +1,112 @@ +/* Cards Options – "How would you like to start?" tile chooser. + Source: totalwireless.com/m/home. White tiles on a pale mint surface, each + with a navy heading top-left and a directional right-arrow bottom-right. + Values from source getComputedStyle: + - tile: bg #fff, radius 12px, padding 16px, no border, no shadow + - heading (h3): 18px / 400 / 28px line-height / navy #000330 / left aligned + - row: flex, gap 16px, 3 tiles; stacks to 1 column on mobile + - arrow: 16px navy, aligned bottom-right + The source page renders these white tiles over a mint (#e6fcf4) section + background. In EDS the block is content-width on a white page, so a subtle + soft shadow is used to keep the white tiles readable/self-contained. */ + +.cards-options > ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +.cards-options > ul > li { + background: var(--tw-white); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 2px 10px rgb(0 3 48 / 8%); +} + +/* article is the tile content column: heading pinned top, arrow pinned bottom */ +.cards-options > ul > li > article { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + min-height: 110px; + padding: 16px; + height: 100%; + box-sizing: border-box; +} + +/* whole-tile clickable variant (when the option authored a link) */ +.cards-options .cards-options-card-link { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + flex: 1; + color: inherit; + text-decoration: none; + cursor: pointer; +} + +/* directional right-arrow shown at the bottom-right of each tile */ +.cards-options > ul > li > article::after { + content: ""; + align-self: flex-end; + width: 16px; + height: 16px; + margin-top: 12px; + background-color: var(--tw-navy); + mask: url('data:image/svg+xml;utf8,') no-repeat center / contain; + flex-shrink: 0; +} + +.cards-options .cards-options-card-image { + line-height: 0; +} + +.cards-options .cards-options-card-image img { + display: block; + width: 48px; + height: 48px; + object-fit: contain; +} + +.cards-options .cards-options-card-body { + flex-grow: 1; +} + +.cards-options .cards-options-card-body:empty { + display: none; +} + +.cards-options .cards-options-card-body :is(h2, h3, h4) { + margin: 0; + font-family: var(--body-font-family); + font-size: 18px; + font-weight: 400; + line-height: 28px; + color: var(--tw-navy); + text-align: left; +} + +.cards-options .cards-options-card-body p { + margin: 8px 0 0; + color: var(--tw-text); + font-size: 16px; + line-height: 24px; + text-align: left; +} + +/* hide authored inline link text in the body when a card link is present; + the arrow already signals the call-to-action */ +.cards-options .cards-options-card-body p:has(> a:only-child) { + display: none; +} + +@media (width >= 600px) { + .cards-options > ul { + grid-template-columns: repeat(3, 1fr); + } +} diff --git a/blocks/cards-options/cards-options.js b/blocks/cards-options/cards-options.js new file mode 100644 index 0000000..4133087 --- /dev/null +++ b/blocks/cards-options/cards-options.js @@ -0,0 +1,74 @@ +import { createOptimizedPicture } from '../../scripts/aem.js'; +import { createTag, isUE } from '../../scripts/shared.js'; + +/** + * Cards Options – icon-forward option chooser. + * Each authored row becomes an option item with an optional icon/graphic, + * heading and optional description. Author may include a link to make the + * whole item clickable. + */ +export default function decorate(block) { + const ul = createTag('ul'); + + [...block.children].forEach((row) => { + const li = createTag('li'); + while (row.firstElementChild) li.append(row.firstElementChild); + + const content = li.firstElementChild; + if (content?.children?.length > 1) { + const imageEl = [...content.children].find((el) => el.querySelector('picture')); + if (imageEl) { + const picture = imageEl.querySelector('picture'); + const imageDiv = createTag('div', { class: 'cards-options-card-image' }); + if (picture) imageDiv.append(picture); + const bodyDiv = createTag('div', { class: 'cards-options-card-body' }); + [...content.children].forEach((el) => { if (el !== imageEl) bodyDiv.append(el); }); + li.replaceChildren(imageDiv, bodyDiv); + } else { + content.className = 'cards-options-card-body'; + } + } else { + [...li.children].forEach((div) => { + div.className = (div.children.length === 1 && div.querySelector('picture')) + ? 'cards-options-card-image' : 'cards-options-card-body'; + }); + } + + const linkEl = li.querySelector('.cards-options-card-image a[href]') || li.querySelector('.cards-options-card-body a[href]'); + if (linkEl) { + if (isUE) { + const wrapper = createTag('div', { class: 'cards-options-card-link' }); + while (li.firstChild) wrapper.append(li.firstChild); + li.append(wrapper); + const parent = linkEl.parentElement; + if (parent) parent.classList.remove('button-container'); + linkEl.classList.remove('button'); + } else { + const wrapper = createTag('a', { + href: linkEl.getAttribute('href'), + title: linkEl.getAttribute('title')?.trim() || undefined, + class: 'cards-options-card-link', + }); + while (li.firstChild) wrapper.append(li.firstChild); + li.append(wrapper); + linkEl.replaceWith(...linkEl.childNodes); + li.querySelectorAll('.cards-options-card-body a[href]').forEach((a) => a.replaceWith(...a.childNodes)); + } + } + + const article = createTag('article'); + while (li.firstChild) article.append(li.firstChild); + li.append(article); + + ul.append(li); + }); + + ul.querySelectorAll('picture > img').forEach((img) => { + const picture = img.closest('picture'); + if (picture) { + picture.replaceWith(createOptimizedPicture(img.src, img.alt || '', false, [{ width: '400' }])); + } + }); + + block.replaceChildren(ul); +} diff --git a/blocks/cards-options/metadata.json b/blocks/cards-options/metadata.json new file mode 100644 index 0000000..39cd5ab --- /dev/null +++ b/blocks/cards-options/metadata.json @@ -0,0 +1,38 @@ +{ + "variantName": "cards-options", + "baseBlock": "cards", + "version": "1.0.0", + "created": "2026-07-14T16:47:35.000Z", + "sourceContext": { + "originUrl": "https://www.totalwireless.com/m/home", + "originPage": "Total Wireless home page", + "blockPosition": "middle" + }, + "visualCharacteristics": { + "colorScheme": "light", + "density": "compact", + "purpose": "option-chooser", + "imagePattern": "icon" + }, + "contentPattern": { + "structure": "3 items: icon + heading + optional description, centered", + "buttonCount": 0, + "imageCount": 3 + }, + "reuseGuidance": { + "suitableFor": [ + "entry-point / 'how would you like to start' choosers", + "icon + heading option grids", + "contact / support option cards" + ], + "notSuitableFor": [ + "image-forward article cards (use base cards)", + "large bento benefit grids (use cards-bento)" + ] + }, + "usage": { + "pagesUsing": ["/m/home"], + "reuseCount": 2, + "lastUsed": "2026-07-14T16:47:35.000Z" + } +} diff --git a/blocks/cards-plans/_cards-plans.json b/blocks/cards-plans/_cards-plans.json new file mode 100644 index 0000000..76c3d04 --- /dev/null +++ b/blocks/cards-plans/_cards-plans.json @@ -0,0 +1,74 @@ +{ + "definitions": [ + { + "title": "Cards Plans", + "id": "cards-plans", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Cards Plans", + "model": "cards-plans", + "filter": "cards-plans" + } + } + } + } + }, + { + "title": "Plan", + "id": "cards-plans-item", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block/item", + "template": { + "name": "Plan", + "model": "cards-plans-item" + } + } + } + } + } + ], + "models": [ + { + "id": "cards-plans", + "fields": [] + }, + { + "id": "cards-plans-item", + "fields": [ + { + "component": "richtext", + "name": "name", + "label": "Plan Name", + "valueType": "string" + }, + { + "component": "text", + "name": "price", + "label": "Per-line Price", + "valueType": "string", + "description": "Base per-line price incl. period, e.g. '$60/mo'. Multiplied by selected number of lines." + }, + { + "component": "richtext", + "name": "features", + "label": "Features", + "valueType": "string", + "description": "Feature list and perks. Include a link for the Select CTA." + } + ] + } + ], + "filters": [ + { + "id": "cards-plans", + "components": [ + "cards-plans-item" + ] + } + ] +} diff --git a/blocks/cards-plans/cards-plans.css b/blocks/cards-plans/cards-plans.css new file mode 100644 index 0000000..6fe29dc --- /dev/null +++ b/blocks/cards-plans/cards-plans.css @@ -0,0 +1,148 @@ +/* Cards Plans -- pricing-plan grid migrated from totalwireless.com/m/home. + Source: white cards with a teal header (plan name + large price) and a + white body listing plan features, with a full-width navy pill CTA. + Values extracted from source computed styles; see + migration-work/cards-plans-extracted.css. */ + +/* Brand teal used for the plan-card header (source uses a decorative teal + asset; approximated here as a solid Total Wireless teal). */ +.cards-plans { + --cards-plans-teal: #00d1c9; + --cards-plans-border: rgb(178 178 178); +} + +/* The interactive lines stepper / BYO toggle are not present in the source + static pricing grid -- hide the controls to match the original design. */ +.cards-plans .cards-plans-controls { + display: none; +} + +/* Plan grid: mobile-first single column. */ +.cards-plans .cards-plans-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +/* Plan card: white body, 1px gray border, 16px radius, flex column. */ +.cards-plans .cards-plans-card { + display: flex; + flex-direction: column; + border: 1px solid var(--cards-plans-border); + border-radius: 16px; + background: var(--tw-white); + color: var(--tw-text); + overflow: hidden; +} + +/* Card cell 1 (name) + cell 2 (price) form the teal header. */ +.cards-plans .cards-plans-card > div:first-child, +.cards-plans .cards-plans-card > div:nth-child(2) { + background: var(--cards-plans-teal); + padding-inline: 16px; +} + +.cards-plans .cards-plans-card > div:first-child { + padding-top: 20px; +} + +.cards-plans .cards-plans-card > div:nth-child(2) { + padding-bottom: 20px; +} + +/* Plan name. */ +.cards-plans .cards-plans-card h3 { + margin: 0; + font-family: var(--body-font-family); + font-size: 16px; + font-weight: 700; + line-height: 24px; + text-transform: uppercase; + letter-spacing: normal; + color: var(--tw-navy); +} + +/* Price line. */ +.cards-plans .cards-plans-price { + display: flex; + align-items: baseline; + gap: 6px; + margin: 0; + color: var(--tw-navy); +} + +.cards-plans .cards-plans-price-amount { + font-size: 78px; + font-weight: 700; + line-height: 1; + color: var(--tw-navy); +} + +.cards-plans .cards-plans-price-suffix { + font-size: 14px; + font-weight: 500; + color: var(--tw-navy); +} + +/* Card cell 3 (features + CTA) = white body. */ +.cards-plans .cards-plans-card > div:last-child { + display: flex; + flex-direction: column; + flex: 1; + padding: 24px; + background: var(--tw-white); +} + +/* Feature list. */ +.cards-plans .cards-plans-card ul { + margin: 0; + padding: 0 0 0 21px; + font-size: 16px; + font-weight: 400; + line-height: 24px; +} + +.cards-plans .cards-plans-card li { + font-size: 16px; + font-weight: 500; + line-height: 24px; + color: var(--tw-text); +} + +.cards-plans .cards-plans-card li + li { + margin-top: 12px; +} + +/* Select CTA: full-width navy pill anchored to the bottom of the body. */ +.cards-plans .cards-plans-card .button-container { + margin: 24px 0 0; + margin-top: auto; +} + +.cards-plans .cards-plans-card a.button:any-link { + display: flex; + width: 100%; + min-height: 48px; + border-radius: 9999px; + padding: 12px 16px; + font-size: 16px; + font-weight: 700; +} + +/* Tablet: two columns. */ +@media (width >= 600px) { + .cards-plans .cards-plans-list { + grid-template-columns: repeat(2, 1fr); + gap: 16px 19.5px; + } +} + +/* Desktop: three columns to match the source carousel viewport. */ +@media (width >= 900px) { + .cards-plans .cards-plans-list { + grid-template-columns: repeat(3, 1fr); + } +} diff --git a/blocks/cards-plans/cards-plans.js b/blocks/cards-plans/cards-plans.js new file mode 100644 index 0000000..85abbc7 --- /dev/null +++ b/blocks/cards-plans/cards-plans.js @@ -0,0 +1,104 @@ +import { createTag } from '../../scripts/shared.js'; + +/** + * Cards Plans – interactive plan pricing comparison. + * + * Authoring: each row is one plan card. Within a plan card, mark the per-line + * price with a link/strong whose text contains a dollar amount; the block reads + * the base per-line price and multiplies by the selected number of lines. + * A "Number of lines" stepper and a "Bring your phone" toggle are rendered + * above the plan grid. + */ + +const MIN_LINES = 1; +const MAX_LINES = 5; + +function parsePrice(text) { + const match = (text || '').replace(/,/g, '').match(/\$?\s*(\d+(?:\.\d+)?)/); + return match ? parseFloat(match[1]) : null; +} + +function formatPrice(value) { + return Number.isInteger(value) ? `$${value}` : `$${value.toFixed(2)}`; +} + +function updatePrices(block, lines) { + block.querySelectorAll('.cards-plans-price[data-base-price]').forEach((el) => { + const base = parseFloat(el.getAttribute('data-base-price')); + if (Number.isFinite(base)) { + const total = base * lines; + const amount = el.querySelector('.cards-plans-price-amount'); + if (amount) amount.textContent = formatPrice(total); + } + }); +} + +function buildControls(block) { + const controls = createTag('div', { class: 'cards-plans-controls' }); + + const label = createTag('span', { class: 'cards-plans-lines-label' }, 'Number of lines'); + const stepper = createTag('div', { class: 'cards-plans-stepper' }); + const dec = createTag('button', { type: 'button', class: 'cards-plans-dec', 'aria-label': 'Decrease lines' }, '−'); + const count = createTag('span', { class: 'cards-plans-count', 'aria-live': 'polite' }, '1'); + const inc = createTag('button', { type: 'button', class: 'cards-plans-inc', 'aria-label': 'Increase lines' }, '+'); + stepper.append(dec, count, inc); + + let lines = MIN_LINES; + const sync = () => { + count.textContent = String(lines); + dec.disabled = lines <= MIN_LINES; + inc.disabled = lines >= MAX_LINES; + updatePrices(block, lines); + }; + dec.addEventListener('click', () => { lines = Math.max(MIN_LINES, lines - 1); sync(); }); + inc.addEventListener('click', () => { lines = Math.min(MAX_LINES, lines + 1); sync(); }); + + controls.append(label, stepper); + + const byo = createTag('label', { class: 'cards-plans-byo' }); + const byoInput = createTag('input', { type: 'checkbox' }); + byo.append(byoInput, createTag('span', {}, 'Bring your own phone')); + byoInput.addEventListener('change', () => { + block.classList.toggle('cards-plans-byo-on', byoInput.checked); + }); + controls.append(byo); + + // initialise + sync(); + return controls; +} + +export default function decorate(block) { + const ul = createTag('ul', { class: 'cards-plans-list' }); + + [...block.children].forEach((row) => { + const li = createTag('li', { class: 'cards-plans-card' }); + while (row.firstElementChild) li.append(row.firstElementChild); + + // Unwrap single wrapper div + const wrapper = li.firstElementChild; + if (wrapper && wrapper.tagName === 'DIV' && li.children.length === 1) { + while (wrapper.firstChild) li.append(wrapper.firstChild); + wrapper.remove(); + } + + // Detect the price element: first element whose text contains a $amount + const priceEl = [...li.querySelectorAll('p, h2, h3, h4, strong')] + .find((el) => /\$\s*\d/.test(el.textContent)); + if (priceEl) { + const base = parsePrice(priceEl.textContent); + const suffix = priceEl.textContent.replace(/\$?\s*\d+(?:\.\d+)?/, '').trim(); + priceEl.classList.add('cards-plans-price'); + if (base != null) priceEl.setAttribute('data-base-price', String(base)); + priceEl.textContent = ''; + priceEl.append(createTag('span', { class: 'cards-plans-price-amount' }, formatPrice(base ?? 0))); + if (suffix) priceEl.append(createTag('span', { class: 'cards-plans-price-suffix' }, ` ${suffix}`)); + } + + ul.append(li); + }); + + const controls = buildControls(block); + block.replaceChildren(controls, ul); + updatePrices(block, MIN_LINES); +} diff --git a/blocks/cards-plans/metadata.json b/blocks/cards-plans/metadata.json new file mode 100644 index 0000000..6e0ca21 --- /dev/null +++ b/blocks/cards-plans/metadata.json @@ -0,0 +1,38 @@ +{ + "variantName": "cards-plans", + "baseBlock": "cards", + "version": "1.0.0", + "created": "2026-07-14T16:47:35.000Z", + "sourceContext": { + "originUrl": "https://www.totalwireless.com/m/home", + "originPage": "Total Wireless home page", + "blockPosition": "middle" + }, + "visualCharacteristics": { + "colorScheme": "light", + "density": "spacious", + "purpose": "plan-pricing", + "imagePattern": "icon" + }, + "contentPattern": { + "structure": "line stepper + BYO toggle + N plan cards (name, per-line price, feature list, Select CTA)", + "buttonCount": 1, + "imageCount": 0, + "interactive": true + }, + "reuseGuidance": { + "suitableFor": [ + "interactive plan / pricing comparison grids", + "per-line multiplier pricing with a line-count stepper" + ], + "notSuitableFor": [ + "static content grids (use cards or cards-bento)", + "single hero pricing callouts" + ] + }, + "usage": { + "pagesUsing": ["/m/home"], + "reuseCount": 1, + "lastUsed": "2026-07-14T16:47:35.000Z" + } +} diff --git a/blocks/columns-benefits/_columns-benefits.json b/blocks/columns-benefits/_columns-benefits.json new file mode 100644 index 0000000..2884beb --- /dev/null +++ b/blocks/columns-benefits/_columns-benefits.json @@ -0,0 +1,53 @@ +{ + "definitions": [ + { + "title": "Columns Benefits", + "id": "columns-benefits", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/columns/v1/columns", + "template": { + "columns": "5", + "rows": "1" + } + } + } + } + } + ], + "models": [ + { + "id": "columns-benefits", + "fields": [ + { + "component": "number", + "valueType": "number", + "name": "columns", + "label": "Number of Columns", + "value": 5, + "validation": { + "numberMin": 1, + "numberMax": 6 + } + } + ] + } + ], + "filters": [ + { + "id": "columns-benefits", + "components": [ + "column" + ] + }, + { + "id": "column", + "components": [ + "text", + "image", + "title" + ] + } + ] +} diff --git a/blocks/columns-benefits/columns-benefits.css b/blocks/columns-benefits/columns-benefits.css new file mode 100644 index 0000000..d9b4f34 --- /dev/null +++ b/blocks/columns-benefits/columns-benefits.css @@ -0,0 +1,63 @@ +/* Block: columns-benefits + "Pick any plan and get:" benefits strip — a wrapping horizontal row of + icon + label chips. Values extracted from totalwireless.com/m/home + computed styles (feature-banner__icon-list). + Source: flex row, wrap, gap 12px, flex-start; each item icon(28px)+label + side-by-side, align-items center, gap 4px; label 24px/32px navy weight 400. */ + +/* The row: horizontal wrapping strip of chips. */ +.columns-benefits > div { + display: flex; + flex-flow: column wrap; + gap: var(--space-m); /* 16px mobile — one chip per line stacks comfortably */ +} + +/* Each cell = one benefit chip (auto width, not a fixed column). */ +.columns-benefits > div > div { + flex: 0 1 auto; +} + +/* EDS wraps the and label together in a

; that

is the + icon + label flex container. */ +.columns-benefits > div > div > p { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + margin: 0; +} + +.columns-benefits picture { + display: inline-flex; + flex: 0 0 auto; + line-height: 0; +} + +.columns-benefits img { + width: 28px; + height: 28px; + object-fit: contain !important; /* override global .section img { object-fit: cover } */ +} + +/* Label text. */ +.columns-benefits > div > div p p { + margin: 0; + font-size: 20px; + font-weight: 400; + line-height: 1.33; + color: var(--tw-navy); + text-align: start; +} + +@media (width >= 900px) { + .columns-benefits > div { + flex-direction: row; + align-items: center; + gap: 12px; + } + + .columns-benefits > div > div p p { + font-size: 24px; + line-height: 32px; + } +} diff --git a/blocks/columns-benefits/columns-benefits.js b/blocks/columns-benefits/columns-benefits.js new file mode 100644 index 0000000..44d8c72 --- /dev/null +++ b/blocks/columns-benefits/columns-benefits.js @@ -0,0 +1,17 @@ +export default function decorate(block) { + const cols = [...block.firstElementChild.children]; + block.classList.add(`columns-benefits-${cols.length}-cols`); + + // Mark icon-only columns so the strip aligns icon above label. + [...block.children].forEach((row) => { + [...row.children].forEach((col) => { + const pic = col.querySelector('picture'); + if (pic) { + const picWrapper = pic.closest('div'); + if (picWrapper && picWrapper.children.length === 1) { + picWrapper.classList.add('columns-benefits-img-col'); + } + } + }); + }); +} diff --git a/blocks/columns-benefits/metadata.json b/blocks/columns-benefits/metadata.json new file mode 100644 index 0000000..560710e --- /dev/null +++ b/blocks/columns-benefits/metadata.json @@ -0,0 +1,37 @@ +{ + "variantName": "columns-benefits", + "baseBlock": "columns", + "version": "1.0.0", + "created": "2026-07-14T16:47:35.000Z", + "sourceContext": { + "originUrl": "https://www.totalwireless.com/m/home", + "originPage": "Total Wireless home page", + "blockPosition": "middle" + }, + "visualCharacteristics": { + "colorScheme": "light", + "density": "compact", + "purpose": "benefit-strip", + "imagePattern": "icon" + }, + "contentPattern": { + "structure": "5 items: icon + short label", + "buttonCount": 0, + "imageCount": 5 + }, + "reuseGuidance": { + "suitableFor": [ + "horizontal icon + label benefit strips", + "plan/feature highlight rows" + ], + "notSuitableFor": [ + "content with CTAs or long descriptions (use cards)", + "two-tile promotional layouts (use columns-promo)" + ] + }, + "usage": { + "pagesUsing": ["/m/home"], + "reuseCount": 1, + "lastUsed": "2026-07-14T16:47:35.000Z" + } +} diff --git a/blocks/columns-promo/_columns-promo.json b/blocks/columns-promo/_columns-promo.json new file mode 100644 index 0000000..2f31847 --- /dev/null +++ b/blocks/columns-promo/_columns-promo.json @@ -0,0 +1,54 @@ +{ + "definitions": [ + { + "title": "Columns Promo", + "id": "columns-promo", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/columns/v1/columns", + "template": { + "columns": "2", + "rows": "1" + } + } + } + } + } + ], + "models": [ + { + "id": "columns-promo", + "fields": [ + { + "component": "number", + "valueType": "number", + "name": "columns", + "label": "Number of Columns", + "value": 2, + "validation": { + "numberMin": 1, + "numberMax": 6 + } + } + ] + } + ], + "filters": [ + { + "id": "columns-promo", + "components": [ + "column" + ] + }, + { + "id": "column", + "components": [ + "text", + "image", + "button", + "title" + ] + } + ] +} diff --git a/blocks/columns-promo/columns-promo.css b/blocks/columns-promo/columns-promo.css new file mode 100644 index 0000000..2954bf2 --- /dev/null +++ b/blocks/columns-promo/columns-promo.css @@ -0,0 +1,138 @@ +/* Columns Promo — two side-by-side promotional split-banner tiles. + Source: totalwireless.com/m/home ("Get 4 Free 5G Phones" + + "Get a free month of 5G Home Internet"). Values from source + computed styles. + + Decorated EDS DOM (decorateMain wraps the bare picture + siblings + into a single

): + .columns-promo > div -> row (holds both tiles) + .columns-promo > div > div -> tile (also gets .columns-promo-img-col) + .columns-promo > div > div > p -> content wrapper: picture, h2, text, buttons */ + +.columns-promo > div { + display: flex; + flex-direction: column; + gap: 32px; +} + +/* Tile: centered content stack with a rounded card image */ +.columns-promo > div > div { + display: flex; + flex-direction: column; + border-radius: 16px; + overflow: hidden; + text-align: center; +} + +/* All tile content lives inside a single

after EDS decoration */ +.columns-promo > div > div > p { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + margin: 0; + padding: 40px 24px; +} + +/* Image sits below the text in the source card */ +.columns-promo > div > div picture { + order: 3; + display: block; + width: 100%; + margin-top: 8px; +} + +.columns-promo > div > div img { + display: block; + width: 100%; + height: auto; + border-radius: 16px; + object-fit: cover; +} + +.columns-promo > div > div h2 { + order: 0; + margin: 0; + font-size: 36px; + font-weight: 700; + line-height: 44px; + letter-spacing: normal; + text-transform: none; + color: var(--tw-navy); +} + +/* Source promo headings have no accent bar; suppress the global + lazy-styles h2::after (which resolves to (0,3,3) specificity). */ +:is(main, article.article-body) > .section .columns-promo.block h2::after { + content: none; + display: none; +} + +/* Supporting text: any paragraph in the tile that is not a button + container and not the wrapper

itself. */ +.columns-promo > div > div p:not(.button-container, :first-child) { + order: 1; + margin: 0; + font-size: 18px; + line-height: 28px; + color: var(--tw-navy); +} + +/* CTA row: pill buttons side by side, centered */ +.columns-promo-cta-group { + order: 2; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 24px; +} + +.columns-promo-cta-group .button-container { + margin: 0; +} + +.columns-promo > div > div a.button:any-link { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 9999px; + padding: 11px 23px; + font-size: 16px; + font-weight: 700; + line-height: 1.2; + letter-spacing: normal; + text-transform: none; + text-decoration: none; + background-color: var(--tw-navy); + background-image: none; + color: var(--tw-white); +} + +/* Second CTA in a tile = secondary (white pill, dark text), matching source */ +.columns-promo-cta-group .button-container:last-child a.button:any-link, +.columns-promo > div > div a.button.secondary:any-link { + border: 0; + background-color: var(--tw-white); + color: #1a1c35; +} + +@media (width >= 900px) { + .columns-promo > div { + flex-direction: row; + align-items: stretch; + gap: 32px; + } + + .columns-promo > div > div { + flex: 1; + min-width: 0; + } + + .columns-promo > div > div > p { + flex: 1; + padding: 80px 24px; + justify-content: center; + } +} diff --git a/blocks/columns-promo/columns-promo.js b/blocks/columns-promo/columns-promo.js new file mode 100644 index 0000000..89491c1 --- /dev/null +++ b/blocks/columns-promo/columns-promo.js @@ -0,0 +1,26 @@ +export default function decorate(block) { + const cols = [...block.firstElementChild.children]; + block.classList.add(`columns-promo-${cols.length}-cols`); + + [...block.children].forEach((row) => { + [...row.children].forEach((col) => { + const pic = col.querySelector('picture'); + if (pic) { + const picWrapper = pic.closest('div'); + if (picWrapper && picWrapper.children.length === 1) { + picWrapper.classList.add('columns-promo-img-col'); + } + } + + // Group adjacent CTA buttons into a single row so they sit + // side by side like the source split-banner tiles. + const buttons = [...col.querySelectorAll('.button-container')]; + if (buttons.length > 1) { + const group = document.createElement('div'); + group.className = 'columns-promo-cta-group'; + buttons[0].parentElement.insertBefore(group, buttons[0]); + buttons.forEach((b) => group.append(b)); + } + }); + }); +} diff --git a/blocks/columns-promo/metadata.json b/blocks/columns-promo/metadata.json new file mode 100644 index 0000000..365fed1 --- /dev/null +++ b/blocks/columns-promo/metadata.json @@ -0,0 +1,37 @@ +{ + "variantName": "columns-promo", + "baseBlock": "columns", + "version": "1.0.0", + "created": "2026-07-14T16:47:35.000Z", + "sourceContext": { + "originUrl": "https://www.totalwireless.com/m/home", + "originPage": "Total Wireless home page", + "blockPosition": "middle" + }, + "visualCharacteristics": { + "colorScheme": "colored", + "density": "wide", + "purpose": "promo-tiles", + "imagePattern": "large" + }, + "contentPattern": { + "structure": "2 tiles: heading + image + CTA per tile", + "buttonCount": 2, + "imageCount": 2 + }, + "reuseGuidance": { + "suitableFor": [ + "two side-by-side promotional offer tiles", + "dual campaign call-outs with images and CTAs" + ], + "notSuitableFor": [ + "simple text-only side-by-side content (use base columns)", + "grids of 3+ repeating items (use cards)" + ] + }, + "usage": { + "pagesUsing": ["/m/home"], + "reuseCount": 2, + "lastUsed": "2026-07-14T16:47:35.000Z" + } +} diff --git a/blocks/faq-home/_faq-home.json b/blocks/faq-home/_faq-home.json new file mode 100644 index 0000000..dc2c963 --- /dev/null +++ b/blocks/faq-home/_faq-home.json @@ -0,0 +1,68 @@ +{ + "definitions": [ + { + "title": "FAQ Home", + "id": "faq-home", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "FAQ Home", + "model": "faq-home", + "filter": "faq-home" + } + } + } + } + }, + { + "title": "FAQ Item", + "id": "faq-home-item", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block/item", + "template": { + "name": "FAQ Item", + "model": "faq-home-item" + } + } + } + } + } + ], + "models": [ + { + "id": "faq-home", + "fields": [] + }, + { + "id": "faq-home-item", + "fields": [ + { + "component": "richtext", + "name": "question", + "label": "Question", + "valueType": "string", + "description": "The question shown on the accordion toggle." + }, + { + "component": "richtext", + "name": "answer", + "label": "Answer", + "valueType": "string", + "description": "The answer revealed when the question is expanded." + } + ] + } + ], + "filters": [ + { + "id": "faq-home", + "components": [ + "faq-home-item" + ] + } + ] +} diff --git a/blocks/faq-home/faq-home.css b/blocks/faq-home/faq-home.css new file mode 100644 index 0000000..239ee35 --- /dev/null +++ b/blocks/faq-home/faq-home.css @@ -0,0 +1,89 @@ +/* Block: faq-home -- FAQ accordion, design migrated from totalwireless.com/m/home + Source computed styles: + - question label: GalanoGrotesque 24px / 700 / line-height 32px / #000330 + - header row padding: 32px 0 (row height ~96px) + - divider: 1px solid rgba(0,0,0,0.2) below each item + - answer text: GalanoGrotesque 16px / 400 / line-height 24px / #000330 + - trigger icon: down-caret chevron, 20x20, navy, rotates 180deg when expanded */ + +main .faq-home { + max-width: 1130px; + margin: 0 auto; +} + +main .faq-home .faq-home-item { + border-bottom: 1px solid rgb(0 0 0 / 20%); +} + +/* Question header row */ +main .faq-home .faq-home-question { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + width: 100%; + padding: 32px 0; + background: none; + border: none; + cursor: pointer; + font-family: var(--heading-font-family); + font-size: 20px; + font-weight: 700; + line-height: 28px; + color: var(--tw-navy); + text-align: left; + transition: color 0.2s ease; +} + +main .faq-home .faq-home-question:hover { + color: var(--tw-navy); +} + +/* Down-caret chevron drawn with borders, rotates when expanded */ +main .faq-home .faq-home-question::after { + content: ''; + flex-shrink: 0; + width: 12px; + height: 12px; + margin-right: 4px; + border-right: 2px solid var(--tw-navy); + border-bottom: 2px solid var(--tw-navy); + transform: rotate(45deg); + transform-origin: center; + transition: transform 0.25s ease; +} + +main .faq-home .faq-home-question[aria-expanded='true']::after { + transform: rotate(225deg); +} + +/* Answer panel */ +main .faq-home .faq-home-answer { + padding: 0 0 32px; +} + +main .faq-home .faq-home-answer p { + margin: 0; + font-family: var(--body-font-family); + font-size: 16px; + font-weight: 400; + line-height: 24px; + color: var(--tw-navy); +} + +main .faq-home .faq-home-answer p + p { + margin-top: 16px; +} + +main .faq-home .faq-home-answer a:any-link { + color: var(--tw-navy); + text-decoration: underline; +} + +/* Desktop: larger question text matching source (24px / line-height 32px) */ +@media (width >= 900px) { + main .faq-home .faq-home-question { + font-size: 24px; + line-height: 32px; + } +} diff --git a/blocks/faq-home/faq-home.js b/blocks/faq-home/faq-home.js new file mode 100644 index 0000000..3046351 --- /dev/null +++ b/blocks/faq-home/faq-home.js @@ -0,0 +1,61 @@ +import { createTag } from '../../scripts/shared.js'; +import { extendSchema } from '../../scripts/schema.js'; + +function toggleItem(button) { + const expanded = button.getAttribute('aria-expanded') === 'true'; + button.setAttribute('aria-expanded', String(!expanded)); + const panel = button.nextElementSibling; + panel.hidden = expanded; +} + +/** + * FAQ Home – inline authored question/answer accordion. + * Each authored row has two cells: question and answer. Unlike the index-driven + * faq block, this variant renders content authored directly on the page. + */ +export default function decorate(block) { + const faqs = []; + const rows = [...block.children]; + block.textContent = ''; + + rows.forEach((row) => { + const cells = [...row.children]; + const question = (cells[0]?.textContent || '').trim(); + const answerCell = cells[1]; + if (!question || !answerCell) return; + + const item = createTag('div', { class: 'faq-home-item' }); + const button = createTag('button', { + class: 'faq-home-question', + 'aria-expanded': 'false', + type: 'button', + }, question); + + const panel = createTag('div', { + class: 'faq-home-answer', + role: 'region', + hidden: true, + }); + while (answerCell.firstChild) panel.append(answerCell.firstChild); + + button.addEventListener('click', () => toggleItem(button)); + item.append(button, panel); + block.append(item); + + faqs.push({ question, answer: panel.textContent.trim() }); + }); + + if (faqs.length) { + extendSchema('WebPage', { + '@type': 'FAQPage', + mainEntity: faqs.map((faq) => ({ + '@type': 'Question', + name: faq.question, + acceptedAnswer: { + '@type': 'Answer', + text: faq.answer, + }, + })), + }); + } +} diff --git a/blocks/faq-home/metadata.json b/blocks/faq-home/metadata.json new file mode 100644 index 0000000..4db9f64 --- /dev/null +++ b/blocks/faq-home/metadata.json @@ -0,0 +1,36 @@ +{ + "variantName": "faq-home", + "baseBlock": "faq", + "version": "1.0.0", + "created": "2026-07-14T16:54:00.000Z", + "sourceContext": { + "originUrl": "https://www.totalwireless.com/m/home", + "originPage": "Total Wireless home page", + "blockPosition": "bottom" + }, + "visualCharacteristics": { + "colorScheme": "light", + "density": "comfortable", + "purpose": "faq-accordion", + "imagePattern": "none" + }, + "contentPattern": { + "structure": "Heading followed by expandable question/answer accordion items authored inline on the page", + "buttonCount": 0, + "imageCount": 0 + }, + "reuseGuidance": { + "suitableFor": [ + "inline authored FAQ accordions on landing/home pages", + "question/answer pairs authored directly on the page (non-index-driven)" + ], + "notSuitableFor": [ + "index/query-driven FAQ listings (use base faq block)" + ] + }, + "usage": { + "pagesUsing": ["/m/home"], + "reuseCount": 1, + "lastUsed": "2026-07-14T16:54:00.000Z" + } +} diff --git a/blocks/footer/footer.css b/blocks/footer/footer.css index ffaf04b..31bd3e6 100644 --- a/blocks/footer/footer.css +++ b/blocks/footer/footer.css @@ -1,8 +1,7 @@ footer { width: 100%; - background-color: light-dark(var(--color-rust), #080d0d); - color: var(--color-parchment); - border-top: light-dark(none, var(--border-heritage)); + background-color: var(--tw-navy, #000330); + color: var(--tw-cream, #fff1cc); font-size: var(--body-font-size-xs); } @@ -68,14 +67,14 @@ footer .footer .section:nth-child(3) li { } footer .footer a:any-link { - color: var(--color-parchment); - text-decoration-color: rgb(245 242 237 / 30%); + color: var(--tw-cream, #fff1cc); + text-decoration-color: rgb(255 241 204 / 30%); transition: color 0.25s, text-decoration-color 0.25s, opacity 0.25s; } footer .footer a:any-link:hover { - text-decoration-color: var(--color-gold); - color: var(--color-gold); + text-decoration-color: var(--tw-white, #fff); + color: var(--tw-white, #fff); } footer .footer .section:nth-child(3) ul a { @@ -119,3 +118,58 @@ footer .footer .icon.icon-logo :is(svg, img) { padding: 48px var(--content-padding-inline); } } + +/* ========================================================================== + Total Wireless footer layout — multi-column link groups (Shop / Current + customers / About), legal link row, centered copyright, social links. + Colors are set on the base rules above (navy bg, cream text). + ========================================================================== */ + +/* First section = the three link-group columns */ +footer .footer .section:first-child > .default-content-wrapper, +footer .footer .section:first-child { + display: grid; + grid-template-columns: 1fr; + gap: 32px; + text-align: left; + max-width: 1100px; + margin: 0 auto; +} + +footer .footer .section:first-child > .default-content-wrapper > div { + text-align: left; +} + +footer .footer .section:first-child p { + font-weight: 700; + font-size: var(--body-font-size-s, 14px); + margin-bottom: 12px; + color: var(--tw-white, #fff); +} + +footer .footer .section:first-child ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Legal row (second section): inline wrap of small links */ +footer .footer .section:nth-child(2) ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px 20px; + font-size: var(--body-font-size-xs, 12px); +} + +@media (width >= 600px) { + footer .footer .section:first-child > .default-content-wrapper { + grid-template-columns: repeat(3, 1fr); + } +} diff --git a/blocks/header/header.css b/blocks/header/header.css index 4659b15..e8c140a 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -1328,3 +1328,37 @@ header nav .nav-tools .nav-language-menu a:focus-visible { max-width: 1264px; } } + +/* ========================================================================== + Total Wireless header brand overrides. + White header bar, navy nav links, navy pill "Log in" — matches the + migrated brand system (styles/brand.css). Overrides the heritage + rust/gold palette in the rules above. + ========================================================================== */ +header.block .nav-wrapper { + background-color: var(--tw-white, #fff); + border-bottom: 1px solid rgb(0 3 48 / 10%); +} + +header nav .nav-sections a:any-link, +header nav .nav-sections p { + color: var(--tw-navy, #000330); + font-weight: 700; +} + +header nav .nav-sections a:any-link:hover, +header nav .nav-sections a:any-link:focus-visible { + color: var(--tw-navy, #000330); + text-decoration: underline; +} + +header nav .nav-tools a:any-link { + color: var(--tw-navy, #000330); +} + +header nav .nav-tools a.button:any-link { + background-color: var(--tw-navy, #000330); + border-color: var(--tw-navy, #000330); + color: var(--tw-white, #fff); + border-radius: 40px; +} diff --git a/blocks/hero-promo/_hero-promo.json b/blocks/hero-promo/_hero-promo.json new file mode 100644 index 0000000..7933e73 --- /dev/null +++ b/blocks/hero-promo/_hero-promo.json @@ -0,0 +1,48 @@ +{ + "definitions": [ + { + "title": "Hero Promo", + "id": "hero-promo", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Hero Promo", + "model": "hero-promo" + } + } + } + } + } + ], + "models": [ + { + "id": "hero-promo", + "fields": [ + { + "component": "richtext", + "valueType": "string", + "name": "image", + "label": "Image", + "multi": false + }, + { + "component": "text", + "valueType": "string", + "name": "imageAlt", + "label": "Alt Text", + "value": "" + }, + { + "component": "richtext", + "name": "text", + "value": "", + "label": "Text", + "valueType": "string" + } + ] + } + ], + "filters": [] +} diff --git a/blocks/hero-promo/hero-promo.css b/blocks/hero-promo/hero-promo.css new file mode 100644 index 0000000..38e515f --- /dev/null +++ b/blocks/hero-promo/hero-promo.css @@ -0,0 +1,202 @@ +/* Hero Promo — Total Wireless promotional banner. + Side-by-side text + foreground product image on a teal->mint gradient + card with concentric arc rings and rounded corners. + Values extracted from totalwireless.com/m/home. Mobile-first. */ + +/* Full-bleed wrapper override so the card can span the content width */ +.hero-promo-wrapper { + max-width: 100%; +} + +/* ---- Card container ---- */ +.hero-promo { + display: flex; + flex-direction: column; + gap: 24px; + overflow: hidden; + padding: 40px 24px; + border-radius: 24px; + + /* teal -> mint gradient with concentric arc rings, sampled from the + source background asset (left rgb(79,215,203) -> right rgb(231,252,245)) */ + background-color: rgb(153 235 223); + background-image: + radial-gradient(circle at 88% 50%, rgb(231 252 245) 0%, transparent 22%), + radial-gradient(circle at 88% 50%, rgb(213 248 240) 24%, transparent 40%), + radial-gradient(circle at 88% 50%, rgb(192 244 234) 42%, transparent 58%), + radial-gradient(circle at 88% 50%, rgb(153 235 223) 60%, transparent 76%), + linear-gradient(90deg, rgb(79 215 203) 0%, rgb(153 235 223) 55%, rgb(231 252 245) 100%); +} + +/* ---- Image row (row 1) ---- */ +.hero-promo .hero-promo-image { + display: flex; + align-items: center; + justify-content: center; + order: 2; /* on mobile show image below the text */ +} + +.hero-promo .hero-promo-image > div { + width: 100%; +} + +.hero-promo picture { + display: block; + line-height: 0; +} + +.hero-promo picture img { + display: block; + width: 100%; + height: auto; + max-height: 320px; + object-fit: contain; + + /* The DM-optimized webp flattens the transparent product PNG onto white. + multiply drops the white so the phone floats on the teal card like the + source, without needing a separate transparent asset. */ + mix-blend-mode: multiply; +} + +/* ---- Text row (row 2) ---- */ +.hero-promo .hero-promo-text { + display: flex; + flex-direction: column; + order: 1; /* text first on mobile */ +} + +.hero-promo .hero-promo-text > div { + display: flex; + flex-direction: column; + gap: 24px; + align-items: flex-start; +} + +/* Heading — source uses the geometric-sans brand face (GalanoGrotesque + -> Montserrat), which maps to --body-font-family in this project. */ +.hero-promo h2 { + margin: 0; + font-family: var(--body-font-family); + font-size: 32px; + line-height: 1.2; + font-weight: 700; + letter-spacing: normal; + color: var(--tw-navy); +} + +/* Suppress the global h2::after brand-accent bar inside the hero + (lazy-styles.css adds it to h2 in plain sections; out-specify it). */ +:is(main, article.article-body) > .section .hero-promo.block h2::after { + display: none; + content: none; +} + +/* Subheading / body copy */ +.hero-promo p:not(.button-container) { + margin: 0; + font-family: var(--body-font-family); + font-size: 16px; + line-height: 24px; + font-weight: 400; + color: var(--tw-navy); +} + +/* Group heading + subheading tighter, separate from the CTAs */ +.hero-promo .hero-promo-text > div > :is(h2, p:not(.button-container)) { + margin-bottom: 0; +} + +/* ---- CTAs ---- */ + +/* decorate() wraps the button-containers in .hero-promo-cta so both + buttons sit on one row with the source 24px gap. */ +.hero-promo .hero-promo-cta { + display: flex; + flex-wrap: wrap; + gap: 24px; +} + +.hero-promo p.button-container { + margin: 0; +} + +/* Pill buttons (override brand.css defaults with the source values) */ +.hero-promo a.button:any-link { + margin: 0; + border: none; + border-radius: 9999px; + padding: 11px 23px; + font-family: var(--body-font-family); + font-size: 16px; + font-weight: 700; + line-height: 24px; + letter-spacing: normal; + text-transform: none; + background-color: var(--tw-navy); + background-image: none; + color: var(--tw-white); + box-shadow: none; +} + +.hero-promo a.button:any-link:hover, +.hero-promo a.button:any-link:focus-visible { + transform: none; + box-shadow: none; + background-color: #1a1d52; + color: var(--tw-white); +} + +/* First CTA = primary (navy). Second CTA = secondary (white pill). */ +.hero-promo .hero-promo-cta p.button-container:nth-child(2) a.button:any-link { + background-color: var(--tw-white); + color: rgb(26 28 53); +} + +.hero-promo .hero-promo-cta p.button-container:nth-child(2) a.button:any-link:hover, +.hero-promo .hero-promo-cta p.button-container:nth-child(2) a.button:any-link:focus-visible { + background-color: rgb(240 240 240); + color: rgb(26 28 53); +} + +/* ---- Tablet ---- */ +@media (width >= 600px) { + .hero-promo { + padding: 64px 24px; + } + + .hero-promo h2 { + font-size: 40px; + line-height: 1.2; + } +} + +/* ---- Desktop: side-by-side, text left / image right ---- */ +@media (width >= 900px) { + .hero-promo { + flex-direction: row; + align-items: center; + gap: 0; + padding: 0; + } + + .hero-promo .hero-promo-text { + order: 1; + flex: 0 0 57.55%; + padding: 80px 24px; + } + + .hero-promo .hero-promo-image { + order: 2; + flex: 0 0 42.45%; + align-self: stretch; + } + + .hero-promo picture img { + max-height: 444px; + } + + .hero-promo h2 { + font-size: 48px; + line-height: 60px; + } +} diff --git a/blocks/hero-promo/hero-promo.js b/blocks/hero-promo/hero-promo.js new file mode 100644 index 0000000..87413d2 --- /dev/null +++ b/blocks/hero-promo/hero-promo.js @@ -0,0 +1,26 @@ +/** + * Hero Promo — promotional banner with text (left) and a foreground + * product image (right) on a teal gradient card. + * @param {Element} block The hero-promo block element + */ +export default function decorate(block) { + const rows = [...block.children]; + const imageRow = rows[0]; + const textRow = rows[rows.length - 1]; + + if (imageRow) imageRow.classList.add('hero-promo-image'); + + const textCell = textRow?.querySelector(':scope > div'); + if (!textCell) return; + textRow.classList.add('hero-promo-text'); + + // Group consecutive CTA buttons into a single horizontal row so they + // sit side by side regardless of their individual widths. + const buttons = [...textCell.querySelectorAll(':scope > p.button-container')]; + if (buttons.length > 0) { + const cta = document.createElement('div'); + cta.className = 'hero-promo-cta'; + buttons[0].before(cta); + buttons.forEach((btn) => cta.append(btn)); + } +} diff --git a/blocks/hero-promo/metadata.json b/blocks/hero-promo/metadata.json new file mode 100644 index 0000000..13f00e2 --- /dev/null +++ b/blocks/hero-promo/metadata.json @@ -0,0 +1,38 @@ +{ + "variantName": "hero-promo", + "baseBlock": "hero", + "version": "1.0.0", + "created": "2026-07-14T16:47:35.000Z", + "sourceContext": { + "originUrl": "https://www.totalwireless.com/m/home", + "originPage": "Total Wireless home page", + "blockPosition": "top" + }, + "visualCharacteristics": { + "colorScheme": "accent", + "density": "wide", + "purpose": "promo-hero", + "imagePattern": "large" + }, + "contentPattern": { + "structure": "heading + paragraph + 2 CTAs + product image", + "buttonCount": 2, + "imageCount": 1 + }, + "reuseGuidance": { + "suitableFor": [ + "full-width promotional banners", + "hero offer banners with product imagery", + "campaign landing intros" + ], + "notSuitableFor": [ + "plain text article intros", + "small inline promo tiles (use teaser-promo)" + ] + }, + "usage": { + "pagesUsing": ["/m/home"], + "reuseCount": 2, + "lastUsed": "2026-07-14T16:47:35.000Z" + } +} diff --git a/blocks/product-grid-picks/_product-grid-picks.json b/blocks/product-grid-picks/_product-grid-picks.json new file mode 100644 index 0000000..d685123 --- /dev/null +++ b/blocks/product-grid-picks/_product-grid-picks.json @@ -0,0 +1,34 @@ +{ + "definitions": [ + { + "title": "Product Grid Picks", + "id": "product-grid-picks", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Product Grid Picks", + "model": "product-grid-picks" + } + } + } + } + } + ], + "models": [ + { + "id": "product-grid-picks", + "fields": [ + { + "component": "aem-content", + "name": "index", + "label": "Product Index", + "valueType": "string", + "description": "Link to a product index JSON. Curated picks are rendered as a product row." + } + ] + } + ], + "filters": [] +} diff --git a/blocks/product-grid-picks/metadata.json b/blocks/product-grid-picks/metadata.json new file mode 100644 index 0000000..c737c2e --- /dev/null +++ b/blocks/product-grid-picks/metadata.json @@ -0,0 +1,37 @@ +{ + "variantName": "product-grid-picks", + "baseBlock": "product-grid", + "version": "1.0.0", + "created": "2026-07-14T16:47:35.000Z", + "sourceContext": { + "originUrl": "https://www.totalwireless.com/m/home", + "originPage": "Total Wireless home page", + "blockPosition": "middle" + }, + "visualCharacteristics": { + "colorScheme": "light", + "density": "compact", + "purpose": "product-picks", + "imagePattern": "large" + }, + "contentPattern": { + "structure": "3 product picks: deal badge + thumbnail + name + Select CTA", + "buttonCount": 1, + "imageCount": 3 + }, + "reuseGuidance": { + "suitableFor": [ + "curated phone / product pick rows", + "index-driven 3-up product showcases with deal badges" + ], + "notSuitableFor": [ + "full product catalog listings (use base product-grid)", + "generic content cards (use cards)" + ] + }, + "usage": { + "pagesUsing": ["/m/home"], + "reuseCount": 1, + "lastUsed": "2026-07-14T16:47:35.000Z" + } +} diff --git a/blocks/product-grid-picks/product-grid-picks.css b/blocks/product-grid-picks/product-grid-picks.css new file mode 100644 index 0000000..6d40a83 --- /dev/null +++ b/blocks/product-grid-picks/product-grid-picks.css @@ -0,0 +1,144 @@ +/* ========================================================================== + Product Grid Picks — "All the best phones, all in one place" + Migrated from totalwireless.com/m/home (source: AEM cmp-carousel product + tiles). Source design: borderless, transparent tiles on white, centered + content — badge, large phone image, product name, price, and a red pill + "Select" CTA. Values from source computed styles; see + migration-work/product-grid-picks-extracted.css. + ========================================================================== */ + +.product-grid-picks { + --pgp-red: rgb(238 0 0); + --pgp-navy: #1a1c35; +} + +.product-grid-picks[aria-busy='true'] { + min-height: 300px; + opacity: 0.5; +} + +/* Grid: mobile-first single column; source is a 3-up row on desktop. */ +.product-grid-picks .product-grid-picks-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: 1fr; + gap: 24px; +} + +/* Tile: transparent, borderless, no shadow — centered content. */ +.product-grid-picks .product-grid-picks-item { + position: relative; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + padding: 0; +} + +.product-grid-picks .product-grid-picks-link { + display: flex; + flex-direction: column; + height: 100%; + color: inherit; + text-decoration: none; + text-align: center; +} + +/* Badge / promo tag row. */ +.product-grid-picks .product-grid-picks-badge { + display: block; + font-family: var(--body-font-family); + font-size: 14px; + font-weight: 700; + line-height: 20px; + color: var(--pgp-navy); + text-align: center; + margin-bottom: 30px; +} + +/* Phone image area: centered, fixed height matching source (~300px). */ +.product-grid-picks .product-grid-picks-image { + display: flex; + align-items: center; + justify-content: center; + height: 300px; + line-height: 0; +} + +.product-grid-picks .product-grid-picks-image img { + display: block; + width: auto; + max-width: 100%; + height: 100%; + object-fit: contain; +} + +.product-grid-picks .product-grid-picks-body { + display: flex; + flex-direction: column; + flex-grow: 1; + align-items: center; + gap: 12px; +} + +/* Product name. */ +.product-grid-picks .product-grid-picks-title { + margin: 18px 0 0; + font-family: var(--body-font-family); + font-size: 18px; + font-weight: 700; + line-height: 28px; + color: var(--pgp-navy); + text-align: center; +} + +/* Price. */ +.product-grid-picks .product-grid-picks-price { + margin: 0; + font-family: var(--body-font-family); + font-size: 24px; + font-weight: 700; + line-height: 1.2; + color: var(--tw-text); + text-align: center; +} + +/* Select CTA — red pill matching source. */ +.product-grid-picks .product-grid-picks-cta { + display: block; + margin-top: auto; + align-self: stretch; + background-color: var(--pgp-red); + color: var(--tw-white); + border: 0; + border-radius: 50px; + padding: 11px 30px; + font-family: var(--body-font-family); + font-size: 16px; + font-weight: 700; + line-height: 32px; + letter-spacing: 0.5px; + text-align: center; +} + +.product-grid-picks .product-grid-picks-item:hover .product-grid-picks-cta { + background-color: #c00; +} + +/* Empty / fallback state (shown when the product index can't be loaded). */ +.product-grid-picks .product-grid-picks-empty { + color: var(--tw-text); + font-size: 16px; + text-align: center; + padding: 40px 0; +} + +/* Desktop: 3-up row like the source carousel. */ +@media (width >= 900px) { + .product-grid-picks .product-grid-picks-list { + grid-template-columns: repeat(3, 1fr); + gap: 20px; + } +} diff --git a/blocks/product-grid-picks/product-grid-picks.js b/blocks/product-grid-picks/product-grid-picks.js new file mode 100644 index 0000000..5b428f9 --- /dev/null +++ b/blocks/product-grid-picks/product-grid-picks.js @@ -0,0 +1,92 @@ +/** + * Product Grid Picks block. + * + * Authoring: add a link to a product index JSON in the block. + * | product-grid-picks | + * | /products/phones/index.json | + * + * Fetches the index, resolves image URLs, and renders a curated product pick row. + */ + +import { createOptimizedPicture } from '../../scripts/aem.js'; +import { createTag } from '../../scripts/shared.js'; + +async function fetchProductIndex(url) { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`product-grid-picks: index fetch failed (${resp.status})`); + const json = await resp.json(); + return json?.data ?? []; +} + +function resolveImage(image, indexUrl) { + if (!image) return ''; + try { + return new URL(image, new URL(indexUrl, window.location.origin)).pathname; + } catch { + return image; + } +} + +function formatPrice(price) { + const num = parseFloat(price); + return Number.isFinite(num) ? `$${num.toFixed(2)}` : price; +} + +function buildCard(product, indexUrl) { + const li = createTag('li', { class: 'product-grid-picks-item' }); + const link = createTag('a', { href: product.url, class: 'product-grid-picks-link' }); + + if (product.badge) { + link.append(createTag('span', { class: 'product-grid-picks-badge' }, product.badge)); + } + + if (product.image) { + const src = resolveImage(product.image, indexUrl); + const picture = createOptimizedPicture(src, product.title || '', false, [ + { width: '400' }, + { media: '(min-width: 900px)', width: '600' }, + ]); + link.append(createTag('div', { class: 'product-grid-picks-image' }, picture)); + } + + const body = createTag('div', { class: 'product-grid-picks-body' }); + body.append(createTag('p', { class: 'product-grid-picks-title' }, product.title || product.sku)); + if (product.price) { + body.append(createTag('p', { class: 'product-grid-picks-price' }, formatPrice(product.price))); + } + body.append(createTag('span', { class: 'product-grid-picks-cta' }, 'Select')); + link.append(body); + + li.append(link); + return li; +} + +export default async function decorate(block) { + const anchor = block.querySelector('a[href]'); + const text = block.textContent.trim(); + const indexUrl = anchor + ? anchor.href + : new URL(text, window.location.origin).href; + + if (!indexUrl) return; + + block.textContent = ''; + block.setAttribute('aria-busy', 'true'); + + try { + const products = await fetchProductIndex(indexUrl); + + if (!products.length) { + block.append(createTag('p', { class: 'product-grid-picks-empty' }, 'No products found.')); + return; + } + + const ul = createTag('ul', { class: 'product-grid-picks-list' }); + products.forEach((product) => ul.append(buildCard(product, indexUrl))); + block.append(ul); + } catch { + block.append(createTag('p', { class: 'product-grid-picks-empty' }, 'Unable to load products right now.')); + } finally { + block.removeAttribute('aria-busy'); + } +} diff --git a/blocks/teaser-promo/_teaser-promo.json b/blocks/teaser-promo/_teaser-promo.json new file mode 100644 index 0000000..5987735 --- /dev/null +++ b/blocks/teaser-promo/_teaser-promo.json @@ -0,0 +1,48 @@ +{ + "definitions": [ + { + "title": "Teaser Promo", + "id": "teaser-promo", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Teaser Promo", + "model": "teaser-promo" + } + } + } + } + } + ], + "models": [ + { + "id": "teaser-promo", + "fields": [ + { + "component": "richtext", + "valueType": "string", + "name": "image", + "label": "Image", + "multi": false + }, + { + "component": "text", + "valueType": "string", + "name": "imageAlt", + "label": "Alt Text", + "value": "" + }, + { + "component": "richtext", + "name": "text", + "label": "Text", + "valueType": "string", + "description": "Promo heading, supporting text and a CTA link (e.g. 'Learn more')." + } + ] + } + ], + "filters": [] +} diff --git a/blocks/teaser-promo/metadata.json b/blocks/teaser-promo/metadata.json new file mode 100644 index 0000000..a95448d --- /dev/null +++ b/blocks/teaser-promo/metadata.json @@ -0,0 +1,37 @@ +{ + "variantName": "teaser-promo", + "baseBlock": "teaser", + "version": "1.0.0", + "created": "2026-07-14T16:47:35.000Z", + "sourceContext": { + "originUrl": "https://www.totalwireless.com/m/home", + "originPage": "Total Wireless home page", + "blockPosition": "middle" + }, + "visualCharacteristics": { + "colorScheme": "light", + "density": "wide", + "purpose": "promo-banner", + "imagePattern": "large" + }, + "contentPattern": { + "structure": "heading + paragraph + CTA + lifestyle/flag image", + "buttonCount": 1, + "imageCount": 1 + }, + "reuseGuidance": { + "suitableFor": [ + "single promotional banner tiles with an image and CTA", + "feature callouts (international calling, money transfer, etc.)" + ], + "notSuitableFor": [ + "text-only teasers (use base teaser)", + "two-tile promo layouts (use columns-promo)" + ] + }, + "usage": { + "pagesUsing": ["/m/home"], + "reuseCount": 2, + "lastUsed": "2026-07-14T16:47:35.000Z" + } +} diff --git a/blocks/teaser-promo/teaser-promo.css b/blocks/teaser-promo/teaser-promo.css new file mode 100644 index 0000000..d3c6e9b --- /dev/null +++ b/blocks/teaser-promo/teaser-promo.css @@ -0,0 +1,154 @@ +/* ========================================================================== + Teaser Promo -- full-width navy rounded CTA banner. + Layout (desktop): [circular image] [heading + supporting text] ...... [teal pill CTA] + Values extracted from computed styles of totalwireless.com/m/home + (".cta-banner-wrapper" -- the Mexico international-calling banner). + ========================================================================== */ + +/* local tokens (exact source values; brand navy is #000330 but the banner + uses the lighter #1a1c35 navy, and the CTA uses the brand teal accent). */ +main .teaser-promo { + --teaser-promo-navy: #1a1c35; + --teaser-promo-teal: #00c8b7; + + background-color: var(--teaser-promo-navy); + color: var(--tw-white); + border-radius: 16px; + padding: 24px; + margin: 0; + + /* mobile-first: stack image, text, CTA vertically */ + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + overflow: hidden; +} + +/* rows (image row + text row) become flex items */ +main .teaser-promo > div { + display: flex; + align-items: center; + width: 100%; +} + +/* image cell */ +main .teaser-promo .teaser-promo-img-col { + flex: 0 0 auto; + line-height: 0; +} + +main .teaser-promo .teaser-promo-img-col picture, +main .teaser-promo .teaser-promo-img-col img { + display: block; + width: 96px; + height: 96px; + object-fit: cover; + border-radius: 50%; +} + +/* text cell wrapper (row 2) */ +main .teaser-promo > div:last-child { + flex-direction: column; + align-items: flex-start; +} + +main .teaser-promo > div:last-child > div { + width: 100%; +} + +/* heading */ +main .teaser-promo h2 { + font-size: 24px; + font-weight: 700; + font-family: var(--heading-font-family); + line-height: 1.27; + color: var(--tw-white); + margin: 0 0 8px; +} + +/* supporting text */ +main .teaser-promo p { + font-size: 16px; + font-weight: 400; + line-height: 1.5; + color: var(--tw-white); + margin: 0; +} + +/* CTA -- teal pill. Overrides the global navy button styling. */ +main .teaser-promo .button-container { + margin: 16px 0 0; +} + +main .teaser-promo a.button:any-link { + background-color: var(--teaser-promo-teal); + background-image: none; + border: 0; + border-radius: 50px; + padding: 11px 30px; + font-size: 16px; + font-weight: 700; + color: var(--teaser-promo-navy); + text-decoration: none; + text-transform: none; + letter-spacing: normal; + white-space: nowrap; +} + +main .teaser-promo a.button:any-link:hover, +main .teaser-promo a.button:any-link:focus-visible { + background-color: #00b3a3; + border-color: transparent; + color: var(--teaser-promo-navy); + box-shadow: none; + transform: none; +} + +/* ---- Desktop: single horizontal bar, CTA pushed to the far right ---- */ +@media (width >= 900px) { + main .teaser-promo { + flex-direction: row; + align-items: center; + gap: 24px; + padding: 0 24px; + min-height: 124px; + } + + /* image row shrinks to content on desktop (don't stretch full width) */ + main .teaser-promo > div:first-child { + flex: 0 0 auto; + width: auto; + } + + /* text row grows to fill, its inner content lays out horizontally so the + CTA sits at the right edge, vertically centered with the heading. */ + main .teaser-promo > div:last-child { + flex: 1 1 auto; + flex-direction: row; + align-items: center; + } + + main .teaser-promo > div:last-child > div { + display: flex; + flex-direction: row; + align-items: center; + gap: 24px; + } + + /* copy stack (heading + supporting text) fills available space */ + main .teaser-promo .teaser-promo-body { + flex: 1 1 auto; + } + + main .teaser-promo h2 { + margin: 0; + font-size: 30px; + line-height: 1.27; + } + + main .teaser-promo .button-container { + margin: 0 0 0 auto; + flex: 0 0 auto; + } +} diff --git a/blocks/teaser-promo/teaser-promo.js b/blocks/teaser-promo/teaser-promo.js new file mode 100644 index 0000000..c6d1869 --- /dev/null +++ b/blocks/teaser-promo/teaser-promo.js @@ -0,0 +1,30 @@ +/** + * Teaser Promo -- full-width navy CTA banner with a circular image, a + * heading + supporting text, and a teal pill CTA pushed to the right. + * + * Authored as two rows (image row, text row). This groups the heading and + * supporting copy into a `.teaser-promo-body` wrapper so the desktop layout + * can place the copy stack and the CTA side by side. + */ +export default function decorate(block) { + // mark the image-only column + block.querySelectorAll(':scope > div > div').forEach((col) => { + const pic = col.querySelector('picture'); + if (pic && col.children.length === 1) { + col.classList.add('teaser-promo-img-col'); + } + }); + + // group heading + supporting text (everything before the CTA) into a body + const textCell = [...block.querySelectorAll(':scope > div > div')] + .find((col) => col.querySelector('h2, h3')); + if (textCell) { + const cta = textCell.querySelector('.button-container'); + const body = document.createElement('div'); + body.className = 'teaser-promo-body'; + [...textCell.children].forEach((child) => { + if (child !== cta) body.append(child); + }); + textCell.prepend(body); + } +} diff --git a/scripts/aem.js b/scripts/aem.js index f6a4a49..4697ca4 100644 --- a/scripts/aem.js +++ b/scripts/aem.js @@ -316,6 +316,13 @@ function createOptimizedPicture( eager = false, breakpoints = [{ media: '(min-width: 600px)', width: '2000' }, { width: '750' }], ) { + // --- BEGIN DM dispatch (excat-generated) --- + if (typeof window.__dmRender__ === 'function') { + const dmPicture = window.__dmRender__(src, alt); + if (dmPicture) return dmPicture; + } + // --- END DM dispatch (excat-generated) --- + const url = !src.startsWith('http') ? new URL(src, window.location.href) : new URL(src); const picture = document.createElement('picture'); const { origin, pathname } = url; diff --git a/scripts/scripts.js b/scripts/scripts.js index 1f9c6b1..607e263 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -31,6 +31,225 @@ const AUDIENCES = { desktop: () => window.innerWidth >= 600, }; +// --- BEGIN DM/Scene7 auto-block (excat-generated) --- + +const DM_BREAKPOINTS = [ + { media: '(min-width: 600px)', width: 2000 }, // desktop + { width: 750 }, // mobile / fallback (no media) +]; + +// ---- Canonical helpers (keep in sync with dm-scene7-helpers.js) ---- +function detectDynamicMediaUrl(urlStr) { + // Reject relative URLs up front — without this guard, the auto-block + // scans every anchor in

and a normal site link like + // `` would be classified as DM and replaced by + // a . Keep byte-identical with dm-scene7-helpers.js. + if (!/^(https?:\/\/|\/\/)/i.test(urlStr)) return false; + let u; + try { u = new URL(urlStr, 'https://x/'); } catch { return false; } + // Scene7 detected by path alone — hostname is irrelevant because + // customer sites routinely CNAME a vanity domain to Scene7 (e.g. + // media-assets.brand.example). + if (u.pathname.startsWith('/is/image/')) { + return 'scene7'; + } + if (/^delivery-p\d+-e\d+\.adobeaemcloud\.com$/.test(u.hostname) + && u.pathname.startsWith('/adobe/assets/urn:')) { + return 'dm-openapi'; + } + return false; +} + +function buildScene7Rendition(src, { width, format }) { + // Manipulate the query string verbatim — URL.searchParams percent- + // encodes `$`, but Scene7's IS/Image template-parameter syntax + // (`$image=`, `$badge=`, etc.) requires the literal `$`. Encoded + // form is silently dropped by Scene7's parser, returning the bare + // template image instead of the personalized composite. + const normalized = src.startsWith('//') ? `https:${src}` : src; + const qIdx = normalized.indexOf('?'); + const base = qIdx >= 0 ? normalized.slice(0, qIdx) : normalized; + const query = qIdx >= 0 ? normalized.slice(qIdx + 1) : ''; + const pairs = query.split('&').filter((p) => p); + const filtered = pairs.filter((p) => { + const k = p.split('=')[0]; + return k !== 'wid' && k !== 'fmt'; + }); + filtered.push(`wid=${width}`); + filtered.push(`fmt=${format}`); + return `${base}?${filtered.join('&')}`; +} + +function buildDmOpenApiRendition(src, { width }) { + // Synthetic base — see buildScene7Rendition above. + const url = new URL(src, 'https://x/'); + url.searchParams.set('width', String(width)); + return url.toString(); +} + +function findDmOnAnchor(a) { + if (!a || typeof a.getAttribute !== 'function') return null; + const href = a.getAttribute('href') || ''; + if (detectDynamicMediaUrl(href)) return { mode: 'unlinked', dmUrl: href }; + const title = a.getAttribute('title') || ''; + if (detectDynamicMediaUrl(title)) return { mode: 'linked', dmUrl: title }; + return null; +} + +// True when the given anchor is the sole child of a markdown-generated +//

wrapper that should be unwrapped so the picture becomes a top- +// level grid cell. P only — NEVER DIV: EDS block content uses

+// cells (cards/carousel/columns decorators detect image cells via +// `div.querySelector('picture')`); unwrapping a
collapses the +// block's row structure and stops images rendering inside blocks. +// Text-node guard:

caption alt

must NOT be +// treated as unwrappable — replacing the parent would delete "caption". +// Comparing trimmed textContent of

against the anchor's catches this. +function isUnwrappableMarkdownParagraph(anchor) { + const parent = anchor && anchor.parentElement; + if (!parent || parent.tagName !== 'P') return false; + if (parent.children.length !== 1 || parent.firstElementChild !== anchor) return false; + return parent.textContent.trim() === anchor.textContent.trim(); +} + +// Sentinel used by the transformer when source alt is empty. Document +// view shows the visible cue; we translate it back to alt="" here so screen +// readers correctly skip decorative images. If an author edits the link +// text away from the sentinel, their edit becomes the real alt — a11y +// improves. Must stay byte-identical to dm-scene7-helpers.js EMPTY_ALT_SENTINEL. +const EMPTY_ALT_SENTINEL = 'Image without alt text'; + +function linkTextToAlt(linkText) { + return linkText === EMPTY_ALT_SENTINEL ? '' : linkText; +} + +// ---- Rendering ---- +function appendSource(picture, { type, srcset, media }) { + const source = document.createElement('source'); + if (type) source.type = type; + source.srcset = srcset; + if (media) source.setAttribute('media', media); + picture.append(source); +} + +function renderScene7Picture(src, alt) { + const picture = document.createElement('picture'); + DM_BREAKPOINTS.forEach((bp) => appendSource(picture, { + type: 'image/webp', + srcset: buildScene7Rendition(src, { width: bp.width, format: 'webp' }), + media: bp.media, + })); + DM_BREAKPOINTS.forEach((bp) => appendSource(picture, { + type: 'image/jpeg', + srcset: buildScene7Rendition(src, { width: bp.width, format: 'jpg' }), + media: bp.media, + })); + const img = document.createElement('img'); + img.src = buildScene7Rendition(src, { width: 750, format: 'jpg' }); + img.alt = alt; + img.loading = 'lazy'; + picture.append(img); + return picture; +} + +function renderDmOpenApiPicture(src, alt) { + const picture = document.createElement('picture'); + DM_BREAKPOINTS.forEach((bp) => appendSource(picture, { + srcset: buildDmOpenApiRendition(src, { width: bp.width }), + media: bp.media, + })); + const img = document.createElement('img'); + img.src = buildDmOpenApiRendition(src, { width: 750 }); + img.alt = alt; + img.loading = 'lazy'; + picture.append(img); + return picture; +} + +function buildDynamicMediaImages(main) { + // Anchors carrying DM URLs from the markdown round-trip. The transformer + // turns into (or + // for the linked case); CommonMark's [text](url "title") syntax + // survives docx and the title attribute round-trips back to a real + // HTML attribute at render time. + main.querySelectorAll('a').forEach((a) => { + const match = findDmOnAnchor(a); + if (!match) return; + + const { mode, dmUrl } = match; + // Translate link text back to alt: sentinel ('Image without alt text') + // means the source had alt="" — render with alt="" for a11y. Any other + // text (including the author's edit of the placeholder) is real alt. + const alt = linkTextToAlt(a.textContent.trim()); + const picture = detectDynamicMediaUrl(dmUrl) === 'scene7' + ? renderScene7Picture(dmUrl, alt) + : renderDmOpenApiPicture(dmUrl, alt); + + // decorateMain() calls decorateButtons() BEFORE buildAutoBlocks(). At + // that point every DM anchor (linked or unlinked) looks like a plain + // text link — no yet — so decorateButtons promotes it to a button + // and adds `button-container` to its sole-child

/

parent. The + // unwanted border around the rebuilt is the visible symptom; + // for unlinked-in-
the leftover `button-container` on a block-cell + //
can also confuse block decorators that filter on classList. + // Strip both classes BEFORE rebuilding so the cleanup covers every + // branch below (replaceChildren / replaceWith / parent-replaceWith). + // Idempotent — no-op when the classes aren't present. + a.classList.remove('button', 'primary', 'secondary'); + if (a.classList.length === 0) a.removeAttribute('class'); + const buttonContainer = a.parentElement; + if ( + buttonContainer + && buttonContainer.classList.contains('button-container') + && buttonContainer.children.length === 1 + ) { + buttonContainer.classList.remove('button-container'); + if (buttonContainer.classList.length === 0) buttonContainer.removeAttribute('class'); + } + + if (mode === 'linked') { + // Keep the outer and its navigation href. Drop the DM URL from title + // (it's been consumed) and replace the anchor's content with the picture. + a.removeAttribute('title'); + a.replaceChildren(picture); + return; + } + + // Unlinked: the whole anchor is just a carrier for the DM URL. + // If it's the markdown-generated

wrapper around a standalone + // image, unwrap so the picture becomes a top-level grid cell. + // NEVER unwrap

— those are block-content cells (cards, + // carousel, columns); unwrapping them collapses the block's row + // structure and decorators can't find their image cells. + if (isUnwrappableMarkdownParagraph(a)) { + a.parentElement.replaceWith(picture); + } else { + a.replaceWith(picture); + } + }); +} + +// Register the DM dispatcher for createOptimizedPicture interop. +// The aem.js patch checks for this hook and delegates DM URLs to our +// renderer, so block decorators that call createOptimizedPicture(img.src, ...) +// on Scene7 IS/Image template URLs or DM Open API URLs preserve their +// query parameters instead of having them stripped by the path-only +// optimizer in aem.js. No-op when the auto-block is not installed +// (hook unregistered → aem.js falls through to standard logic). +// +// Returning null for non-DM URLs lets the caller (createOptimizedPicture) +// fall through to its standard path-only optimization. This is the +// regression guard for non-DM images on the same page. +window.__dmRender__ = (src, alt) => { + const family = detectDynamicMediaUrl(src); + if (!family) return null; + return family === 'scene7' + ? renderScene7Picture(src, alt) + : renderDmOpenApiPicture(src, alt); +}; + +// --- END DM/Scene7 auto-block --- + function getExperimentationContext() { return { getAllMetadata, getMetadata, loadCSS, loadScript, sampleRUM, toCamelCase, toClassName, @@ -126,6 +345,7 @@ async function loadFragments(section) { function buildAutoBlocks(main) { try { buildEmbedBlocks(main); + buildDynamicMediaImages(main); } catch (error) { console.error('Auto Blocking failed', error); } diff --git a/styles/brand.css b/styles/brand.css new file mode 100644 index 0000000..85cd544 --- /dev/null +++ b/styles/brand.css @@ -0,0 +1,127 @@ +/* ========================================================================== + Total Wireless brand tokens (migrated from totalwireless.com/m/home) + Overrides the boilerplate design tokens with the source site's palette, + typography, and spacing. GalanoGrotesque is proprietary; Montserrat + (already bundled) is the closest geometric-sans fallback. + ========================================================================== */ + +:root, +:host { + /* brand palette */ + --tw-navy: #000330; + --tw-cream: #fff1cc; + --tw-text: #202020; + --tw-white: #fff; + + /* fonts — GalanoGrotesque with Montserrat fallback */ + --heading-font-family: "GalanoGrotesque", montserrat, "Trebuchet MS", sans-serif; + --body-font-family: "GalanoGrotesque", montserrat, "Trebuchet MS", sans-serif; + --font-family: var(--body-font-family); + --font-family-serif: var(--heading-font-family); + + /* semantic colors mapped to brand */ + --background-color: var(--tw-white); + --text-color: var(--tw-text); + --link-color: var(--tw-navy); + --link-hover-color: var(--tw-navy); + --color-text: var(--tw-text); + --color-link: var(--tw-navy); + --color-link-hover: var(--tw-navy); + --color-brand: var(--tw-navy); + --color-accent: var(--tw-cream); + --color-light: var(--tw-white); + --color-dark: var(--tw-navy); + --color-shaded: var(--tw-cream); + --light-color: var(--tw-cream); + --dark-color: var(--tw-navy); + + /* heading sizes (desktop values from source) */ + --heading-font-size-xxl: 48px; + --heading-font-size-xl: 48px; + --heading-font-size-l: 24px; + --heading-font-size-m: 20px; + --heading-font-size-s: 18px; + --heading-font-size-xs: 16px; + --heading-line-height: 1.25; + + /* body */ + --body-font-size-m: 16px; + --body-line-height: 1.5; + + /* spacing */ + --section-padding: 24px; + --nav-height: 64px; +} + +@media (width >= 600px) { + :root, + :host { + --heading-font-size-xxl: 48px; + --heading-font-size-xl: 48px; + } +} + +/* ========================================================================== + Brand button + link overrides (Total Wireless: solid navy pill buttons, + navy underlined links). Overrides the boilerplate rust/gold gradient + buttons defined in styles.css. + ========================================================================== */ + +main a.button:any-link, +main button.button { + border: 1px solid var(--tw-navy); + border-radius: 40px; + padding: 14px 32px; + font-weight: 700; + font-size: 16px; + letter-spacing: normal; + text-transform: none; + background-color: var(--tw-navy); + background-image: none; + color: var(--tw-white); +} + +main a.button.primary:any-link, +main button.button.primary { + border-color: var(--tw-navy); + background-color: var(--tw-navy); + color: var(--tw-white); +} + +main a.button:any-link:hover, +main a.button:any-link:focus-visible, +main button.button:hover, +main button.button:focus-visible, +main a.button.primary:hover, +main a.button.primary:focus-visible { + background-color: #1a1d52; + border-color: #1a1d52; + color: var(--tw-white); + box-shadow: none; + transform: none; +} + +/* Secondary = outline navy */ +main a.button.secondary:any-link, +main button.button.secondary { + border: 1.5px solid var(--tw-navy); + background-color: transparent; + color: var(--tw-navy); +} + +main a.button.secondary:hover, +main a.button.secondary:focus-visible, +main button.button.secondary:hover, +main button.button.secondary:focus-visible { + border-color: var(--tw-navy); + background-color: rgb(0 3 48 / 6%); + color: var(--tw-navy); + transform: none; + box-shadow: none; +} + +/* Default (non-block) links */ +main a:not(.button):any-link { + color: var(--tw-navy); +} + diff --git a/styles/styles.css b/styles/styles.css index a8f1304..1bf20f3 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -1,3 +1,5 @@ +@import url('brand.css'); + /* * Copyright 2020 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); @@ -730,3 +732,24 @@ main > .section.warm { background-clip: text; color: transparent; } + +/* ========================================================================== + Total Wireless brand token re-assertion. + styles.css :root (above) redefines --heading-font-family etc. AFTER the + @import 'brand.css', so brand values are re-declared on html:root — + higher specificity than the plain :root above AND later in source, so it + wins. Keep in sync with brand.css. + ========================================================================== */ +html:root { + --heading-font-family: "GalanoGrotesque", montserrat, "Trebuchet MS", sans-serif; + --body-font-family: "GalanoGrotesque", montserrat, "Trebuchet MS", sans-serif; + --font-family: var(--body-font-family); + --font-family-serif: var(--heading-font-family); + --text-color: var(--tw-text); + --link-color: var(--tw-navy); + --link-hover-color: var(--tw-navy); + --background-color: var(--tw-white); + --color-text: var(--tw-text); + --color-link: var(--tw-navy); + --color-link-hover: var(--tw-navy); +} diff --git a/tools/importer/import-home.js b/tools/importer/import-home.js new file mode 100644 index 0000000..c908f27 --- /dev/null +++ b/tools/importer/import-home.js @@ -0,0 +1,220 @@ +/* eslint-disable */ +/* global WebImporter */ + +// PARSER IMPORTS +import heroPromoParser from './parsers/hero-promo.js'; +import columnsPromoParser from './parsers/columns-promo.js'; +import cardsOptionsParser from './parsers/cards-options.js'; +import columnsBenefitsParser from './parsers/columns-benefits.js'; +import cardsPlansParser from './parsers/cards-plans.js'; +import teaserPromoParser from './parsers/teaser-promo.js'; +import productGridPicksParser from './parsers/product-grid-picks.js'; +import cardsBentoParser from './parsers/cards-bento.js'; +import faqHomeParser from './parsers/faq-home.js'; + +// TRANSFORMER IMPORTS +import cleanupTransformer from './transformers/totalwireless-cleanup.js'; +import sectionsTransformer from './transformers/totalwireless-sections.js'; +import dmImagesTransformer from './transformers/totalwireless-dm-images.js'; + +// PARSER REGISTRY +const parsers = { + 'hero-promo': heroPromoParser, + 'columns-promo': columnsPromoParser, + 'cards-options': cardsOptionsParser, + 'columns-benefits': columnsBenefitsParser, + 'cards-plans': cardsPlansParser, + 'teaser-promo': teaserPromoParser, + 'product-grid-picks': productGridPicksParser, + 'cards-bento': cardsBentoParser, + 'faq-home': faqHomeParser, +}; + +// PAGE TEMPLATE CONFIGURATION - Embedded from page-templates.json +const PAGE_TEMPLATE = { + name: 'home', + description: 'Total Wireless mobile home page - hero, plan/product grids, feature promos, and supporting content sections', + urls: [ + 'https://www.totalwireless.com/m/home', + ], + blocks: [ + { + name: 'hero-promo', + instances: [ + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(4)', + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(21)', + ], + }, + { + name: 'columns-promo', + instances: [ + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(6)', + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(19)', + ], + }, + { + name: 'cards-options', + instances: [ + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(8)', + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(28)', + ], + }, + { + name: 'columns-benefits', + instances: [ + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(10)', + ], + }, + { + name: 'cards-plans', + instances: [ + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(12)', + ], + }, + { + name: 'teaser-promo', + instances: [ + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(14)', + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(23)', + ], + }, + { + name: 'product-grid-picks', + instances: [ + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(17)', + ], + }, + { + name: 'cards-bento', + instances: [ + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(24)', + ], + }, + { + name: 'faq-home', + instances: [ + 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(27)', + ], + }, + ], + sections: [ + { id: 'section-1', name: 'section-1', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(4)', style: null, blocks: ['hero-promo'], defaultContent: [] }, + { id: 'section-2', name: 'section-2', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(6)', style: null, blocks: ['columns-promo'], defaultContent: [] }, + { id: 'section-3', name: 'section-3', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(8)', style: null, blocks: ['cards-options'], defaultContent: [] }, + { id: 'section-4', name: 'section-4', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(10)', style: null, blocks: ['columns-benefits'], defaultContent: [] }, + { id: 'section-5', name: 'section-5', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(12)', style: null, blocks: ['cards-plans'], defaultContent: [] }, + { id: 'section-6', name: 'section-6', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(14)', style: null, blocks: ['teaser-promo'], defaultContent: [] }, + { id: 'section-7', name: 'section-7', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn:nth-child(15)', style: null, blocks: [], defaultContent: ['body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn:nth-child(15)'] }, + { id: 'section-8', name: 'section-8', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(17)', style: null, blocks: ['product-grid-picks'], defaultContent: [] }, + { id: 'section-9', name: 'section-9', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(19)', style: null, blocks: ['columns-promo'], defaultContent: [] }, + { id: 'section-10', name: 'section-10', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(21)', style: null, blocks: ['hero-promo'], defaultContent: [] }, + { id: 'section-11', name: 'section-11', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(23)', style: null, blocks: ['teaser-promo'], defaultContent: [] }, + { id: 'section-12', name: 'section-12', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(24)', style: null, blocks: ['cards-bento'], defaultContent: [] }, + { id: 'section-13', name: 'section-13', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(26)', style: null, blocks: [], defaultContent: ['body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(26)'] }, + { id: 'section-14', name: 'section-14', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(27)', style: null, blocks: ['faq-home'], defaultContent: [] }, + { id: 'section-15', name: 'section-15', selector: 'body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(28)', style: null, blocks: ['cards-options'], defaultContent: [] }, + ], +}; + +// TRANSFORMER REGISTRY +// cleanup runs first (beforeTransform), sections + dm-images run in afterTransform. +const transformers = [ + cleanupTransformer, + ...(PAGE_TEMPLATE.sections && PAGE_TEMPLATE.sections.length > 1 ? [sectionsTransformer] : []), + dmImagesTransformer, +]; + +/** + * Execute all page transformers for a specific hook + */ +function executeTransformers(hookName, element, payload) { + const enhancedPayload = { ...payload, template: PAGE_TEMPLATE }; + transformers.forEach((transformerFn) => { + try { + transformerFn.call(null, hookName, element, enhancedPayload); + } catch (e) { + console.error(`Transformer failed at ${hookName}:`, e); + } + }); +} + +/** + * Find all blocks on the page based on the embedded template configuration + */ +function findBlocksOnPage(document, template) { + const pageBlocks = []; + template.blocks.forEach((blockDef) => { + blockDef.instances.forEach((selector) => { + const elements = document.querySelectorAll(selector); + if (elements.length === 0) { + console.warn(`Block "${blockDef.name}" selector not found: ${selector}`); + } + elements.forEach((element) => { + pageBlocks.push({ + name: blockDef.name, + selector, + element, + section: blockDef.section || null, + }); + }); + }); + }); + console.log(`Found ${pageBlocks.length} block instances on page`); + return pageBlocks; +} + +export default { + transform: (payload) => { + const { + document, url, html, params, + } = payload; + + const main = document.body; + + // 1. beforeTransform (initial cleanup) + executeTransformers('beforeTransform', main, payload); + + // 2. Find blocks on page + const pageBlocks = findBlocksOnPage(document, PAGE_TEMPLATE); + + // 3. Parse each block using registered parsers + pageBlocks.forEach((block) => { + if (!block.element.parentNode) return; // Already replaced by earlier parser + const parser = parsers[block.name]; + if (parser) { + try { + parser(block.element, { document, url, params }); + } catch (e) { + console.error(`Failed to parse ${block.name} (${block.selector}):`, e); + } + } else { + console.warn(`No parser found for block: ${block.name}`); + } + }); + + // 4. afterTransform (section breaks + DM image anchors + final cleanup) + executeTransformers('afterTransform', main, payload); + + // 5. WebImporter built-in rules + const hr = document.createElement('hr'); + main.appendChild(hr); + WebImporter.rules.createMetadata(main, document); + WebImporter.rules.transformBackgroundImages(main, document); + WebImporter.rules.adjustImageUrls(main, url, params.originalURL); + + // 6. Generate sanitized path + const path = WebImporter.FileUtils.sanitizePath( + new URL(params.originalURL).pathname.replace(/\/$/, '').replace(/\.html$/, ''), + ); + + return [{ + element: main, + path, + report: { + title: document.title, + template: PAGE_TEMPLATE.name, + blocks: pageBlocks.map((b) => b.name), + }, + }]; + }, +}; diff --git a/tools/importer/page-templates.json b/tools/importer/page-templates.json new file mode 100644 index 0000000..8dc501d --- /dev/null +++ b/tools/importer/page-templates.json @@ -0,0 +1,228 @@ +{ + "metadata": { + "projectType": "xwalk" + }, + "templates": [ + { + "name": "home", + "urls": [ + "https://www.totalwireless.com/m/home" + ], + "representativeUrl": "https://www.totalwireless.com/m/home", + "coverageGaps": [], + "description": "Total Wireless mobile home page - hero, plan/product grids, feature promos, and supporting content sections", + "blocks": [ + { + "name": "hero-promo", + "instances": [ + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(4)", + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(21)" + ] + }, + { + "name": "columns-promo", + "instances": [ + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(6)", + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(19)" + ] + }, + { + "name": "cards-options", + "instances": [ + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(8)", + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(28)" + ] + }, + { + "name": "columns-benefits", + "instances": [ + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(10)" + ] + }, + { + "name": "cards-plans", + "instances": [ + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(12)" + ] + }, + { + "name": "teaser-promo", + "instances": [ + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(14)", + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(23)" + ] + }, + { + "name": "product-grid-picks", + "instances": [ + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(17)" + ] + }, + { + "name": "cards-bento", + "instances": [ + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(24)" + ] + }, + { + "name": "faq-home", + "instances": [ + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(27)" + ] + } + ], + "sections": [ + { + "id": "section-1", + "name": "section-1", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(4)", + "style": null, + "blocks": [ + "hero-promo" + ], + "defaultContent": [] + }, + { + "id": "section-2", + "name": "section-2", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(6)", + "style": null, + "blocks": [ + "columns-promo" + ], + "defaultContent": [] + }, + { + "id": "section-3", + "name": "section-3", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(8)", + "style": null, + "blocks": [ + "cards-options" + ], + "defaultContent": [] + }, + { + "id": "section-4", + "name": "section-4", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(10)", + "style": null, + "blocks": [ + "columns-benefits" + ], + "defaultContent": [] + }, + { + "id": "section-5", + "name": "section-5", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(12)", + "style": null, + "blocks": [ + "cards-plans" + ], + "defaultContent": [] + }, + { + "id": "section-6", + "name": "section-6", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(14)", + "style": null, + "blocks": [ + "teaser-promo" + ], + "defaultContent": [] + }, + { + "id": "section-7", + "name": "section-7", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn:nth-child(15)", + "style": null, + "blocks": [], + "defaultContent": [ + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn:nth-child(15)" + ] + }, + { + "id": "section-8", + "name": "section-8", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(17)", + "style": null, + "blocks": [ + "product-grid-picks" + ], + "defaultContent": [] + }, + { + "id": "section-9", + "name": "section-9", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(19)", + "style": null, + "blocks": [ + "columns-promo" + ], + "defaultContent": [] + }, + { + "id": "section-10", + "name": "section-10", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(21)", + "style": null, + "blocks": [ + "hero-promo" + ], + "defaultContent": [] + }, + { + "id": "section-11", + "name": "section-11", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(23)", + "style": null, + "blocks": [ + "teaser-promo" + ], + "defaultContent": [] + }, + { + "id": "section-12", + "name": "section-12", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(24)", + "style": null, + "blocks": [ + "cards-bento" + ], + "defaultContent": [] + }, + { + "id": "section-13", + "name": "section-13", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(26)", + "style": null, + "blocks": [], + "defaultContent": [ + "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(26)" + ] + }, + { + "id": "section-14", + "name": "section-14", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(27)", + "style": null, + "blocks": [ + "faq-home" + ], + "defaultContent": [] + }, + { + "id": "section-15", + "name": "section-15", + "selector": "body > div.page-content > div.root.container.responsivegrid > div.cmp-container > div.aem-Grid > div.container.responsivegrid.aem-GridColumn > div.cmp-container > div.aem-Grid > div.experiencefragment.aem-GridColumn:nth-child(28)", + "style": null, + "blocks": [ + "cards-options" + ], + "defaultContent": [] + } + ] + } + ] +} diff --git a/tools/importer/parsers/cards-bento.js b/tools/importer/parsers/cards-bento.js new file mode 100644 index 0000000..bf74938 --- /dev/null +++ b/tools/importer/parsers/cards-bento.js @@ -0,0 +1,108 @@ +/* eslint-disable */ +/* global WebImporter */ +/** + * Parser for cards-bento. Base block: cards (block/v1/block container). + * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / bento-box cards) + * Model (blocks/cards-bento/_cards-bento.json): + * cards-bento (container) → cards-bento-item { image (reference), imageAlt (collapsed), text (richtext) }. + * Library convention: each card row has TWO cells — cell 1 = image (image/imageAlt), + * cell 2 = text (leading label paragraph, heading, description, CTA link). Empty cell still present. + * + * Each ".bento-box-card" is one card: optional image + eyebrow label + title + description + CTA. + * A leading section heading ("Get more with Total Wireless") is emitted as default content. + */ +export default function parse(element, { document }) { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + + // Exact card containers: class token "bento-box-card" (not the __image/__content/-wrapper variants). + let items = [...element.querySelectorAll('[class~="bento-box-card"]')]; + if (!items.length) { + // Fallback: leaf elements whose class merely contains the token. + items = [...element.querySelectorAll('[class*="bento-box-card"]')] + .filter((el) => !el.querySelector('[class*="bento-box-card"]')); + } + if (!items.length) { + element.replaceWith(...element.childNodes); + return; + } + + const cells = []; + const usedHeadings = new Set(); + const seen = new Set(); + + items.forEach((item) => { + const seenImg = new Set(); + let image = null; + item.querySelectorAll('img').forEach((img) => { + const k = (img.getAttribute('src') || '').split('?')[0]; + if (k && !seenImg.has(k)) { seenImg.add(k); if (!image) image = img; } + }); + + const eyebrow = item.querySelector('[class*="eyebrow"]'); + const headingEl = [...item.querySelectorAll('h1, h2, h3, h4, h5, h6')].find((h) => norm(h.textContent)) + || item.querySelector('[class*="card-title"]'); + const descEl = item.querySelector('[class*="description"]'); + const link = item.querySelector('a[href]'); + + const eyebrowText = norm(eyebrow && eyebrow.textContent); + const headingText = norm(headingEl && headingEl.textContent); + const descText = norm(descEl && descEl.textContent); + + if (!headingText && !eyebrowText && !image) return; + + // Dedupe responsive duplicates. + const key = `${eyebrowText}|${headingText}|${descText}`; + if (seen.has(key)) return; + seen.add(key); + if (headingText) usedHeadings.add(headingText); + + // Cell 1 — image (reference). Empty cell (no hint) if no image. + const imageCell = document.createDocumentFragment(); + if (image) { + imageCell.appendChild(document.createComment(' field:image ')); + imageCell.appendChild(image); + } + + // Cell 2 — text (richtext): eyebrow label, heading, description, CTA. + const textCell = document.createDocumentFragment(); + textCell.appendChild(document.createComment(' field:text ')); + if (eyebrowText && eyebrowText !== headingText) { + const p = document.createElement('p'); + p.textContent = eyebrowText; + textCell.appendChild(p); + } + if (headingText) { + const h = document.createElement(headingEl && /^H[1-6]$/.test(headingEl.tagName) ? headingEl.tagName.toLowerCase() : 'h3'); + h.textContent = headingText; + textCell.appendChild(h); + } + if (descText && descText !== headingText) { + const p = document.createElement('p'); + p.textContent = descText; + textCell.appendChild(p); + } + if (link) { + const wrap = document.createElement('p'); + const a = document.createElement('a'); + a.setAttribute('href', link.getAttribute('href')); + a.textContent = norm(link.textContent) || 'Learn more'; + wrap.appendChild(a); + textCell.appendChild(wrap); + } + + cells.push([imageCell, textCell]); + }); + + const leadHeading = [...element.querySelectorAll('h1, h2')] + .find((h) => norm(h.textContent) && !usedHeadings.has(norm(h.textContent))); + + const block = WebImporter.Blocks.createBlock(document, { name: 'cards-bento', cells }); + + if (leadHeading) { + const h = document.createElement(leadHeading.tagName.toLowerCase()); + h.textContent = norm(leadHeading.textContent); + element.replaceWith(h, block); + } else { + element.replaceWith(block); + } +} diff --git a/tools/importer/parsers/cards-options.js b/tools/importer/parsers/cards-options.js new file mode 100644 index 0000000..e09cf6e --- /dev/null +++ b/tools/importer/parsers/cards-options.js @@ -0,0 +1,110 @@ +/* eslint-disable */ +/* global WebImporter */ +/** + * Parser for cards-options. Base block: cards (block/v1/block container). + * Source: https://www.totalwireless.com/m/home (AEM experiencefragment) + * Model (blocks/cards-options/_cards-options.json): + * cards-options (container) → cards-options-item { image (reference), imageAlt (collapsed), text (richtext) }. + * Library convention: each card row has TWO cells — cell 1 = image (image/imageAlt), + * cell 2 = text (title/description/CTA). An empty cell must still be present. + * + * Cross-page resilience: instance A uses ".tilelet-section" (icon-forward heading-only options), + * instance B uses nested ".content-card-wrapper" contact cards (eyebrow label + copy + CTA). + * The item finder tries several card container patterns and keeps only leaf items. + * A leading section heading (e.g. "How would you like to start?") is emitted as default content. + */ +export default function parse(element, { document }) { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + + const findItems = (root) => { + const sels = ['[class*="tilelet-section"]', '[class*="content-card-wrapper"]', '[class*="card-wrapper"]']; + for (const s of sels) { + let found = [...root.querySelectorAll(s)]; + // Keep only leaf items (no nested item of the same selector inside). + found = found.filter((el) => !el.querySelector(s)); + found = found.filter((el) => el.querySelector('h1,h2,h3,h4,h5,h6') || el.querySelector('[class*="eyebrow"], [class*="title"]')); + if (found.length >= 2) return found; + } + return [...root.querySelectorAll('li')].filter((el) => el.querySelector('h1,h2,h3,h4,h5,h6')); + }; + + const items = findItems(element); + if (!items.length) { + element.replaceWith(...element.childNodes); + return; + } + + const cells = []; + const usedHeadings = new Set(); + const seenRows = new Set(); + + items.forEach((item) => { + // Optional icon/image. + const seenImg = new Set(); + let image = null; + item.querySelectorAll('img').forEach((img) => { + const k = (img.getAttribute('src') || '').split('?')[0]; + if (k && !seenImg.has(k)) { seenImg.add(k); if (!image) image = img; } + }); + + const headingEl = [...item.querySelectorAll('h1, h2, h3, h4, h5, h6')].find((h) => norm(h.textContent)); + const eyebrowEl = item.querySelector('[class*="eyebrow"]'); + const titleEl = item.querySelector('[class*="card-title"], [class*="headline"]'); + const descEl = item.querySelector('[class*="description"]'); + const link = item.querySelector('a[href]'); + + const label = norm((headingEl && headingEl.textContent) || (eyebrowEl && eyebrowEl.textContent)); + let body = norm((descEl && descEl.textContent) || (titleEl && titleEl.textContent)); + if (body === label) body = ''; + + // Dedupe responsive duplicate cards. + const rowKey = `${label}|${body}`; + if (seenRows.has(rowKey)) return; + seenRows.add(rowKey); + if (label) usedHeadings.add(label); + + // Cell 1 — image (reference). Empty cell if no icon. + const imageCell = document.createDocumentFragment(); + if (image) { + imageCell.appendChild(document.createComment(' field:image ')); + imageCell.appendChild(image); + } + + // Cell 2 — text (richtext): heading, optional description, optional CTA link. + const textCell = document.createDocumentFragment(); + textCell.appendChild(document.createComment(' field:text ')); + if (label) { + const h = document.createElement(headingEl ? headingEl.tagName.toLowerCase() : 'h3'); + h.textContent = label; + textCell.appendChild(h); + } + if (body) { + const p = document.createElement('p'); + p.textContent = body; + textCell.appendChild(p); + } + if (link) { + const wrap = document.createElement('p'); + const a = document.createElement('a'); + a.setAttribute('href', link.getAttribute('href')); + a.textContent = norm(link.textContent) || 'Learn more'; + wrap.appendChild(a); + textCell.appendChild(wrap); + } + + cells.push([imageCell, textCell]); + }); + + const leadHeading = [...element.querySelectorAll('h1, h2')] + .find((h) => norm(h.textContent) && !usedHeadings.has(norm(h.textContent))); + + const block = WebImporter.Blocks.createBlock(document, { name: 'cards-options', cells }); + + if (leadHeading) { + const h = document.createElement(leadHeading.tagName.toLowerCase()); + h.textContent = norm(leadHeading.textContent); + element.replaceWith(h, block); + } else { + element.replaceWith(block); + } +} diff --git a/tools/importer/parsers/cards-plans.js b/tools/importer/parsers/cards-plans.js new file mode 100644 index 0000000..0ca497c --- /dev/null +++ b/tools/importer/parsers/cards-plans.js @@ -0,0 +1,99 @@ +/* eslint-disable */ +/* global WebImporter */ +/** + * Parser for cards-plans. Base block: cards (block/v1/block container). + * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / plan cards) + * Model (blocks/cards-plans/_cards-plans.json): + * cards-plans (container) → cards-plans-item { name (richtext), price (text), features (richtext) }. + * Container block: each plan card is one row; child properties are columns, so each row has + * THREE cells matching the item model fields: name, price, features. The features cell holds the + * feature bullet list plus the Select CTA link. Generated for xwalk: one field hint per cell. + * + * Notes: + * - price is normalised to a clean base per-line amount + "/mo" (block JS reads this and + * multiplies by the selected number of lines). + * - feature bullets are deduped and stripped of trailing "Check footnote…" screen-reader text. + */ +export default function parse(element, { document }) { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const cleanFeature = (el) => norm(el.textContent).replace(/\s*Check footnote.*$/i, '').trim(); + + let items = [...element.querySelectorAll('[class*="plan-card"]')]; + // Keep only leaf plan cards (avoid wrapper containers nesting other plan cards). + items = items.filter((el) => !el.querySelector('[class*="plan-card"]')); + if (!items.length) { + element.replaceWith(...element.childNodes); + return; + } + + const cells = []; + const seen = new Set(); + + items.forEach((item) => { + const nameEl = item.querySelector('[class*="plan_title"], [class*="plan-title"], h2, h3'); + const name = norm(nameEl && nameEl.textContent); + + // Base per-line price: first dollar amount in the price element + "/mo". + let price = ''; + const rawPrice = norm(item.querySelector('[class*="plan_price"], [class*="plan-price"]') && item.querySelector('[class*="plan_price"], [class*="plan-price"]').textContent); + const m = rawPrice.match(/\$\s*\d+(?:\.\d+)?/); + if (m) price = `${m[0].replace(/\s/, '')}/mo`; + + if (!name && !price) return; + + // Dedupe responsive duplicate cards. + const key = `${name}|${price}`; + if (seen.has(key)) return; + seen.add(key); + + // Cell 1 — name (richtext). + const nameCell = document.createDocumentFragment(); + nameCell.appendChild(document.createComment(' field:name ')); + const h = document.createElement('h3'); + h.textContent = name; + nameCell.appendChild(h); + + // Cell 2 — price (text field group). Empty (no hint) if absent. + const priceCell = document.createDocumentFragment(); + if (price) { + priceCell.appendChild(document.createComment(' field:price ')); + const p = document.createElement('p'); + p.textContent = price; + priceCell.appendChild(p); + } + + // Cell 3 — features (richtext): feature bullet list + Select CTA. + const featuresCell = document.createDocumentFragment(); + featuresCell.appendChild(document.createComment(' field:features ')); + const featureItems = [...item.querySelectorAll('[class*="zelda-ul"] li, [class*="feature-points"] li, [class*="standard-benefits"] li')] + .map((li) => cleanFeature(li)) + .filter(Boolean); + const uniqFeatures = [...new Set(featureItems)]; + if (uniqFeatures.length) { + const ul = document.createElement('ul'); + uniqFeatures.forEach((t) => { + const li = document.createElement('li'); + li.textContent = t; + ul.appendChild(li); + }); + featuresCell.appendChild(ul); + } + // Select / primary action CTA. + const cta = [...item.querySelectorAll('a[href]')] + .find((a) => /select|bring your own|shop|choose/i.test(norm(a.textContent))) + || item.querySelector('a[href]'); + if (cta) { + const wrap = document.createElement('p'); + const a = document.createElement('a'); + a.setAttribute('href', cta.getAttribute('href')); + a.textContent = norm(cta.textContent) || 'Select'; + wrap.appendChild(a); + featuresCell.appendChild(wrap); + } + + cells.push([nameCell, priceCell, featuresCell]); + }); + + const block = WebImporter.Blocks.createBlock(document, { name: 'cards-plans', cells }); + element.replaceWith(block); +} diff --git a/tools/importer/parsers/columns-benefits.js b/tools/importer/parsers/columns-benefits.js new file mode 100644 index 0000000..a2c05dd --- /dev/null +++ b/tools/importer/parsers/columns-benefits.js @@ -0,0 +1,68 @@ +/* eslint-disable */ +/* global WebImporter */ +/** + * Parser for columns-benefits. Base block: columns (core/franklin/components/columns). + * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / feature-banner) + * Model (blocks/columns-benefits/_columns-benefits.json): columns block, 5 columns, 1 row. + * Columns blocks use DEFAULT CONTENT ONLY — no field-hint comments (per hinting rules). + * Library convention: name row + a content row with one cell per column. + * + * Source: an intro line ("Pick any plan and get:") followed by 5 icon+label benefit items + * (".feature-banner__icon-item" = icon image + label). Output: one row, one cell per benefit + * (5 columns). The intro line is emitted as default content immediately before the block. + */ +export default function parse(element, { document }) { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + + // Benefit items: prefer the authored icon-item containers; fall back to grouping images. + let items = [...element.querySelectorAll('[class*="feature-banner__icon-item"]')]; + if (!items.length) { + const seen = new Set(); + element.querySelectorAll('img').forEach((img) => { + const key = (img.getAttribute('src') || '').split('?')[0]; + if (key && !seen.has(key)) { seen.add(key); items.push(img.closest('div') || img); } + }); + } + if (!items.length) { + element.replaceWith(...element.childNodes); + return; + } + + const columnCells = items.map((item) => { + const cell = []; + // Icon image. + const img = item.querySelector('img'); + if (img) cell.push(img); + // Label text (icon-text container or the item's text; fall back to alt). + const labelEl = item.querySelector('[class*="icon-text"]') || item; + let label = norm(labelEl.textContent); + if (!label && img) label = norm(img.getAttribute('alt')); + if (label) { + const p = document.createElement('p'); + p.textContent = label; + cell.push(p); + } + return cell; + }); + + // Intro line as default content preceding the columns block (not a column itself). + const usedLabels = new Set( + columnCells.flatMap((c) => c.map((n) => (n.textContent ? norm(n.textContent) : ''))), + ); + const introEl = [...element.querySelectorAll('h1, h2, h3, h4, p')] + .find((el) => { + const t = norm(el.textContent); + return t && !el.querySelector('img') && !usedLabels.has(t); + }); + + const cells = [columnCells]; + const block = WebImporter.Blocks.createBlock(document, { name: 'columns-benefits', cells }); + + if (introEl) { + const intro = document.createElement('p'); + intro.textContent = norm(introEl.textContent); + element.replaceWith(intro, block); + } else { + element.replaceWith(block); + } +} diff --git a/tools/importer/parsers/columns-promo.js b/tools/importer/parsers/columns-promo.js new file mode 100644 index 0000000..c89e1df --- /dev/null +++ b/tools/importer/parsers/columns-promo.js @@ -0,0 +1,96 @@ +/* eslint-disable */ +/* global WebImporter */ +/** + * Parser for columns-promo. Base block: columns (core/franklin/components/columns). + * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / split-banner) + * Model (blocks/columns-promo/_columns-promo.json): columns block, 2 columns, 1 row. + * Columns blocks use DEFAULT CONTENT ONLY — no field-hint comments (per hinting rules). + * Library convention: name row + a content row with one cell per column; extra rows must + * keep the same column count. Here: 1 content row, 2 cells (one per promo tile). + * + * Each source ".splitbanner-custom__section" is one promo tile (column): image + heading + * + supporting text + CTA. + */ +export default function parse(element, { document }) { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const isText = (el) => el + && !el.querySelector('button, svg, img, input') + && norm(el.textContent).length > 0; + + // Identify the column tiles. Prefer the authored split-banner sections; fall back to + // direct grid columns for cross-page resilience. + let sections = [...element.querySelectorAll('[class*="splitbanner-custom__section"]')]; + if (sections.length < 2) { + const grid = element.querySelector('.aem-Grid'); + if (grid) { + sections = [...grid.children].filter((c) => c.classList.contains('aem-GridColumn') && norm(c.textContent)); + } + } + if (!sections.length) { + element.replaceWith(...element.childNodes); + return; + } + + const columnCells = sections.map((section) => { + const cell = []; + const seenText = new Set(); + + // Image (first unique per column). + const seenImg = new Set(); + let image = null; + section.querySelectorAll('img').forEach((img) => { + const src = img.getAttribute('src') || ''; + const k = src.split('?')[0]; + if (src && !seenImg.has(k)) { seenImg.add(k); if (!image) image = img; } + }); + if (image) cell.push(image); + + // Heading. + let heading = [...section.querySelectorAll('h1, h2, h3, h4')].find((h) => norm(h.textContent)); + if (heading) seenText.add(norm(heading.textContent)); + + // Paragraphs (deduped, non-CTA). + const paras = []; + [...section.querySelectorAll('p')].forEach((p) => { + if (!isText(p) || p.querySelector('a')) return; + const t = norm(p.textContent); + if (seenText.has(t)) return; + seenText.add(t); + paras.push(t); + }); + if (!heading && paras.length) { + heading = document.createElement('h2'); + heading.textContent = paras.shift(); + } + if (heading) cell.push(heading); + paras.forEach((t) => { + const p = document.createElement('p'); + p.textContent = t; + cell.push(p); + }); + + // CTA links (deduped). + const seenCta = new Set(); + section.querySelectorAll('a[href]').forEach((a) => { + const t = norm(a.textContent); + const key = `${a.getAttribute('href')}|${t}`; + if (t && !seenCta.has(key)) { + seenCta.add(key); + const wrap = document.createElement('p'); + const na = document.createElement('a'); + na.setAttribute('href', a.getAttribute('href')); + na.textContent = t; + wrap.appendChild(na); + cell.push(wrap); + } + }); + + return cell; + }); + + // Single content row, one cell per column (no field hints — columns block). + const cells = [columnCells]; + + const block = WebImporter.Blocks.createBlock(document, { name: 'columns-promo', cells }); + element.replaceWith(block); +} diff --git a/tools/importer/parsers/faq-home.js b/tools/importer/parsers/faq-home.js new file mode 100644 index 0000000..3dfd919 --- /dev/null +++ b/tools/importer/parsers/faq-home.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +/* global WebImporter */ +/** + * Parser for faq-home. Base block: faq (block/v1/block container). + * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / accordion) + * Model (blocks/faq-home/_faq-home.json): + * faq-home (container) → faq-home-item { question (richtext), answer (richtext) }. + * Container block: each accordion item is one row with TWO cells: question, answer. + * Generated for xwalk: one field hint per cell. + * + * The accordion header renders the question twice (visible label + trigger) plus a caret + * icon glyph, so the question text is cleaned of caret noise and de-duplicated. + * A leading section heading ("FAQs") is emitted as default content before the block. + */ +export default function parse(element, { document }) { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const clean = (s) => { + let t = norm(s).replace(/(?:up|down)-caret icon/ig, ' ').replace(/\s+/g, ' ').trim(); + if (t.length) { + // De-duplicate an exactly-doubled string ("X X" or "XX"). + const h = Math.floor(t.length / 2); + const a = t.slice(0, h).trim(); + const b = t.slice(h).trim(); + if (a && a === b) return a; + const half = t.slice(0, Math.ceil(t.length / 2)).trim(); + if (t === half + half) return half; + } + return t; + }; + + // Find accordion items; keep the leaf filter using the SAME selector to avoid + // false-positive matches on nested module classes (e.g. accordion-item-header-module…). + let itemSel = '[class*="accordionItem___"]'; + let items = [...element.querySelectorAll(itemSel)]; + if (!items.length) { itemSel = '[class*="accordion-item"]'; items = [...element.querySelectorAll(itemSel)]; } + if (!items.length) { itemSel = '[class*="faq__field"]'; items = [...element.querySelectorAll(itemSel)]; } + if (!items.length) { items = [...element.querySelectorAll('details')]; itemSel = null; } + if (itemSel) items = items.filter((el) => !el.querySelector(itemSel)); + if (!items.length) { + element.replaceWith(...element.childNodes); + return; + } + + const cells = []; + const seen = new Set(); + + items.forEach((item) => { + const qEl = item.querySelector('[class*="accordionItemHeader"], [class*="accordion-item-header"], [class*="Header"], summary, button, h3, h4'); + const aEl = item.querySelector('[class*="accordionDetailContent"], [class*="accordionItemDetail"], [class*="accordion-item-detail"], [class*="Detail"], [class*="content"], [class*="panel"]'); + + const question = clean(qEl && qEl.textContent); + const answer = norm(aEl && aEl.textContent); + + if (!question || seen.has(question)) return; + seen.add(question); + + // Cell 1 — question (richtext). + const qCell = document.createDocumentFragment(); + qCell.appendChild(document.createComment(' field:question ')); + const qp = document.createElement('p'); + qp.textContent = question; + qCell.appendChild(qp); + + // Cell 2 — answer (richtext). + const aCell = document.createDocumentFragment(); + aCell.appendChild(document.createComment(' field:answer ')); + if (answer) { + const ap = document.createElement('p'); + ap.textContent = answer; + aCell.appendChild(ap); + } + + cells.push([qCell, aCell]); + }); + + const leadHeading = [...element.querySelectorAll('h1, h2')].find((h) => norm(h.textContent)); + + const block = WebImporter.Blocks.createBlock(document, { name: 'faq-home', cells }); + + if (leadHeading && !seen.has(clean(leadHeading.textContent))) { + const h = document.createElement(leadHeading.tagName.toLowerCase()); + h.textContent = norm(leadHeading.textContent); + element.replaceWith(h, block); + } else { + element.replaceWith(block); + } +} diff --git a/tools/importer/parsers/hero-promo.js b/tools/importer/parsers/hero-promo.js new file mode 100644 index 0000000..f7fb7f6 --- /dev/null +++ b/tools/importer/parsers/hero-promo.js @@ -0,0 +1,100 @@ +/* eslint-disable */ +/* global WebImporter */ +/** + * Parser for hero-promo. Base block: hero. + * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / hero-banner-picture) + * Model (blocks/hero-promo/_hero-promo.json): image (reference), imageAlt (collapsed), text (richtext). + * Library convention: 1 column, 3 rows (name row, image row, text row: title/subheading/CTA). + * Generated for xwalk: field hints on each content cell. + * + * NOTE: The source renders responsive duplicates (desktop/tablet/mobile) of the same + * image and text, so this parser dedupes images by src and text by normalized content. + */ +export default function parse(element, { document }) { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + // A meaningful text element carries text and is not a button/icon/media wrapper. + const isText = (el) => el + && !el.querySelector('button, svg, img, input') + && norm(el.textContent).length > 0; + + const seenText = new Set(); + + // Prefer a real heading with text; fall back to promoting the first paragraph. + let heading = [...element.querySelectorAll('h1, h2, h3, h4')].find((h) => norm(h.textContent)); + if (heading) seenText.add(norm(heading.textContent)); + + // Supporting paragraphs (deduped, excluding CTA-bearing and heading-duplicate text). + const paras = []; + [...element.querySelectorAll('p')].forEach((p) => { + if (!isText(p) || p.querySelector('a')) return; + const t = norm(p.textContent); + if (seenText.has(t)) return; + seenText.add(t); + paras.push(t); + }); + + if (!heading && paras.length) { + heading = document.createElement('h2'); + heading.textContent = paras.shift(); + } + + // CTA links (deduped by href + text). + const ctas = []; + const seenCta = new Set(); + element.querySelectorAll('a[href]').forEach((a) => { + const t = norm(a.textContent); + const key = `${a.getAttribute('href')}|${t}`; + if (t && !seenCta.has(key)) { + seenCta.add(key); + ctas.push({ href: a.getAttribute('href'), text: t }); + } + }); + + // First unique image (dedupe responsive variants by src base). + const seenImg = new Set(); + let image = null; + element.querySelectorAll('img').forEach((img) => { + const src = img.getAttribute('src') || ''; + const k = src.split('?')[0]; + if (src && !seenImg.has(k)) { + seenImg.add(k); + if (!image) image = img; + } + }); + + // Empty-block guard. + if (!heading && !paras.length && !ctas.length && !image) { + element.replaceWith(...element.childNodes); + return; + } + + const cells = []; + + // Content row 1 – image (reference field). imageAlt collapses into . + const imageCell = document.createDocumentFragment(); + imageCell.appendChild(document.createComment(' field:image ')); + if (image) imageCell.appendChild(image); + cells.push([imageCell]); + + // Content row 2 – text (richtext field): heading, tagline paragraphs and CTA links. + const textCell = document.createDocumentFragment(); + textCell.appendChild(document.createComment(' field:text ')); + if (heading) textCell.appendChild(heading); + paras.forEach((t) => { + const p = document.createElement('p'); + p.textContent = t; + textCell.appendChild(p); + }); + ctas.forEach((c) => { + const wrap = document.createElement('p'); + const a = document.createElement('a'); + a.setAttribute('href', c.href); + a.textContent = c.text; + wrap.appendChild(a); + textCell.appendChild(wrap); + }); + cells.push([textCell]); + + const block = WebImporter.Blocks.createBlock(document, { name: 'hero-promo', cells }); + element.replaceWith(block); +} diff --git a/tools/importer/parsers/product-grid-picks.js b/tools/importer/parsers/product-grid-picks.js new file mode 100644 index 0000000..c3c62a6 --- /dev/null +++ b/tools/importer/parsers/product-grid-picks.js @@ -0,0 +1,73 @@ +/* eslint-disable */ +/* global WebImporter */ +/** + * Parser for product-grid-picks. Base block: product-grid. + * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / product carousel picks) + * Model (blocks/product-grid-picks/_product-grid-picks.json): + * product-grid-picks { index (aem-content) } — a link to a product index JSON. The block JS + * fetches the index and renders a curated product-pick row (image, name, price, Select CTA). + * + * This is an index-driven block: the authored content is a single link to a product index. + * The source page renders hardcoded product cards, but the EDS block is data-driven, so the + * parser emits ONE row with ONE cell containing the index link (field:index). We derive the + * index path from the section's "shop all" CTA, defaulting to a conventional products index. + * Generated for xwalk: field hint on the index cell. + * + * A leading section heading ("All the best phones, all in one place") and its shop-all CTA are + * emitted as default content before the block. + */ +export default function parse(element, { document }) { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + + // Section heading + "shop all" CTA (default content preceding the block). + const heading = [...element.querySelectorAll('h1, h2, h3')].find((h) => norm(h.textContent)); + const shopAll = [...element.querySelectorAll('a[href]')] + .find((a) => /shop all|all phones|view all|see all/i.test(norm(a.textContent))); + + // Derive the product index path. Prefer a JSON index if one is linked; otherwise map the + // shop-all destination to a conventional query-index path, defaulting to phones. + let indexPath = '/products/phones/index.json'; + const jsonLink = [...element.querySelectorAll('a[href]')] + .find((a) => /\.json(\?|$)/i.test(a.getAttribute('href') || '')); + if (jsonLink) { + indexPath = jsonLink.getAttribute('href'); + } else if (shopAll) { + try { + const p = new URL(shopAll.getAttribute('href'), 'https://www.totalwireless.com').pathname; + indexPath = `${p.replace(/\/$/, '')}/index.json`.replace(/^\/+/, '/'); + } catch { /* keep default */ } + } + + // Row 1 (content) — index (aem-content field): a link to the product index JSON. + const indexCell = document.createDocumentFragment(); + indexCell.appendChild(document.createComment(' field:index ')); + const a = document.createElement('a'); + a.setAttribute('href', indexPath); + a.textContent = indexPath; + indexCell.appendChild(a); + + const cells = [[indexCell]]; + const block = WebImporter.Blocks.createBlock(document, { name: 'product-grid-picks', cells }); + + // Emit heading + shop-all CTA as default content before the block. + const before = []; + if (heading) { + const h = document.createElement(heading.tagName.toLowerCase()); + h.textContent = norm(heading.textContent); + before.push(h); + } + if (shopAll) { + const p = document.createElement('p'); + const link = document.createElement('a'); + link.setAttribute('href', shopAll.getAttribute('href')); + link.textContent = norm(shopAll.textContent); + p.appendChild(link); + before.push(p); + } + + if (before.length) { + element.replaceWith(...before, block); + } else { + element.replaceWith(block); + } +} diff --git a/tools/importer/parsers/teaser-promo.js b/tools/importer/parsers/teaser-promo.js new file mode 100644 index 0000000..f6f15f4 --- /dev/null +++ b/tools/importer/parsers/teaser-promo.js @@ -0,0 +1,91 @@ +/* eslint-disable */ +/* global WebImporter */ +/** + * Parser for teaser-promo. Base block: teaser. + * Source: https://www.totalwireless.com/m/home (AEM experiencefragment / cta-banner-with-pictogram) + * Model (blocks/teaser-promo/_teaser-promo.json): image (reference), imageAlt (collapsed), text (richtext). + * Single promotional tile: image + heading + supporting text + CTA link. + * 1 column, 3 rows (name, image, text). Generated for xwalk: field hints per cell. + */ +export default function parse(element, { document }) { + const norm = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const isText = (el) => el + && !el.querySelector('button, svg, img, input') + && norm(el.textContent).length > 0; + + const seenText = new Set(); + + let heading = [...element.querySelectorAll('h1, h2, h3, h4')].find((h) => norm(h.textContent)); + if (heading) seenText.add(norm(heading.textContent)); + + const paras = []; + [...element.querySelectorAll('p')].forEach((p) => { + if (!isText(p) || p.querySelector('a')) return; + const t = norm(p.textContent); + if (seenText.has(t)) return; + seenText.add(t); + paras.push(t); + }); + + if (!heading && paras.length) { + heading = document.createElement('h2'); + heading.textContent = paras.shift(); + } + + const ctas = []; + const seenCta = new Set(); + element.querySelectorAll('a[href]').forEach((a) => { + const t = norm(a.textContent); + const key = `${a.getAttribute('href')}|${t}`; + if (t && !seenCta.has(key)) { + seenCta.add(key); + ctas.push({ href: a.getAttribute('href'), text: t }); + } + }); + + const seenImg = new Set(); + let image = null; + element.querySelectorAll('img').forEach((img) => { + const src = img.getAttribute('src') || ''; + const k = src.split('?')[0]; + if (src && !seenImg.has(k)) { + seenImg.add(k); + if (!image) image = img; + } + }); + + if (!heading && !paras.length && !ctas.length && !image) { + element.replaceWith(...element.childNodes); + return; + } + + const cells = []; + + // Content row 1 – image (reference). imageAlt collapses into . + const imageCell = document.createDocumentFragment(); + imageCell.appendChild(document.createComment(' field:image ')); + if (image) imageCell.appendChild(image); + cells.push([imageCell]); + + // Content row 2 – text (richtext): heading, supporting text and CTA link. + const textCell = document.createDocumentFragment(); + textCell.appendChild(document.createComment(' field:text ')); + if (heading) textCell.appendChild(heading); + paras.forEach((t) => { + const p = document.createElement('p'); + p.textContent = t; + textCell.appendChild(p); + }); + ctas.forEach((c) => { + const wrap = document.createElement('p'); + const a = document.createElement('a'); + a.setAttribute('href', c.href); + a.textContent = c.text; + wrap.appendChild(a); + textCell.appendChild(wrap); + }); + cells.push([textCell]); + + const block = WebImporter.Blocks.createBlock(document, { name: 'teaser-promo', cells }); + element.replaceWith(block); +} diff --git a/tools/importer/transformers/totalwireless-cleanup.js b/tools/importer/transformers/totalwireless-cleanup.js new file mode 100644 index 0000000..359566c --- /dev/null +++ b/tools/importer/transformers/totalwireless-cleanup.js @@ -0,0 +1,105 @@ +/* eslint-disable */ +/* global WebImporter */ + +/** + * Transformer: Total Wireless site-wide cleanup. + * + * Removes non-authorable CMS chrome, global header/footer experience + * fragments, tracking/analytics scaffolding, cookie banners, feedback + * widgets, and CMS wrapper attributes from the AEM-authored source DOM. + * + * 🚨 Every selector below was verified against migration-work/cleaned.html + * for https://www.totalwireless.com/m/home. None are guessed. + */ + +const TransformHook = { beforeTransform: 'beforeTransform', afterTransform: 'afterTransform' }; + +export default function transform(hookName, element, payload) { + if (hookName === TransformHook.beforeTransform) { + // --- Overlays / modals / cookie banners that would interfere with + // block parsing (found at body tail and mid-document in cleaned.html). + WebImporter.DOMUtils.remove(element, [ + '#tDrkDiv', // tracking dark-overlay div (top of body) + '#__tealiumModalParent', // Tealium consent modal shell + '#__tealiumModal', // Tealium modal base (.__tealiumModalBase) + '#cookieBannerEN', // Combined cookie bar (English) + '#cookieBannerES', // Combined cookie bar (Spanish) + '#learnMoreModal', // global "learn more" overlay + '#autoPayModal', // global "auto pay" overlay + '.QSIFeedbackButton', // Qualtrics feedback widget + '[class*="preloaded_lightbox"]', // fancybox preloaded lightbox at body tail + ]); + + // Hidden tracking/state scaffolding at the top of . + ['#pageName', '#adtechEvent', '#sourceSystem'].forEach((sel) => { + const el = element.querySelector(sel); + if (el) el.remove(); + }); + } + + if (hookName === TransformHook.afterTransform) { + // --- Global chrome: header + footer experience fragments (site shell, + // not page content). Verified classes from cleaned.html. + WebImporter.DOMUtils.remove(element, [ + '.cmp-experiencefragment--totalwireless-header', // top nav / butterbar / skinny-banner + '.cmp-experiencefragment--totalwireless-footer', // footer link-lists, social, bottom links + 'nav.header-primary-menu', // primary menu (belt-and-braces if header stripped) + ]); + + // --- Tracking pixels / beacons / analytics iframes at body tail. + WebImporter.DOMUtils.remove(element, [ + '#ttdUniversalPixelTag', + '#postup_rules', + '[id^="batBeacon"]', // Bing UET beacon container + img + '[id^="universal_pixel_"]', // The Trade Desk universal pixel iframes + ]); + + // --- Non-content /