@revenuecat/purchases-ui-js
Version:
Web components for Paywalls. Powered by RevenueCat
212 lines (211 loc) • 9.53 kB
JavaScript
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 };
}