From 27fb1228181e99aa275e690181782733b7f20b6c Mon Sep 17 00:00:00 2001 From: Oliver Kucharzewski Date: Fri, 31 Oct 2025 11:06:51 +1100 Subject: [PATCH 1/4] feat: allow users to choose their expanded tile choice. --- .changeset/sad-pumas-design.md | 5 + .../vertical-expanded-tiles/base.template.tsx | 54 ++ src/libs/vertical-expanded-tiles/config.ts | 27 + .../embed-youtube.template.tsx | 131 ++++ .../expanded-swiper.loader.tsx | 558 ++++++++++++++++++ .../expanded-tile-video.tsx | 284 +++++++++ .../inline-products.template.spec.tsx | 26 + .../inline-products.template.tsx | 146 +++++ .../vertical-expanded-tiles/inline-swiper.tsx | 296 ++++++++++ .../products.swiper.ts | 42 ++ .../products.template.tsx | 134 +++++ .../vertical-expanded-tiles/tiktok-message.ts | 47 ++ .../tile-content.template.tsx | 145 +++++ .../vertical-expanded-tiles/tile.template.tsx | 191 ++++++ .../video.templates.tsx | 231 ++++++++ src/types/core/sdk.ts | 18 +- src/types/types.ts | 4 +- src/types/widgets.ts | 3 + src/widget-loader.ts | 12 +- 19 files changed, 2347 insertions(+), 7 deletions(-) create mode 100644 .changeset/sad-pumas-design.md create mode 100644 src/libs/vertical-expanded-tiles/base.template.tsx create mode 100644 src/libs/vertical-expanded-tiles/config.ts create mode 100644 src/libs/vertical-expanded-tiles/embed-youtube.template.tsx create mode 100644 src/libs/vertical-expanded-tiles/expanded-swiper.loader.tsx create mode 100644 src/libs/vertical-expanded-tiles/expanded-tile-video.tsx create mode 100644 src/libs/vertical-expanded-tiles/inline-products.template.spec.tsx create mode 100644 src/libs/vertical-expanded-tiles/inline-products.template.tsx create mode 100644 src/libs/vertical-expanded-tiles/inline-swiper.tsx create mode 100644 src/libs/vertical-expanded-tiles/products.swiper.ts create mode 100644 src/libs/vertical-expanded-tiles/products.template.tsx create mode 100644 src/libs/vertical-expanded-tiles/tiktok-message.ts create mode 100644 src/libs/vertical-expanded-tiles/tile-content.template.tsx create mode 100644 src/libs/vertical-expanded-tiles/tile.template.tsx create mode 100644 src/libs/vertical-expanded-tiles/video.templates.tsx diff --git a/.changeset/sad-pumas-design.md b/.changeset/sad-pumas-design.md new file mode 100644 index 0000000..91a361a --- /dev/null +++ b/.changeset/sad-pumas-design.md @@ -0,0 +1,5 @@ +--- +"@stackla/widget-utils": patch +--- + +Add expanded tile variant type diff --git a/src/libs/vertical-expanded-tiles/base.template.tsx b/src/libs/vertical-expanded-tiles/base.template.tsx new file mode 100644 index 0000000..68897de --- /dev/null +++ b/src/libs/vertical-expanded-tiles/base.template.tsx @@ -0,0 +1,54 @@ +import { ISdk, Tile } from "src/types" +import { VerticalExpandedTile } from "./tile.template" +import { createElement } from "../jsx-html" + +export function getExpandedSlides(sdk: ISdk, tiles: Tile[]) { + return tiles.map(tile => ( +
+ +
+ )) +} + +export function VerticalExpandedTiles(sdk: ISdk) { + const tiles = sdk.getTiles() + const { show_nav } = sdk.getExpandedTileConfig() + const navigationArrowsEnabled = show_nav + + return ( +
+
+
+
+
+ +
+
+ +
+
+
+
+ {tiles.map(tile => ( +
+ +
+ ))} +
+
+
+ ) +} diff --git a/src/libs/vertical-expanded-tiles/config.ts b/src/libs/vertical-expanded-tiles/config.ts new file mode 100644 index 0000000..23a8038 --- /dev/null +++ b/src/libs/vertical-expanded-tiles/config.ts @@ -0,0 +1,27 @@ +import { MyWidgetSettings } from "src/types" +import { VerticalExpandedTiles } from "./base.template" +import ProductsTemplate from "./products.template" +import { InlineProductsTemplate } from "./inline-products.template" +import { TileContentTemplate } from "./tile-content.template" + +export const loadVerticalExpandedTilesConfig = (config: MyWidgetSettings = {}) => { + config.templates = { + ...config.templates + } + config.templates["expanded-tiles"] = VerticalExpandedTiles + config.templates["ugc-product"] = ProductsTemplate + config.templates["inline-products"] = InlineProductsTemplate + config.templates["tile-content"] = TileContentTemplate + config.config = { + ...config.config, + expandedTile: { + ...config.config?.expandedTile, + swiper_options: { + ...config.config?.expandedTile?.swiper_options, + direction: "vertical" + } + } + } + + return config +} diff --git a/src/libs/vertical-expanded-tiles/embed-youtube.template.tsx b/src/libs/vertical-expanded-tiles/embed-youtube.template.tsx new file mode 100644 index 0000000..deb2ec1 --- /dev/null +++ b/src/libs/vertical-expanded-tiles/embed-youtube.template.tsx @@ -0,0 +1,131 @@ +import { createElement } from "../../" + +export type EmbedYoutubeProps = { + tileId: string + videoId: string + onLoad?: (event: Event) => void + swiperId: string +} + +export function EmbedYoutube({ tileId, videoId, onLoad, swiperId }: EmbedYoutubeProps) { + const contentElement = loadYoutubeIframeContent(tileId, videoId, swiperId) + + return ( + + ) +} + +function loadYoutubeIframeContent(tileId: string, videoId: string, swiperId: string) { + const scriptId = `yt-script-${tileId}-${videoId}` + const playerId = `yt-player-${tileId}-${videoId}` + return ( + + + + + + + +
+ + + ) +} + +export function loadYoutubePlayerAPI(playerId: string, videoId: string, swiperId: string) { + return ` + let player; + let swiper = parent.window.ugc.swiperContainer["${swiperId}"]; + const instance = swiper?.instance; + + function onPlayerStateChange(event) { + instance?.autoplay?.stop(); + if (event.data === YT.PlayerState.ENDED) { + instance?.autoplay?.start(); + instance?.slideNext(); + } + } + + function loadPlayer(playDefault = false) { + player = new YT.Player("${playerId}", { + width: "100%", + height: "100%", + videoId: "${videoId}", + playerVars: { + autoplay: 0, + controls: 1, + modestbranding: 1, + rel: 0, + enablejsapi: 1, + playsinline: 1, + }, + events: { + onReady: playDefault ? play : pause, + onStateChange: onPlayerStateChange, + onError: errorHandler + } + }); + } + + function onYouTubeIframeAPIReady() { + loadPlayer(); + } + + function errorHandler(e) { + player?.getIframe().dispatchEvent(new CustomEvent("yt-video-error", { detail: e })); + } + + function pause() { + if (player && player.pauseVideo) { + player.pauseVideo(); + } + } + + function play() { + if (player && player.playVideo) { + player.playVideo(); + } + } + + function observeVisibility() { + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + play(); + } else { + pause(); + instance?.autoplay?.start(); + } + }); + }, { threshold: 0.5 }); + + observer.observe(document.body); + } + + window.onYouTubeIframeAPIReady = () => { + loadPlayer(false); + observeVisibility(); + }; + ` +} diff --git a/src/libs/vertical-expanded-tiles/expanded-swiper.loader.tsx b/src/libs/vertical-expanded-tiles/expanded-swiper.loader.tsx new file mode 100644 index 0000000..3448907 --- /dev/null +++ b/src/libs/vertical-expanded-tiles/expanded-swiper.loader.tsx @@ -0,0 +1,558 @@ +import { + EVENT_EXPANDED_TILE_CLOSE, + EVENT_LOAD_MORE, + EVENT_TILE_EXPAND_RENDERED, + ISdk, + registerDefaultClickEvents, + registerGenericEventListener, + registerProductsUpdatedListener, + registerShareMenuClosedListener, + registerShareMenuOpenedListener, + registerTileExpandListener +} from "../../" + +import { + destroySwiper, + getActiveSlide, + getActiveSlideElement, + getSwiperIndexForTile, + initializeSwiper, + LookupAttr, + SwiperWithExtensions, + updateSwiperInstance +} from "../extensions" + +import { loadProductsSwiper } from "./products.swiper" +import { waitForElm } from "../../libs" +import { type SwiperType as Swiper } from "../../types" +import { muteTiktokVideo, unMuteTiktokVideo } from "./tiktok-message" +import { getExpandedSlides } from "./base.template" +import { + controlVideoPlayback, + setupTikTokPlayerReadyEvent, + setupVideoEvents, + setupYoutubeEvents, + YoutubeIframeElementType +} from "./expanded-tile-video" +import { SwiperTypeOptions } from "../../types" + +interface ExpandedTileSettings { + initialTileId: string + lookupAttr?: LookupAttr + widgetSelector: HTMLElement + expandedTileWrapper: Element + swiperSettings?: SwiperTypeOptions +} + +/** + * Initialize/re-initialize swiper for loading expanded tiles + * + * @param { string } initialTileId - id of the tile that should be displayed after loading swiper + * @param { LookupAttr } lookupAttr - additional attribute lookup options for finding the first slide to load + * @param { LookupAttr.name } lookupAttr.name - name of the attribute for e.g. data-yt-id or data-tiktok-id + * @param { LookupAttr.value } lookupAttr.value - required value of the attribute + */ +function initializeSwiperForExpandedTiles( + sdk: ISdk, + paritalSettings: Partial & { + initialTileId: string + } +) { + const expandedTile = sdk.getExpandedTiles() + const expandedTileWrapper = expandedTile?.querySelector(".expanded-tile-wrapper") + + if (!expandedTile || !expandedTileWrapper) { + throw new Error("The expanded tile element not found") + } + const widgetSelector = expandedTile.querySelector(".swiper-expanded") + + if (!widgetSelector) { + throw new Error("Failed to find widget UI element. Failed to initialise Glide") + } + + const isStory = expandedTileWrapper.getAttribute("variation") === "vertical" + + const { initialTileId, lookupAttr } = paritalSettings + + const settings: ExpandedTileSettings = { + initialTileId, + widgetSelector, + expandedTileWrapper, + lookupAttr, + swiperSettings: paritalSettings.swiperSettings || {} + } + + if (isStory) { + initalizeStoryExpandedTile(sdk, settings) + } else { + initalizeExpandedTile(sdk, settings) + } +} + +function initalizeExpandedTile(sdk: ISdk, settings: ExpandedTileSettings) { + const { initialTileId, widgetSelector } = settings + + initializeSwiper(sdk, { + id: "expanded", + widgetSelector, + mode: "expanded", + prevButton: "swiper-expanded-button-prev", + nextButton: "swiper-expanded-button-next", + paramsOverrides: { + loop: true, + slidesPerView: 1, + autoHeight: true, + keyboard: { + enabled: true, + onlyInViewport: false + }, + mousewheel: { + enabled: true, + forceToAxis: true, + releaseOnEdges: true + }, + autoplay: false, + on: { + reachEnd: () => { + sdk.triggerEvent(EVENT_LOAD_MORE) + }, + afterInit: (swiper: SwiperWithExtensions) => { + const swiperIndex = getSwiperIndexForTile(settings.widgetSelector, initialTileId) + swiper.slideToLoop(swiperIndex, 0, false) + }, + navigationNext: (swiper: SwiperWithExtensions) => swiperNavigationHandler(sdk, swiper), + navigationPrev: (swiper: SwiperWithExtensions) => swiperNavigationHandler(sdk, swiper), + ...settings.swiperSettings?.on + } + }, + getSliderTemplate: getExpandedSlides + }) +} + +function initalizeStoryExpandedTile(sdk: ISdk, settings: ExpandedTileSettings) { + const { initialTileId, widgetSelector, expandedTileWrapper, swiperSettings } = settings + initializeSwiper(sdk, { + id: "expanded", + widgetSelector, + mode: "expanded", + prevButton: "swiper-expanded-button-prev", + nextButton: "swiper-expanded-button-next", + paramsOverrides: { + slidesPerView: "auto", + autoHeight: true, + autoplay: { + delay: 5000, + disableOnInteraction: false + }, + mousewheel: { + enabled: true, + forceToAxis: true, + releaseOnEdges: false + }, + freeMode: { + enabled: true, + sticky: true, + momentum: true + }, + direction: "horizontal", + centeredSlides: true, + effect: "coverflow", + coverflowEffect: { + rotate: 0, + scale: 0.8, + stretch: -1, + depth: 1, + modifier: 1, + slideShadows: false + }, + keyboard: { + enabled: true, + onlyInViewport: false + }, + grabCursor: true, + loop: true, + on: { + reachEnd: () => { + sdk.triggerEvent(EVENT_LOAD_MORE) + }, + afterInit: (swiper: Swiper) => { + swiper.slideToLoop(getSwiperIndexForTile(settings.widgetSelector, initialTileId), 0, false) + registerStoryControls(sdk, expandedTileWrapper, swiper) + }, + autoplayTimeLeft: (swiper: Swiper, _timeLeft: number, percentage: number) => { + storyAutoplayProgress(swiper, percentage) + }, + autoplay: (swiper: Swiper) => swiperNavigationHandler(sdk, swiper), + navigationNext: (swiper: Swiper) => swiperNavigationHandler(sdk, swiper), + navigationPrev: (swiper: Swiper) => swiperNavigationHandler(sdk, swiper), + slideChange: (swiper: Swiper) => { + swiper?.autoplay?.start() + } + }, + ...swiperSettings + }, + getSliderTemplate: getExpandedSlides + }) +} + +async function swiperNavigationHandler(sdk: ISdk, swiper: Swiper) { + void controlVideoPlayback(sdk, swiper) + const tileId = getTileIdFromSlide(swiper, swiper.realIndex) + if (!tileId) { + throw Error("Tile ID is not found in next slide from swiper") + } + const tile = sdk.getTileById(tileId) + if (!tile) { + throw Error("Tile is not found in next slide from swiper") + } + sdk.setTile(tile) +} + +export function storyAutoplayProgress(swiper: Swiper, progress: number) { + const activeSlideElement = getActiveSlideFromSlides(swiper) + const progressLine = activeSlideElement?.querySelector(".story-autoplay-progress > .progress-content") + + if (!progressLine) { + return + } + + progressLine.style.width = `${100 - Math.round(progress * 100)}%` +} + +function registerStoryControls(sdk: ISdk, tileWrapper: Element, swiper: Swiper) { + const storyCtrls = tileWrapper.querySelector(".story-controls") + + if (!storyCtrls) { + return + } + + const playCtrl = storyCtrls.querySelector(".play-ctrl") + const pauseCtrl = storyCtrls.querySelector(".pause-ctrl") + + const volumeCtrl = storyCtrls.querySelector(".volume-ctrl") + const muteCtrl = storyCtrls.querySelector(".mute-ctrl") + + playCtrl?.addEventListener("click", () => { + playCtrl.classList.add("hidden") + pauseCtrl?.classList.remove("hidden") + swiper?.autoplay?.start() + }) + + pauseCtrl?.addEventListener("click", () => { + pauseCtrl.classList.add("hidden") + playCtrl?.classList.remove("hidden") + swiper?.autoplay?.stop() + }) + + volumeCtrl?.addEventListener("click", () => { + volumeCtrl.classList.add("hidden") + muteCtrl?.classList.remove("hidden") + updateSwiperInstance(sdk, "expanded", (swiperData: SwiperWithExtensions) => { + swiperData.muted = true + }) + + const videoEles = tileWrapper.querySelectorAll("video") + videoEles.forEach(ele => { + ele.muted = true + }) + + const iFramePlayers = tileWrapper.querySelectorAll(`iframe.video-content`) + iFramePlayers.forEach(iFramePlayer => { + const iFramePlayerId = iFramePlayer.getAttribute("id") + if (iFramePlayerId) { + if (iFramePlayerId.includes("yt-frame")) { + const iFramePlayerWindow = iFramePlayer.contentWindow + iFramePlayerWindow.mute() + } else if (iFramePlayerId.includes("tiktok-frame")) { + const iFramePlayerWindow = iFramePlayer.contentWindow + muteTiktokVideo(iFramePlayerWindow) + } + } + }) + }) + + muteCtrl?.addEventListener("click", () => { + muteCtrl.classList.add("hidden") + volumeCtrl?.classList.remove("hidden") + updateSwiperInstance(sdk, "expanded", (swiperData: SwiperWithExtensions) => { + swiperData.muted = false + }) + + const videoEles = tileWrapper.querySelectorAll("video") + videoEles.forEach(ele => { + ele.muted = false + }) + + const iFramePlayers = tileWrapper.querySelectorAll(`iframe.video-content`) + iFramePlayers.forEach(iFramePlayer => { + const iFramePlayerId = iFramePlayer.getAttribute("id") + if (iFramePlayerId) { + if (iFramePlayerId.includes("yt-frame")) { + const iFramePlayerWindow = iFramePlayer.contentWindow + iFramePlayerWindow.unMute() + } else if (iFramePlayerId.includes("tiktok-frame")) { + const iFramePlayerWindow = iFramePlayer.contentWindow + unMuteTiktokVideo(iFramePlayerWindow) + } + } + }) + }) + + handleAutoplayProgress(tileWrapper, swiper, playCtrl) +} + +/** + * pause autoplay progress when hover over active slide + * resume autoplay progress when mouse leave the active slide + * @param tileWrapper + * @param swiper + * @param playCtrl + */ +function handleAutoplayProgress(tileWrapper: Element, swiper: Swiper, playCtrl: Element | null) { + const swiperWrapperEle = tileWrapper.querySelector(".swiper-wrapper") + + swiperWrapperEle?.addEventListener("mouseleave", () => { + if (playCtrl?.classList.contains("hidden")) { + if (swiper?.autoplay?.paused) { + swiper?.autoplay?.resume() + } + } + }) +} + +export function getSwiperSlideById(swiper: Swiper, id: number): HTMLElement | undefined { + return swiper.slides[id] +} + +export function getActiveSlideFromSlides(swiper: Swiper): HTMLElement | undefined { + return swiper.slides.find(slide => slide.classList.contains("swiper-slide-active")) +} + +/* + Get Tile ID +*/ + +export function getTileIdFromSlide(swiper: Swiper, index: number) { + const element = getSwiperSlideById(swiper, index) + return element?.getAttribute("data-id") +} + +/** + * Triggered when an inline tile is clicked + * Adds background overlay for expanded tile and initializes swiper for expanded tile + * + * @param { string } tileId - the id of clicked inline tile and the tile that will be displayed on expanded tile load + */ +export function onTileExpand(sdk: ISdk, tileId: string) { + const expandedTileSettings = sdk.getWidgetTemplateSettings().config?.expandedTile + const swiperSettings = expandedTileSettings?.swiper_options + const expandedTile = sdk.getExpandedTiles() + const body = document.querySelector("body") + + if (body) { + body.style.overflow = "hidden" + } + + const overlay: HTMLDialogElement = expandedTile.parentElement as HTMLDialogElement + + overlay.showModal() + overlay.classList.add("expanded-tile-overlay") + + waitForElm(expandedTile, [".swiper-expanded"], () => { + const tileElement = expandedTile.shadowRoot?.querySelector(`.swiper-slide[data-id="${tileId}"]`) + const youtubeId = tileElement?.getAttribute("data-yt-id") + const tiktokId = tileElement?.getAttribute("data-tiktok-id") + + if (youtubeId) { + const lookupYtAttr: LookupAttr = { name: "data-yt-id", value: youtubeId } + const settings = { + initialTileId: tileId, + lookupAttr: lookupYtAttr, + swiperSettings: swiperSettings || {} + } + initializeSwiperForExpandedTiles(sdk, settings) + } else if (tiktokId) { + const lookupTiktokAttr: LookupAttr = { name: "data-tiktok-id", value: tiktokId } + const settings = { + initialTileId: tileId, + lookupAttr: lookupTiktokAttr, + swiperSettings: swiperSettings || {} + } + initializeSwiperForExpandedTiles(sdk, settings) + } else { + const settings = { + initialTileId: tileId, + swiperSettings: swiperSettings || {} + } + initializeSwiperForExpandedTiles(sdk, settings) + } + }) +} + +/** + * Triggered after all slides in expanded tiles are loaded + * Setup onload and onerror events for media sources (video/youtube/tiktok) + */ +export function onTileRendered(event: CustomEvent) { + const widgetSelectorId = event.detail.widgetSelector as string + const sdk = window.ugc.getWidgetBySelector(widgetSelectorId).sdk as ISdk + const expandedTilesElement = sdk.getExpandedTiles() + const tiles = expandedTilesElement.querySelectorAll(".swiper-slide") + + const widgetSelector = expandedTilesElement.querySelector(".swiper-expanded") + + if (!widgetSelector) { + throw new Error("Widget selector for expanded tile (swiper-expanded) is not found") + } + + setupTikTokPlayerReadyEvent(sdk) + + tiles?.forEach(tile => { + setupVideoEvents(sdk, tile, widgetSelector) + setupYoutubeEvents(sdk, tile, widgetSelector) + }) +} + +/** + * Reduces visibility of swiper controls when share menu is opened + */ +export function reduceBackgroundControlsVisibility(sdk: ISdk, sourceId: string) { + if (!isValidEventSource(sdk, sourceId)) { + return + } + + const expandedTile = sdk.getExpandedTiles() + const navigationPrevButton = expandedTile.querySelector(".swiper-expanded-button-prev") + const navigationNextButton = expandedTile.querySelector(".swiper-expanded-button-next") + const exitTileButtons = expandedTile.querySelectorAll(".exit") + + navigationNextButton?.classList.add("swiper-button-disabled") + navigationPrevButton?.classList.add("swiper-button-disabled") + + exitTileButtons.forEach(button => { + button.style.opacity = "0.4" + }) +} + +/** + * Resets visibility of swiper controls when share menu is closed + */ +export function resetBackgroundControlsVisibility(sdk: ISdk, sourceId: string) { + if (!isValidEventSource(sdk, sourceId)) { + return + } + + const expandedTile = sdk.getExpandedTiles() + const wrapper = expandedTile.querySelector(".expanded-tile-wrapper") + + if (!wrapper) { + return + } + + const navigationPrevButton = wrapper.querySelector(".swiper-expanded-button-prev") + const navigationNextButton = wrapper.querySelector(".swiper-expanded-button-next") + const exitTileButton = wrapper.querySelector(".exit") + + navigationNextButton?.classList.remove("swiper-button-disabled") + navigationPrevButton?.classList.remove("swiper-button-disabled") + + if (exitTileButton) { + exitTileButton.removeAttribute("style") + } +} + +/** + * Checks if the source id from the event matches the active slide tile id + * TODO - Improve this if the tile id can be repeated real time + * + * @param { string } sourceId - an event identifier. usually a tileId or a combinations of ids + * @returns + */ +function isValidEventSource(sdk: ISdk, sourceId: string) { + const activeSlideElement = getActiveSlideElement(sdk, "expanded") + return activeSlideElement?.getAttribute("data-id") === sourceId +} + +/** + * Checks if the supplied tile element is currently active in the slide + * + * @param { Element } tile - tile element to check + * @param { HTMLElement } widgetSelector - the container of the swiper element + * @param { LookupAttr } lookupAttr - additional attribute lookup options for finding the first slide to load + * @param { LookupAttr.name } lookupAttr.name - name of the attribute for e.g. data-yt-id or data-tiktok-id + * @param { LookupAttr.value } lookupAttr.value - required value of the attribute + * @returns true if the supplied tile element is the currently displayed element. false, otherwise. + */ +export function isActiveTile(sdk: ISdk, tile: Element, widgetSelector: HTMLElement, lookupAttr?: LookupAttr) { + const tileId = tile.getAttribute("data-id") + + if (lookupAttr) { + const originalLookupAttrValue = tile.getAttribute(lookupAttr.name) + const activeSwiperElement = getActiveSlideElement(sdk, "expanded") + const activeElementTileId = activeSwiperElement?.getAttribute("data-id") + const activeElementLookupAttrValue = activeSwiperElement?.getAttribute(lookupAttr.name) + return originalLookupAttrValue === activeElementLookupAttrValue && tileId === activeElementTileId + } + const tileIndex = tileId ? getSwiperIndexForTile(widgetSelector, tileId) : 0 + return getActiveSlide(sdk, "expanded") === tileIndex +} + +/** + * Triggered when expanded tile is closed + * Destroys swiper instance for expanded tile and removes background overlay + */ +export function onTileClosed(event: CustomEvent) { + const widgetSelectorId = event.detail.widgetSelector as string + const sdk = window.ugc.getWidgetBySelector(widgetSelectorId).sdk as ISdk + const expandedTile = sdk.getExpandedTiles() + + const overlay: HTMLDialogElement = expandedTile.parentElement as HTMLDialogElement + overlay.close() + overlay.classList.remove("expanded-tile-overlay") + + const body = document.querySelector("body") + + if (body) { + body.style.overflow = "auto" + } + + destroySwiper(sdk, "expanded") +} + +export function loadExpandSettingComponents(sdk: ISdk) { + const { show_shopspots, show_products, show_add_to_cart, show_carousel_grouping } = sdk.getExpandedTileConfig() + + if (show_shopspots) { + sdk.addLoadedComponents(["shopspots"]) + } + + if (show_carousel_grouping) { + sdk.addLoadedComponents(["carousel-grouping"]) + } + + if (show_products) { + sdk.addLoadedComponents(["products", "inline-products"]) + } + if (show_add_to_cart) { + sdk.addLoadedComponents(["add-to-cart"]) + } +} + +export function loadExpandedTileFeature(sdk: ISdk) { + const widgetContainer = sdk.getStyleConfig() + const { click_through_url } = widgetContainer + + if (click_through_url === "[EXPAND]") { + loadExpandSettingComponents(sdk) + registerTileExpandListener(sdk, onTileExpand) + registerGenericEventListener(sdk, EVENT_EXPANDED_TILE_CLOSE, onTileClosed) + registerGenericEventListener(sdk, EVENT_TILE_EXPAND_RENDERED, onTileRendered) + registerShareMenuOpenedListener(sdk, (sourceId: string) => reduceBackgroundControlsVisibility(sdk, sourceId)) + registerShareMenuClosedListener(sdk, (sourceId: string) => resetBackgroundControlsVisibility(sdk, sourceId)) + registerProductsUpdatedListener(sdk, loadProductsSwiper) + } else if (click_through_url === "[ORIGINAL_URL]" || /^https?:\/\/.+/.test(click_through_url ?? "")) { + registerDefaultClickEvents(sdk) + } else if (click_through_url === "[CUSTOM]") { + alert("Custom URL integration Not implemented yet") + } +} diff --git a/src/libs/vertical-expanded-tiles/expanded-tile-video.tsx b/src/libs/vertical-expanded-tiles/expanded-tile-video.tsx new file mode 100644 index 0000000..3cab16a --- /dev/null +++ b/src/libs/vertical-expanded-tiles/expanded-tile-video.tsx @@ -0,0 +1,284 @@ +import { type SwiperType } from "../../types" +import { getSwiperSlideById, getTileIdFromSlide, isActiveTile } from "./expanded-swiper.loader" +import { getInstance, getSwiperContainer, LookupAttr } from "../extensions" +import { ISdk } from "../../" +import { playTiktokVideo, muteTiktokVideo, pauseTiktokVideo } from "./tiktok-message" + +type YoutubeContentWindow = Window & { + play: () => void + pause: () => void + reset: () => void + mute: () => void + unMute: () => void +} + +export type YoutubeIframeElementType = HTMLIFrameElement & { + contentWindow: YoutubeContentWindow +} + +type SwiperVideoElementType = Window | YoutubeContentWindow | HTMLVideoElement + +type VideoSource = "video" | "youtube" | "tiktok" + +type SwiperVideoElementData = { + element: SwiperVideoElementType + source: VideoSource +} + +let tiktokDefaultPlayed = false + +/** + * Play the video/audio attached to the slide on load where the element is a media element (video/youtube/tiktok) + */ +export async function playMediaOnLoad(sdk: ISdk) { + const swiper = getInstance(sdk, `expanded`) as SwiperType + if (swiper) { + const activeElementData = getSwiperVideoElement(sdk, swiper, swiper.realIndex) + triggerPlay(sdk, activeElementData) + } +} + +/** + * Play/Pause the video/audio attached to the slide on navigation where the element is a media element (video/youtube/tiktok) + * @param { Swiper } swiper - the swiper element + */ +export async function controlVideoPlayback(sdk: ISdk, swiper: SwiperType) { + const activeElement = getSwiperVideoElement(sdk, swiper, swiper.realIndex) + const previousElement = getSwiperVideoElement(sdk, swiper, swiper.previousIndex) + + if (activeElement) { + triggerPlay(sdk, activeElement) + } + + if (previousElement) { + triggerPause(previousElement) + } + + if (!activeElement && !previousElement) { + console.warn("No active or previous video element found in controlVideoPlayback") + } +} + +/** + * Trigger media play for different media element sources ("video", "youtube", "tiktok") + * + * @param elementData - the media container element and the source + * @param elementData.element - the container element of the media (video tag or iframe.contentWindow) + * @param elementData.source - the media source (video for custom video source, youtube/tiktok) + */ +export function triggerPlay(sdk: ISdk, elementData?: SwiperVideoElementData) { + if (!elementData) { + console.warn("elementData is required") + return + } + + const swiperExpandedId = `expanded` + + switch (elementData.source) { + case "video": { + const videoElement = elementData.element as HTMLVideoElement + void videoElement.play() + if (getSwiperContainer(sdk, swiperExpandedId)?.muted) { + videoElement.muted = true + } else { + videoElement.muted = false + } + break + } + case "tiktok": { + const tiktokFrameWindow = elementData.element as Window + playTiktokVideo(tiktokFrameWindow) + muteTiktokVideo(tiktokFrameWindow) + break + } + case "youtube": { + return + } + default: + throw new Error(`unsupported video source ${elementData.source}`) + } +} +/** + * Trigger media pause for different element sources ("video", "youtube", "tiktok") + * + * @param elementData - the media container element and the source + * @param elementData.element - the container element of the media (video tag or iframe.contentWindow) + * @param elementData.source - the media source (video for custom video source, youtube/tiktok) + */ +export function triggerPause(elementData?: SwiperVideoElementData) { + if (!elementData) { + throw new Error("elementData is required") + } + + switch (elementData.source) { + case "video": { + const videoElement = elementData.element as HTMLVideoElement + videoElement.pause() + videoElement.currentTime = 0 + break + } + case "tiktok": { + const tiktokFrameWindow = elementData.element as Window + pauseTiktokVideo(tiktokFrameWindow) + break + } + case "youtube": { + return + } + default: + throw new Error(`unsupported video source ${elementData.source}`) + } +} + +/** + * Get swiper video/audio element at the provided index. + * + * @param { Swiper } swiper - the swiper element + * @param { number } index - index of the slide to be returned + * @param { number } isStory - if it is story widget + * @returns the video/iframe element or undefined if the element at index is not a video/audio + */ +export function getSwiperVideoElement( + sdk: ISdk, + swiper: SwiperType, + index: number, + isStory = false +): SwiperVideoElementData | undefined { + const element = getSwiperSlideById(swiper, index) + const tileId = getTileIdFromSlide(swiper, index) + const youtubeId = element?.getAttribute("data-yt-id") + + if (!element) { + throw new Error(`Failed to find element for the slide at index ${index}`) + } + + if (!tileId) { + throw new Error(`Failed to find tile id for the slide at index ${index}`) + } + + const tile = sdk.getTileById(tileId) + + if (!tile) { + throw new Error(`Failed to find tile by tile id ${tileId} in getSwiperVideoElement`) + } + + const media = tile.media + + if (media !== "video" && media !== "short") { + return undefined + } + + if (youtubeId) { + const youtubeFrame = element?.querySelector(`iframe#yt-frame-${tileId}-${youtubeId}`) + if (youtubeFrame) { + return { element: youtubeFrame.contentWindow, source: "youtube" } + } + } + + const tiktokId = element?.getAttribute("data-tiktok-id") + + if (tiktokId) { + const tiktokFrame = element?.querySelector(`iframe#tiktok-frame-${tileId}-${tiktokId}`) + if (tiktokFrame && tiktokFrame.contentWindow) { + return { element: tiktokFrame.contentWindow, source: "tiktok" } + } + } + + const videoElement = element?.querySelector( + `${isStory ? "" : " .panel .panel-left"} .video-content-wrapper video` + ) + + if (videoElement) { + return { element: videoElement, source: "video" } + } + + console.warn("Undefined video element", element, tileId) + + return undefined +} + +/** + * Setup onload and onerror (yt-video-error) events for youtube media + * + * @param { Element } tile - the tile element + * @param { HTMLElement } widgetSelector - the container of swiper element + */ +export function setupYoutubeEvents(sdk: ISdk, tile: Element, widgetSelector: HTMLElement) { + const tileId = tile.getAttribute("data-id") + const youtubeId = tile.getAttribute("data-yt-id") + + if (youtubeId && tileId) { + const youtubeFrame = tile.querySelector(`iframe#yt-frame-${tileId}-${youtubeId}`) + youtubeFrame?.addEventListener("load", () => { + playActiveMediaTileOnLoad(sdk, tile, widgetSelector, { name: "data-yt-id", value: youtubeId }) + }) + youtubeFrame?.addEventListener("yt-video-error", () => { + youtubeFrame.closest(".video-content-wrapper")?.classList.add("hidden") + tile.querySelector(".video-fallback-content")?.classList.remove("hidden") + }) + } +} + +/** + * Setup tiktok player events using window.postMessage api + * All media are paused by defult and only the media in the active slide is played + */ +export function setupTikTokPlayerReadyEvent(sdk: ISdk) { + tiktokDefaultPlayed = false + window.onmessage = ( + event: MessageEvent<{ + type: string + value?: number + "x-tiktok-player": boolean + }> + ) => { + if (event.data["x-tiktok-player"] && event.data.type === "onPlayerReady") { + const frameWindow = event.source as Window + pauseTiktokVideo(frameWindow) + + if (!tiktokDefaultPlayed) { + tiktokDefaultPlayed = true + setTimeout(() => playMediaOnLoad(sdk), 300) + } + } + } +} + +/** + * Play media associated with currently active tile + * + * @param { Element } tile - tile element to check + * @param { HTMLElement } widgetSelector - the container of the swiper element + * @param { LookupAttr } lookupAttr - additional attribute lookup options for finding the first slide to load + * @param { LookupAttr.name } lookupAttr.name - name of the attribute for e.g. data-yt-id or data-tiktok-id + * @param { LookupAttr.value } lookupAttr.value - required value of the attribute + */ +export function playActiveMediaTileOnLoad( + sdk: ISdk, + tile: Element, + widgetSelector: HTMLElement, + lookupAttr?: LookupAttr +) { + if (isActiveTile(sdk, tile, widgetSelector, lookupAttr)) { + void playMediaOnLoad(sdk) + } +} + +/** + * Setup onload and onerror events for custom video source + * + * @param { Element } tile - the tile element + * @param { HTMLElement } widgetSelector - the container of swiper element + */ +export function setupVideoEvents(sdk: ISdk, tile: Element, widgetSelector: HTMLElement) { + const videoSourceElement = tile.querySelector("video.video-content > source") + if (videoSourceElement) { + videoSourceElement.addEventListener("load", () => { + playActiveMediaTileOnLoad(sdk, tile, widgetSelector) + }) + videoSourceElement.addEventListener("error", () => { + videoSourceElement.closest(".video-content-wrapper")?.classList.add("hidden") + tile.querySelector(".video-fallback-content")?.classList.remove("hidden") + }) + } +} diff --git a/src/libs/vertical-expanded-tiles/inline-products.template.spec.tsx b/src/libs/vertical-expanded-tiles/inline-products.template.spec.tsx new file mode 100644 index 0000000..dd2af23 --- /dev/null +++ b/src/libs/vertical-expanded-tiles/inline-products.template.spec.tsx @@ -0,0 +1,26 @@ +import { stripMetaCurrencySymbols } from "./inline-products.template" + +describe("should test stripMetaCurrencySymbols", () => { + test("should return empty string for empty string input", () => { + expect(stripMetaCurrencySymbols("")).toBe("") + }) + test("should keep currency symbols and numbers", () => { + expect(stripMetaCurrencySymbols("$100")).toBe("$100") + expect(stripMetaCurrencySymbols("€200.50")).toBe("€200.50") + expect(stripMetaCurrencySymbols("£300,00")).toBe("£300,00") + expect(stripMetaCurrencySymbols("¥400")).toBe("¥400") + }) + test("should remove non-currency characters", () => { + expect(stripMetaCurrencySymbols("Price: $100")).toBe("$100") + expect(stripMetaCurrencySymbols("Total: €200.50")).toBe("€200.50") + expect(stripMetaCurrencySymbols("Cost: £300,00")).toBe("£300,00") + expect(stripMetaCurrencySymbols("Amount: ¥400")).toBe("¥400") + }) + test("should handle multiple currency symbols", () => { + expect(stripMetaCurrencySymbols("$100€200")).toBe("$100€200") + expect(stripMetaCurrencySymbols("£300,00¥400")).toBe("£300,00¥400") + }) + test("should remove GBP symbol but keep the number", () => { + expect(stripMetaCurrencySymbols("GBP£300,00")).toBe("£300,00") + }) +}) diff --git a/src/libs/vertical-expanded-tiles/inline-products.template.tsx b/src/libs/vertical-expanded-tiles/inline-products.template.tsx new file mode 100644 index 0000000..a59cbfd --- /dev/null +++ b/src/libs/vertical-expanded-tiles/inline-products.template.tsx @@ -0,0 +1,146 @@ +import { createElement, createFragment } from "../jsx-html" +import { ISdk, TagExtended, Tile } from "../../" + +type SwiperTemplateProps = { + tile: Tile + navArrows: boolean + prevIcon: string + nextIcon: string + sdk: ISdk +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function InlineProductsTemplate(sdk: ISdk, component: any) { + if (!component.tile) { + return <> + } + + return ( + + ) +} + +export function ProductTagsSwiperTemplate({ tile, navArrows, prevIcon }: SwiperTemplateProps) { + const products: TagExtended[] = (tile.tags_extended || []).filter(({ type }) => type === "product") + + if (!tile || !products || !products.length) { + return <> + } + + return ( + <> +
+ {navArrows && ( +
+ +
+ )} + +
+
+ Product +
+ +
+
+ {products.map(product => ( +
+
+ (window.location.href = product.custom_url)} + draggable="false" + highlight="false" + src={product.image_small_url} + class="inline-product-img" + /> +
+
+ + + + +
+ +
+ ))} +
+
+ + + +
+
+ + ) +} + +function ProductHeader({ product }: { product: TagExtended }) { + if (!product || !product.tag) return <> + + return
{getTitleFromProductTag(product.tag)}
+} + +function getColorFromProductTag(tag: string) { + const color = tag.split("|")[1] + return color ? color.trim() : "" +} + +function getTitleFromProductTag(tag: string) { + const tile = tag.split("|")[0] + return tile ? tile.trim() : "" +} + +function ProductDescription({ product }: { product: TagExtended }) { + if (!product) return <> + + return
{getColorFromProductTag(product.tag)}
+} + +export function stripMetaCurrencySymbols(price: string) { + // eslint-disable-next-line no-useless-escape + const pattern = /[^\$\€\£\¥\₹\₩\₽\฿\₫\d\.,]/g + if (!price) return "" + return price.replace(pattern, "").trim() +} + +function ProductPrice({ product }: { product: TagExtended }) { + if (!product || !product.price) return <> + const { price } = product + + return
{stripMetaCurrencySymbols(price)}
+} + +function ProductCTA({ product, tile }: { product: TagExtended; tile: Tile }) { + const { custom_url, cta_text = "Buy Now" } = product + const buttonText = cta_text + + return ( + + {buttonText} + + ) +} +function MobileProductCTA({ product, tile }: { product: TagExtended; tile: Tile }) { + const { custom_url } = product + + return ( + + ) +} diff --git a/src/libs/vertical-expanded-tiles/inline-swiper.tsx b/src/libs/vertical-expanded-tiles/inline-swiper.tsx new file mode 100644 index 0000000..c0f16f9 --- /dev/null +++ b/src/libs/vertical-expanded-tiles/inline-swiper.tsx @@ -0,0 +1,296 @@ +import { ISdk } from "../../types" +import { + initializeSwiper, + refreshSwiper, + setSwiperLoadingStatus, + isSwiperLoading, + updateSwiperInstance +} from "../extensions/swiper" +import type { Swiper } from "swiper" +import { enableTileImages, loadAllUnloadedTiles } from "../../libs" + +declare const sdk: ISdk +const ids = new Set() + +export function initializeInlineSwiperListeners() { + const swiper = sdk.querySelector(".carousel-inline.swiper-inline") + + if (!swiper) { + throw new Error("Failed to find swiper element") + } + + initializeSwiperForInlineTiles() +} + +function initializeSwiperForInlineTiles() { + const widgetSelector = sdk.querySelector(".carousel-inline.swiper-inline") + + if (!widgetSelector) { + throw new Error("Failed to find widget UI element. Failed to initialise Swiper") + } + + const tiles = sdk.querySelectorAll(".ugc-tile") + const tilesLength = tiles?.length ?? 0 + + initializeSwiper(sdk, { + id: "inline-carousel", + mode: "inline", + widgetSelector, + prevButton: "swiper-inline-carousel-button-prev", + nextButton: "swiper-inline-carousel-button-next", + paramsOverrides: { + loop: tilesLength > 4, + slidesPerView: "auto", + grabCursor: true, + allowTouchMove: true, + breakpointsBase: "container", + spaceBetween: 10, + freeMode: { + sticky: false, + momentum: true, + enabled: true + }, + mousewheel: { + enabled: true, + forceToAxis: true, + releaseOnEdges: true + }, + breakpoints: { + 0: { + slidesPerView: 1.5 + }, + 600: { + slidesPerView: 3.3 + }, + 900: { + slidesPerView: 5.3 + } + }, + keyboard: { + enabled: true, + onlyInViewport: false + }, + on: { + beforeInit: (swiper: Swiper) => { + enableLoadedTiles() + swiper.slideToLoop(0, 0, false) + }, + afterInit: (swiper: Swiper) => { + setSwiperLoadingStatus(sdk, "inline-carousel", true) + void loadTilesAsync(swiper) + }, + activeIndexChange: (swiper: Swiper) => { + if (swiper.navigation.prevEl) { + if (swiper.realIndex === 0 && isSwiperLoading(sdk, "inline-carousel")) { + disablePrevNavigation(swiper) + } else { + enablePrevNavigation(swiper) + } + } + } + } + } + }) +} + +export function enableLoadedTiles() { + sdk + .querySelectorAll(".ugc-tiles > .ugc-tile[style*='display: none']") + ?.forEach((tileElement: HTMLElement) => (tileElement.style.display = "")) +} + +async function loadTilesAsync(swiper: Swiper) { + const observer = registerObserver(swiper) + + const nextEl = swiper.navigation?.nextEl + + loadAllUnloadedTiles(sdk) + swiper.update() + + observer.disconnect() + + if (nextEl && nextEl instanceof HTMLElement) { + nextEl.classList.remove("swiper-button-hidden") + } + updateLoadingStateInterval(swiper.el) +} + +function updateLoadingStateInterval(swiperElem: HTMLElement) { + const intervalId = setInterval(function () { + const elements = swiperElem.querySelectorAll(".swiper-slide:has(.icon-section.hidden)") + if (elements.length === 0) { + clearInterval(intervalId) + updateSwiperInstance(sdk, "inline-carousel", swiperData => { + swiperData.isLoading = false + if (swiperData.instance) { + swiperData.instance.off("activeIndexChange") + swiperData.instance.setGrabCursor() + swiperData.instance.allowTouchMove = true + swiperData.instance.params.loop = true + enablePrevNavigation(swiperData.instance) + } + }) + refreshSwiper(sdk, "inline-carousel") + } + }, 200) +} + +function enablePrevNavigation(swiper: Swiper) { + swiper.allowSlidePrev = true + swiper.navigation.prevEl?.classList.remove("swiper-button-hidden") +} + +function disablePrevNavigation(swiper: Swiper) { + swiper.allowSlidePrev = false + swiper.navigation.prevEl?.classList.add("swiper-button-hidden") +} + +function registerObserver(swiper: Swiper) { + const observer = new MutationObserver(() => { + enableTileImages(swiper.wrapperEl) + }) + observer.observe(swiper.wrapperEl, { + childList: true + }) + return observer +} + +async function generateUserHandleText(tileId: string, retries = 0): Promise { + const tile = sdk.getTileById(tileId) + if (!tile) { + console.info(`Tile with id ${tileId} not found, retrying...`) + if (retries < 5) { + return new Promise(resolve => { + setTimeout(() => resolve(generateUserHandleText(tileId, retries + 1)), 500) + }) + } + + return "@nosto" + } + + const fallback = "nosto" + + const contentTags = tile.tags_extended?.filter( + tag => tag.type === "content" && tag.publicly_visible && tag.tag.includes("@") + ) + + const displayedItem = tile.user && tile.user.length ? tile.user : tile.terms && tile.terms.length ? tile.terms[0] : "" + + return contentTags?.length ? contentTags[0].tag : `@${displayedItem.length ? displayedItem : fallback}` +} + +function doesUserHandleExist(tile: HTMLElement): boolean { + const userHandle = tile.querySelector(".user-handle") + + if (!userHandle) { + return true + } + + const trimmedContent = userHandle.textContent?.trim() || "" + + return userHandle !== null && trimmedContent.length > 0 +} + +export function disableExpandedTileIfProductsMissing( + tile: HTMLElement, + tileId: string, + shouldDisableInactiveTiles = true, + retries = 0 +) { + const tileMeta = sdk.getTileById(tileId) + + if (!tileMeta) { + if (retries < 5) { + setTimeout(() => disableExpandedTileIfProductsMissing(tile, tileId, shouldDisableInactiveTiles, retries + 1), 100) + } else { + console.error(`Tile with id ${tileId} not found after multiple retries.`) + } + + return + } + + const productTags = sdk + .getProductTagsFromTile(tileMeta) + .filter(productTag => productTag.availability_status == "InStock") + const iconSection = tile.querySelector(".icon-lookup") + + if (iconSection) { + if (!productTags || productTags.length === 0) { + shouldDisableInactiveTiles && tile.classList.add("inactive") + } + } +} + +export function mutateTilesOnce(shouldDisableInactiveTiles = true) { + const tiles = sdk.querySelectorAll(".ugc-tile:not(.observed)") + + if (!tiles || tiles.length === 0) { + return + } + + tiles.forEach(async tile => { + const id = tile.getAttribute("data-id") + if (id) { + if (ids.has(id)) { + console.info(`Duplicate tile id found: ${id}. Removing duplicate tile.`) + tile.remove() + } else { + ids.add(id) + } + + tile.classList.add("observed") + + const userHandle = tile.querySelector(".user-handle") + userHandle?.replaceChildren(document.createTextNode(await generateUserHandleText(id))) + + disableExpandedTileIfProductsMissing(tile, id, shouldDisableInactiveTiles) + } + }) +} + +export function observeResize(track: HTMLElement | null) { + if (!track) { + return + } + const resizeObserver = new ResizeObserver(() => { + const swiperWrapper = sdk.querySelectorAll(".swiper-wrapper") + + if (!swiperWrapper) { + return + } + + swiperWrapper.forEach(wrapper => { + wrapper.setAttribute("style", "") + }) + }) + resizeObserver.observe(track.querySelector(".ugc-tiles")!) + return resizeObserver +} + +export function observeMutations(track: HTMLElement | null, shouldDisableInactiveTiles = true) { + const mutationObserver = new MutationObserver(mutationList => { + for (const mutation of mutationList) { + if (mutation.type === "childList") { + const target = mutation.target as HTMLElement + if (target.classList.contains("user-handle")) { + continue + } + + if (doesUserHandleExist(target)) { + continue + } + + mutateTilesOnce(shouldDisableInactiveTiles) + } + } + }) + + if (track) { + mutationObserver.observe(track.querySelector(".ugc-tiles")!, { + childList: true, + subtree: false + }) + + mutateTilesOnce(shouldDisableInactiveTiles) + } +} diff --git a/src/libs/vertical-expanded-tiles/products.swiper.ts b/src/libs/vertical-expanded-tiles/products.swiper.ts new file mode 100644 index 0000000..aee3c67 --- /dev/null +++ b/src/libs/vertical-expanded-tiles/products.swiper.ts @@ -0,0 +1,42 @@ +import type { SwiperType } from "../../" +import { EVENT_PRODUCT_NAVIGATION } from "../../events" +import { initializeSwiper } from "../extensions" +import { ISdk } from "src/types" + +export function loadProductsSwiper(sdk: ISdk, tileId: string, target: HTMLElement) { + if (target) { + const settings = sdk.getWidgetTemplateSettings().config?.components?.products?.swiper_options + const swiperCrossSell = target.querySelector(".swiper-expanded-product-recs") + + if (swiperCrossSell) { + initializeSwiper(sdk, { + id: `expanded-product-recs-${tileId}`, + mode: "expanded-product-recs", + widgetSelector: swiperCrossSell, + paramsOverrides: { + slidesPerView: "auto", + spaceBetween: 2, + mousewheel: { + enabled: false + }, + grabCursor: false, + on: { + navigationNext: () => { + sdk.triggerEvent(`${EVENT_PRODUCT_NAVIGATION}:${tileId}`, { + direction: "next" + }) + }, + navigationPrev: (swiper: SwiperType) => { + sdk.triggerEvent(`${EVENT_PRODUCT_NAVIGATION}:${tileId}`, { + direction: "previous" + }) + + swiper.update() + } + }, + ...settings + } + }) + } + } +} diff --git a/src/libs/vertical-expanded-tiles/products.template.tsx b/src/libs/vertical-expanded-tiles/products.template.tsx new file mode 100644 index 0000000..c8e9075 --- /dev/null +++ b/src/libs/vertical-expanded-tiles/products.template.tsx @@ -0,0 +1,134 @@ +import { ISdk, TagExtended, Tile, IProductsComponent } from "../../types" +import { createElement, createFragment } from "../jsx-html" + +declare const sdk: ISdk + +export function ProductCTA({ product }: { sdk: ISdk; product: TagExtended; tile: Tile }) { + const { custom_url, target, availability, cta_text = "Buy Now" } = product + + return ( + <> + + {cta_text} + + + ) +} + +export function ProductDetails({ sdk, product, tile }: { sdk: ISdk; product: TagExtended; tile: Tile }) { + const selectedProduct = sdk.getSelectedProduct() + const selectedProductId = selectedProduct ? selectedProduct.id : null + const { custom_url, id } = product + + const itemActive = id == selectedProductId ? "stacklapopup-products-item-active" : "" + + return ( + <> +
+ +
+ + ) +} + +export function ProductWrapper({ + products, + selectedProductId, + component +}: { + products: TagExtended[] + selectedProductId: string + component: IProductsComponent +}) { + const tileId = component?.getTileId() + const tile = sdk.getTileById(tileId) + + if (!tile) { + throw new Error("No tile found") + } + + return ( + <> + {products.map(product => { + const { id, image_small_url, is_cross_seller } = product + + return ( +
+
+ + +
+
+ ) + })} + + ) +} + +export function ProductImages({ + products, + selectedProduct, + component +}: { + products: TagExtended[] + selectedProduct: TagExtended + component: IProductsComponent +}) { + return ( + <> +
+
+
+ {selectedProduct && ( + + )} +
+
+
+ + ) +} + +export default function ProductsTemplate(sdk: ISdk, component?: IProductsComponent) { + const tileId = component && component.getTileId() + + if (!tileId) { + console.warn("No tile id found in ProductsTemplate") + return <> + } + + const tile = sdk.getTileById(tileId) + const selectedProductState = sdk.getSelectedProduct() + + if (!tile) { + throw new Error("No tile found") + } + + const products: TagExtended[] = (tile.tags_extended || []).filter(({ type }) => type === "product") + + if (!products.length) { + return <> + } + + const selectedProductById = selectedProductState + ? products.find(({ id }) => id == selectedProductState.id.toString()) + : null + + const selectedProduct: TagExtended = selectedProductById || products[0] + + return ( + <> + + + ) +} diff --git a/src/libs/vertical-expanded-tiles/tiktok-message.ts b/src/libs/vertical-expanded-tiles/tiktok-message.ts new file mode 100644 index 0000000..467c770 --- /dev/null +++ b/src/libs/vertical-expanded-tiles/tiktok-message.ts @@ -0,0 +1,47 @@ +type TiktokMessageType = "play" | "pause" | "mute" | "unMute" | "seekTo" + +/** + * Post tiktok messages to play a video/audio + * + * @param { Window } frameWindow - tiktok frame contentWindow + */ +export function playTiktokVideo(frameWindow: Window) { + postTiktokMessage(frameWindow, "unMute") + postTiktokMessage(frameWindow, "play") +} + +/** + * Post tiktok messages to pause a video/audio and reset the current video progress + * + * @param { Window } frameWindow - tiktok frame contentWindow + */ +export function pauseTiktokVideo(frameWindow: Window) { + postTiktokMessage(frameWindow, "mute") + postTiktokMessage(frameWindow, "pause") + postTiktokMessage(frameWindow, "seekTo", 0) +} + +export function muteTiktokVideo(frameWindow: Window) { + postTiktokMessage(frameWindow, "mute") +} + +export function unMuteTiktokVideo(frameWindow: Window) { + postTiktokMessage(frameWindow, "unMute") +} + +/** + * + * @param { Window } frameWindow - tiktok frame contentWindow + * @param { TiktokMessageType } type - message type to be posted ("play" | "pause" | "mute" | "unMute" | "seekTo") + * @param { number } value - position of media progress. 0 to reset progress + */ +export function postTiktokMessage(frameWindow: Window, type: TiktokMessageType, value?: number) { + frameWindow.postMessage({ type, value, "x-tiktok-player": true }, "https://www.tiktok.com") + + // Fallback logic to handle when TikTok player is not ready yet + window.addEventListener("message", event => { + if (event.data.type === "onPlayerReady" && event.data["x-tiktok-player"]) { + frameWindow.postMessage({ type, value, "x-tiktok-player": true }, "https://www.tiktok.com") + } + }) +} diff --git a/src/libs/vertical-expanded-tiles/tile-content.template.tsx b/src/libs/vertical-expanded-tiles/tile-content.template.tsx new file mode 100644 index 0000000..b6066cc --- /dev/null +++ b/src/libs/vertical-expanded-tiles/tile-content.template.tsx @@ -0,0 +1,145 @@ +import { createElement, createFragment } from "../jsx-html" +import { ISdk, Tile } from "../../" + +type RenderConfig = { + renderUserInfo: boolean + renderAvatarImage: boolean + renderUserName: boolean + renderUserHandle: boolean + renderDescription: boolean + renderCaption: boolean + renderTimephrase: boolean + renderShareMenu: boolean +} + +type UserInfoTemplateProps = { + tile: Tile + renderConfig: RenderConfig + sdk: ISdk +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function TileContentTemplate(sdk: ISdk, component: any) { + const tileId = component.getTileId() + const tile = sdk.getTileById(tileId) + const renderConfig = component.renderConfig + const sourceId = component.sourceId + const mode = component.mode + const widgetId = component.getWidgetId() + + if (!tile) { + console.warn("No tile found") + return <> + } + + let shareMenuTimephraseWrapper = <> + const shareMenu = renderConfig.renderShareMenu ? ( + + ) : ( + <> + ) + if (renderConfig.renderHeaderTimephrase) { + shareMenuTimephraseWrapper = ( + + ) + } else { + shareMenuTimephraseWrapper = shareMenu + } + + return ( + <> +
+
+ + {shareMenuTimephraseWrapper} +
+ + +
+ + ) +} + +function Description({ tile, renderConfig, widgetId }: { widgetId: string; tile: Tile; renderConfig: RenderConfig }) { + if (!renderConfig.renderDescription) { + return <> + } + + return ( +
+ {renderConfig.renderCaption && ( +
+
{tile.message}
+
+ )} + + {renderConfig.renderTimephrase && ( + + )} +
+ ) +} + +function getUsernameOrTerm(tile: Tile) { + const contentTags = tile.tags_extended?.filter( + tag => tag.type === "content" && tag.publicly_visible && tag.tag.includes("@") + ) + + const displayedItem = tile.user ?? (tile.terms && tile.terms.length ? tile.terms[0] : "") + + return contentTags?.length ? contentTags[0].tag : `@${displayedItem.length ? displayedItem : "nosto"}` +} + +function UserInfoTemplate(props: UserInfoTemplateProps) { + const { tile, renderConfig } = props + const { avatar, user } = tile + + if (!renderConfig.renderUserInfo) { + return <> + } + + const tileAvatar = ( + + + { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const currentTarget: HTMLImageElement | null = e.currentTarget as HTMLImageElement + currentTarget.style.display = "" + }} + onError={e => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const currentTarget: HTMLImageElement | null = e.currentTarget as HTMLImageElement + currentTarget.style.display = "none" + }} + /> + + + ) + + const tileUser = + user || tile.terms ? ( + + {getUsernameOrTerm(tile)} + + ) : ( + <> + ) + + return ( + + ) +} diff --git a/src/libs/vertical-expanded-tiles/tile.template.tsx b/src/libs/vertical-expanded-tiles/tile.template.tsx new file mode 100644 index 0000000..9193e6f --- /dev/null +++ b/src/libs/vertical-expanded-tiles/tile.template.tsx @@ -0,0 +1,191 @@ +import { ISdk, Tile } from "../../types" +import { createElement, createFragment } from "../jsx-html" +import { VideoContainer, VideoErrorFallbackTemplate } from "./video.templates" + +export type ExpandedTileProps = { + tile: Tile + sdk: ISdk +} + +export type ShopspotProps = { + shopspotEnabled: boolean + parent?: string + tileId: string + sdk: ISdk +} + +export type ContentWrapperProps = { + id: string + parent?: string + sdk: ISdk +} + +export async function togglePlayPause(sdk: ISdk) { + const expandedTiles = sdk.getExpandedTiles() + const activeSlide = expandedTiles.querySelector(".ugc-tile.swiper-slide-active") + const video = activeSlide?.querySelector("video") + const pauseButton = activeSlide?.querySelector(".pause-video") + const playButton = activeSlide?.querySelector(".play-video") + + if (video?.paused) { + void video?.play() + pauseButton?.classList.remove("hidden") + playButton?.classList.add("hidden") + } else { + void video?.pause() + pauseButton?.classList.add("hidden") + playButton?.classList.remove("hidden") + } +} + +export function StoryControls({ video, sdk }: { video: boolean; sdk: ISdk }) { + return ( +
+ {video ? ( + <> + togglePlayPause(sdk)} /> + +
+ ) +} + +export function VerticalExpandedTile({ tile, sdk }: ExpandedTileProps) { + const { show_shopspots, show_products, show_timestamp } = sdk.getExpandedTileConfig() + + const shopspotEnabled = sdk.isComponentLoaded("shopspots") && show_shopspots && !!tile.hotspots?.length + + let productsEnabled = false + + if (sdk.isComponentLoaded("products") && show_products && sdk.getProductTagsFromTile(tile)?.length) { + productsEnabled = true + } + + const sharingToolsEnabled = false + + return ( + <> +
+
+ + + +
+
+ {tile.media === "video" ? ( + + ) : ( + + )} + + {tile.media === "video" ? ( + <> + + + + ) : tile.media === "image" ? ( + + ) : tile.media === "text" ? ( + {tile.message} + ) : tile.media === "html" ? ( + {tile.html} + ) : ( + <> + )} +
+
+
+ + ) +} + +export function IconSection({ tile, sdk }: { tile: Tile; sdk: ISdk }) { + const parent = sdk.getNodeId() + const bottomSectionIconContent = [] + + bottomSectionIconContent.push( + + ) + + return ( +
+
{...bottomSectionIconContent}
+
+ ) +} + +export function ShopSpotTemplate({ sdk, shopspotEnabled, parent, tileId }: ShopspotProps) { + return shopspotEnabled ? ( + <> + + + ) : ( + <> + ) +} + +export function ImageTemplate({ + tile, + image, + shopspotEnabled = false, + parent, + sdk +}: { + tile: Tile + image: string + shopspotEnabled?: boolean + parent?: string + sdk: ISdk +}) { + return image ? ( + <> +
+
+ {shopspotEnabled ? ( + + ) : ( + <> + )} +
+ + ) : ( + <> + ) +} + +function AutoplayProgress() { + return ( +
+
+
+
+
+ ) +} diff --git a/src/libs/vertical-expanded-tiles/video.templates.tsx b/src/libs/vertical-expanded-tiles/video.templates.tsx new file mode 100644 index 0000000..2c92ef2 --- /dev/null +++ b/src/libs/vertical-expanded-tiles/video.templates.tsx @@ -0,0 +1,231 @@ +import { getMutatedId } from "../extensions" +import { Tile, createElement, createFragment } from "../../" +import { storyAutoplayProgress } from "./expanded-swiper.loader" +import { ImageTemplate, ShopSpotTemplate } from "./tile.template" +import { EmbedYoutube } from "./embed-youtube.template" +import { ISdk } from "../../types" + +type OnLoad = (event: Event) => void + +function getVideoData(tile: Tile) { + if (tile.video_files?.length) { + return tile.video_files[0] + } + + if (tile.video && tile.video.standard_resolution) { + return { + width: "auto", + height: "auto", + mime: "video/mp4", + url: tile.video.standard_resolution.url + } + } + + console.error("Failed to find video data") + + return +} + +export function handlePauseAutoplay(swiperId: string) { + const swiperInstance = window.ugc.swiperContainer[swiperId].instance + if (swiperInstance) { + swiperInstance?.autoplay?.stop() + } else { + console.error(`Swiper instance for id ${swiperId} not found`) + } +} + +export function handlePlayAutoplay(swiperId: string) { + const swiperInstance = window.ugc.swiperContainer[swiperId].instance + if (swiperInstance) { + swiperInstance?.autoplay?.start() + } else { + console.error(`Swiper instance for id ${swiperId} not found`) + } +} + +export function UgcVideoTemplate({ + tile, + onLoad, + swiperId, + controls = true +}: { + tile: Tile + onLoad: OnLoad + swiperId: string + controls?: boolean +}) { + const videoData = getVideoData(tile) + if (!videoData) { + return <> + } + + const { url, width, height, mime } = videoData + + return ( + + ) +} + +export function TikTokTemplate({ tile, onLoad }: { tile: Tile; onLoad: OnLoad }) { + const tiktokId = tile.tiktok_id + + return ( +