UNPKG

@revenuecat/purchases-ui-js

Version:

Web components for Paywalls. Powered by RevenueCat

212 lines (211 loc) 9.53 kB
import { mapColorInfo, mapColorMode } from "./base-utils"; export const SAFE_AREA_FALLBACK_COLOR_CSS_VAR = "--rc-purchases-ui-bg-color"; // Light-only schemes must not bleed into the dark media query — a dark rule // reading from `light` would override the light value. function getSafeAreaFallbackColorInfoForMode(paywallOverride, hostFallback, colorMode) { const colorAtMode = (scheme) => colorMode === "dark" ? scheme?.dark : scheme?.light; return colorAtMode(paywallOverride) ?? colorAtMode(hostFallback) ?? null; } const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/; function isHexColor(value) { return HEX_COLOR_REGEX.test(value); } function isOpaqueHexColor(value) { const match = HEX_COLOR_REGEX.exec(value); return match != null && (match[1] == null || match[1].toLowerCase() === "ff"); } function normalizeHexColor(color) { return color.length === 7 ? `${color.toLowerCase()}ff` : color.toLowerCase(); } function getCssHexColor(value) { return value?.type === "hex" && isHexColor(value.value) ? value.value : null; } function isAxisAlignedVerticalGradient(degrees) { return Math.abs(((degrees % 180) + 180) % 180) < 1e-10; } // Alias-aware counterpart for the SDK runtime; mapColorInfo resolves aliases // that the hex-only path can't. export function resolveSafeAreaFallbackCss(paywallOverride, hostFallback, colorMode) { const info = getSafeAreaFallbackColorInfoForMode(paywallOverride, hostFallback, colorMode); return info ? mapColorInfo(info) : null; } // SSR has no real viewport (no off-axis/radial sampling). Hex and alias // backgrounds short-circuit immediately; axis-aligned vertical gradients go // through the sticky-component disambiguation since edges may differ. Alias // values are emitted as var() references — valid CSS that resolves once the // SDK stylesheet is present. Invalid hex is skipped rather than emitted. function getSsrSafeAreaCssForMode(paywallData, hostFallback, colorMode) { const base = paywallData?.components_config?.base; const background = base?.background; if (background?.type === "color") { const color = mapColorMode(colorMode, background.value); if (color.type === "hex") { const hex = getCssHexColor(color); if (hex) return hex; } else if (color.type === "alias") { return mapColorInfo(color); } else if (color.type === "linear" && isAxisAlignedVerticalGradient(color.degrees)) { const edges = getBackgroundSafeAreaColors(background, colorMode, { width: 1, height: 1 }, { stickyComponents: { hasHeader: base?.header != null, hasFooter: base?.sticky_footer != null, }, }); const sampled = edges.top ?? edges.bottom; if (sampled && isHexColor(sampled)) return sampled; } } const fallbackInfo = getSafeAreaFallbackColorInfoForMode(base?.safe_area_fallback_color, hostFallback, colorMode); if (fallbackInfo == null) return null; return fallbackInfo.type === "alias" ? mapColorInfo(fallbackInfo) : getCssHexColor(fallbackInfo); } export function getSafeAreaFallbackColorHeadStyles(paywallData, hostFallbackColor) { return ["light", "dark"] .map((mode) => { const value = getSsrSafeAreaCssForMode(paywallData, hostFallbackColor, mode); return value ? `@media (prefers-color-scheme: ${mode}) { html, body { ${SAFE_AREA_FALLBACK_COLOR_CSS_VAR}: ${value}; } }` : null; }) .filter(Boolean) .join("\n"); } function colorAtPercent(sortedPoints, percent) { const firstPoint = sortedPoints[0]; const lastPoint = sortedPoints.at(-1); if (firstPoint == null || lastPoint == null) { return null; } if (percent <= firstPoint.percent) { return normalizeHexColor(firstPoint.color); } if (percent >= lastPoint.percent) { return normalizeHexColor(lastPoint.color); } const nextPointIndex = sortedPoints.findIndex((point) => point.percent >= percent); const previousPoint = sortedPoints[nextPointIndex - 1]; const nextPoint = sortedPoints[nextPointIndex]; if (previousPoint == null || nextPoint == null) { return null; } if (previousPoint.color.toLowerCase() === nextPoint.color.toLowerCase()) { return normalizeHexColor(previousPoint.color); } if (previousPoint.percent === nextPoint.percent) { return normalizeHexColor(nextPoint.color); } return null; } function getSortedGradientPoints(points) { return points .filter((point) => Number.isFinite(point.percent) && isHexColor(point.color)) .slice() .sort((a, b) => a.percent - b.percent); } function solidColorForRange(sortedPoints, [rangeStart, rangeEnd]) { const expectedColor = colorAtPercent(sortedPoints, rangeStart); if (expectedColor == null || !isOpaqueHexColor(expectedColor)) { return null; } const breakpoints = [ rangeStart, ...sortedPoints .map((point) => point.percent) .filter((percent) => percent > rangeStart && percent < rangeEnd), rangeEnd, ]; const isSolid = breakpoints.every((breakpoint, index) => { if (colorAtPercent(sortedPoints, breakpoint) !== expectedColor) { return false; } const nextBreakpoint = breakpoints[index + 1]; if (nextBreakpoint == null || nextBreakpoint === breakpoint) { return true; } return (colorAtPercent(sortedPoints, (breakpoint + nextBreakpoint) / 2) === expectedColor); }); return isSolid ? expectedColor : null; } function snapToZero(value) { return Math.abs(value) < 1e-10 ? 0 : value; } function linearGradientPositionPercent(degrees, x, y, viewport) { const radians = (degrees * Math.PI) / 180; // Math.sin(Math.PI) is ~1.22e-16, not 0. Snap so a 180° gradient's edge // sits exactly on a stop instead of just barely missing it. const dx = snapToZero(Math.sin(radians)); const dy = snapToZero(-Math.cos(radians)); const gradientLength = Math.abs(dx) * viewport.width + Math.abs(dy) * viewport.height; const offset = (x - viewport.width / 2) * dx + (y - viewport.height / 2) * dy; return ((offset + gradientLength / 2) / gradientLength) * 100; } function linearEdgeRangePercent(degrees, edge, viewport) { const y = edge === "top" ? 0 : viewport.height; const start = linearGradientPositionPercent(degrees, 0, y, viewport); const end = linearGradientPositionPercent(degrees, viewport.width, y, viewport); return [Math.min(start, end), Math.max(start, end)]; } function radialGradientPositionPercent(x, y, viewport) { const radius = Math.hypot(viewport.width / 2, viewport.height / 2); const distance = Math.hypot(x - viewport.width / 2, y - viewport.height / 2); return (distance / radius) * 100; } function radialEdgeRangePercent(edge, viewport) { const y = edge === "top" ? 0 : viewport.height; const center = radialGradientPositionPercent(viewport.width / 2, y, viewport); const corner = radialGradientPositionPercent(0, y, viewport); return [Math.min(center, corner), Math.max(center, corner)]; } function getSolidEdgeColor(color, edge, viewport, sortedPoints) { switch (color.type) { case "hex": case "alias": return mapColorInfo(color); case "linear": return solidColorForRange(sortedPoints ?? [], linearEdgeRangePercent(color.degrees, edge, viewport)); case "radial": return solidColorForRange(sortedPoints ?? [], radialEdgeRangePercent(edge, viewport)); } } // `position: sticky` promotes a header/footer to its own compositor layer // regardless of fill, so even transparent sticky elements break the opposite // safe-area strip. Pass `stickyComponents` when painting into a single CSS // variable (SDK runtime + SSR head) so the covered edge is nulled and the // caller can fall through to the override/host chain; editor previews leave // it absent. export function getBackgroundSafeAreaColors(background, colorMode, viewport, options) { let top = null; let bottom = null; if (background?.type === "color") { const color = mapColorMode(colorMode, background.value); const sortedPoints = color.type === "linear" || color.type === "radial" ? getSortedGradientPoints(color.points) : null; top = getSolidEdgeColor(color, "top", viewport, sortedPoints); bottom = getSolidEdgeColor(color, "bottom", viewport, sortedPoints); } if (options?.stickyComponents) { const hasHeader = options.stickyComponents.hasHeader === true; const hasFooter = options.stickyComponents.hasFooter === true; // A single CSS variable can only paint one strip; emit only when exactly // one edge is exposed (the opposite edge is occluded by a sticky element). top = hasFooter && !hasHeader ? top : null; bottom = hasHeader && !hasFooter ? bottom : null; } if (options?.paywallFallbackColor != null || options?.hostFallbackColor != null) { const fallback = resolveSafeAreaFallbackCss(options.paywallFallbackColor, options.hostFallbackColor, colorMode); top = top ?? fallback; bottom = bottom ?? fallback; } return { top, bottom }; }