import { toJsLiteral } from '../pwa/source-literals' import { ITEM_COMPARISON_STYLES } from './component-styles' import { COMPARE_ROUTE } from './compare-route' import { COMPARE_KINDS, ItemComparisonConfig } from './settings' export const ITEM_COMPARISON_COMPONENT_FILE = 'teleport-item-comparison' export const ITEM_COMPARISON_COMPONENT_NAME = 'TeleportItemComparison' /** The editor's marks and events (`features/plugins/flows/item-comparison/compare-markers.ts` there). */ const COMPARE_TOGGLE_MARKER = 'data-tq-compare-toggle' const COMPARE_REMOVE_MARKER = 'data-tq-compare-remove' const COMPARE_CLEAR_MARKER = 'data-tq-compare-clear' const COMPARE_KIND_ATTR = 'data-tq-compare-kind' const ITEM_ID_ATTR = 'data-item-id' const EVENT_PREFIX = 'workflow:custom:' const TOGGLE_EVENT_PREFIX = 'tq-compare-toggle:' const REMOVE_EVENT = 'tq-compare-remove' const CLEAR_EVENT = 'tq-compare-clear' const REQUEST_EVENT = 'tq-compare-request' const FOLD_EVENT = 'tq-compare-fold' const UNFOLD_EVENT = 'tq-compare-unfold' const ITEMS_EVENT = 'tq-compare-items' /** The editor's phone breakpoint: the tray starts folded up to it. */ const PHONE_QUERY = '(max-width: 767px)' /** * `components/teleport-item-comparison.js`, rendered once from `_app`: the * keeper of the visitor's comparison. The controls the editor built — the * Compare buttons, the tray's and the page's Remove and Clear — send custom * events through their workflows; this keeps the picks (one kind at a time, * capped) in the visitor's browser, marks the Compare buttons pressed, reads * the picked rows through the compare route and answers the page's and the * tray's workflows with `tq-compare-items`: the items with their attribute * rows (the best value marked, the shared attributes left out when the * owner asked), the rows of their custom properties, the tray's slots (the * picks, then free slots), whether the tray shows on this page and whether * it is open or folded into its round button — open on a wide screen, folded * on a phone, until the visitor folds or opens it (`tq-compare-fold` / * `tq-compare-unfold`, kept for the visit). * A toast tells a visitor when the comparison is full. Nothing here is drawn * but the toast; the tray and the page are the project's own components. */ export const buildItemComparisonComponentSource = (config: ItemComparisonConfig): string => { const client = { maxItems: config.maxItems, highlightBest: config.highlightBest, onlyDifferences: config.onlyDifferences, compareRoute: config.compareRoute, propertyRows: { products: config.productPropertyRows, bookingItems: config.bookingPropertyRows, }, properties: { products: config.productProperties, bookingItems: config.bookingProperties }, } return `import { useCallback, useEffect, useRef, useState } from 'react' import { useRouter } from 'next/router' const SETTINGS = ${toJsLiteral(client)} const COMPARE_ROUTE = ${toJsLiteral(COMPARE_ROUTE)} const TOGGLE_SELECTOR = ${toJsLiteral(`[${COMPARE_TOGGLE_MARKER}]`)} const REMOVE_SELECTOR = ${toJsLiteral(`[${COMPARE_REMOVE_MARKER}]`)} const CLEAR_SELECTOR = ${toJsLiteral(`[${COMPARE_CLEAR_MARKER}]`)} const KIND_ATTR = ${toJsLiteral(COMPARE_KIND_ATTR)} const ID_ATTR = ${toJsLiteral(ITEM_ID_ATTR)} const EVENT_PREFIX = ${toJsLiteral(EVENT_PREFIX)} const TOGGLE_EVENT_PREFIX = ${toJsLiteral(TOGGLE_EVENT_PREFIX)} const REMOVE_EVENT = ${toJsLiteral(REMOVE_EVENT)} const CLEAR_EVENT = ${toJsLiteral(CLEAR_EVENT)} const REQUEST_EVENT = ${toJsLiteral(REQUEST_EVENT)} const FOLD_EVENT = ${toJsLiteral(FOLD_EVENT)} const UNFOLD_EVENT = ${toJsLiteral(UNFOLD_EVENT)} const ITEMS_EVENT = ${toJsLiteral(ITEMS_EVENT)} const PHONE_QUERY = ${toJsLiteral(PHONE_QUERY)} const KINDS = ${toJsLiteral(COMPARE_KINDS)} const STORE_KEY = 'tq_compare' const TRAY_KEY = 'tq_compare_tray' const CHANGE_EVENT = 'tq-compare:change' const STYLES = ${toJsLiteral(ITEM_COMPARISON_STYLES)} const BEST_LABEL = 'Best' const MAX_CUSTOM_ROWS = 30 function readPicks() { try { const parsed = JSON.parse(window.localStorage.getItem(STORE_KEY) || 'null') if (parsed && KINDS.indexOf(parsed.kind) >= 0 && Array.isArray(parsed.ids)) { return { kind: parsed.kind, ids: parsed.ids.filter((id) => typeof id === 'string').slice(0, SETTINGS.maxItems) } } } catch (e) {} return { kind: null, ids: [] } } // False when the browser keeps nothing (storage blocked or full): nothing changed, nothing is announced. function writePicks(picks) { try { if (picks.ids.length === 0) window.localStorage.removeItem(STORE_KEY) else window.localStorage.setItem(STORE_KEY, JSON.stringify(picks)) } catch (e) { return false } window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) return true } // The visitor's fold or open, for this visit; kept in memory too when the browser keeps nothing. let trayChoiceMade = null function trayChoice() { try { const stored = window.sessionStorage.getItem(TRAY_KEY) if (stored === 'open' || stored === 'folded') return stored } catch (e) {} return trayChoiceMade } function chooseTray(choice) { trayChoiceMade = choice try { window.sessionStorage.setItem(TRAY_KEY, choice) } catch (e) {} } // Open on a wide screen, folded on a phone — until the visitor chooses. function trayOpen() { const choice = trayChoice() if (choice) return choice === 'open' return !(window.matchMedia && window.matchMedia(PHONE_QUERY).matches) } function syncToggles(picks) { const toggles = document.querySelectorAll(TOGGLE_SELECTOR) for (let i = 0; i < toggles.length; i++) { const toggle = toggles[i] const pressed = toggle.getAttribute(KIND_ATTR) === picks.kind && picks.ids.indexOf(toggle.getAttribute(ID_ATTR)) >= 0 toggle.setAttribute('aria-pressed', pressed ? 'true' : 'false') } } // The id an event or a click carries: the event's data, else the control's attribute. function idOf(detail, control) { if (typeof detail === 'string' && detail) return detail if (detail && typeof detail === 'object' && typeof detail.value === 'string') return detail.value return control ? control.getAttribute(ID_ATTR) : null } function freeSlots(count) { const slots = [] for (let i = count; i < SETTINGS.maxItems; i++) slots.push({ id: '', title: '', image: '' }) return slots } // The rows the page draws: the attributes the owner chose, the best value // marked, the attributes every item shares left out when asked. function rowsOf(data) { const items = data && Array.isArray(data.items) ? data.items : [] const attributes = data && Array.isArray(data.attributes) ? data.attributes : [] const shown = attributes.filter((attribute) => { if (!SETTINGS.onlyDifferences || items.length < 2) return true const texts = items.map((item) => (item.attributes[attribute.key] ? item.attributes[attribute.key].text : '')) return texts.some((value) => value !== texts[0]) }) const best = {} shown.forEach((attribute) => { if (!SETTINGS.highlightBest || !attribute.better) return const numbers = items.map((item) => (item.attributes[attribute.key] && typeof item.attributes[attribute.key].number === 'number' ? item.attributes[attribute.key].number : null)) const texts = items.map((item) => (item.attributes[attribute.key] ? item.attributes[attribute.key].text : '')) const known = numbers.filter((value) => value !== null) if (!known.length || texts.every((value) => value === texts[0])) return const winner = attribute.better === 'low' ? Math.min.apply(null, known) : Math.max.apply(null, known) best[attribute.key] = numbers.map((value) => value === winner) }) return items.map((item, index) => { const rows = {} shown.forEach((attribute) => { const cell = item.attributes[attribute.key] if (cell) rows[attribute.key] = { label: attribute.label, text: cell.text, best: best[attribute.key] && best[attribute.key][index] ? BEST_LABEL : '' } }) return { id: item.id, kind: data.kind, title: item.title, url: item.url || '', image: item.image || '', attributes: rows } }) } // The rows of the items' custom properties: every name an item shows (in the // order they first appear, one row per name whatever its case), or the ones // the owner picked — each item's value side by side, '' where it has none; // with "only differences", the rows every item agrees on left out. function customRowsOf(data) { const items = data && Array.isArray(data.items) ? data.items : [] const kind = data && data.kind const mode = SETTINGS.propertyRows[kind] || 'all' if (mode === 'none' || items.length === 0) return [] const lists = mode === 'chosen' ? [SETTINGS.properties[kind] || []] : items.map((item) => (Array.isArray(item.properties) ? item.properties : []).map((property) => property.key)) const names = [] lists.forEach((list) => { list.forEach((name) => { const label = String(name || '').trim() if (label && !names.some((known) => known.key === label.toLowerCase())) names.push({ key: label.toLowerCase(), label }) }) }) const rows = [] for (let i = 0; i < names.length && rows.length < MAX_CUSTOM_ROWS; i++) { const name = names[i] const cells = items.map((item) => { const found = (Array.isArray(item.properties) ? item.properties : []).find((property) => String(property.key || '').toLowerCase() === name.key) return { text: found ? String(found.value || '') : '' } }) if (cells.every((cell) => !cell.text)) continue if (SETTINGS.onlyDifferences && cells.length > 1 && cells.every((cell) => cell.text === cells[0].text)) continue rows.push({ key: name.key, label: name.label, cells }) } return rows } function Toast({ message }) { return message ?