@revenuecat/purchases-ui-js 3.12.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/paywall/Paywall.stories.svelte +21 -4
- package/dist/components/paywall/Paywall.svelte +43 -21
- package/dist/components/paywall/ViewportBackdrop.svelte +61 -0
- package/dist/components/paywall/ViewportBackdrop.svelte.d.ts +7 -0
- package/dist/components/video/Video.svelte +208 -19
- package/dist/components/workflows/Screen.stories.svelte +39 -0
- package/dist/utils/background-utils.d.ts +15 -0
- package/dist/utils/background-utils.js +21 -0
- package/dist/utils/document-background.d.ts +3 -0
- package/dist/utils/document-background.js +59 -0
- package/package.json +2 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script module lang="ts">
|
|
2
2
|
import { defineMeta } from "@storybook/addon-svelte-csf";
|
|
3
|
+
import type { ComponentProps } from "svelte";
|
|
3
4
|
|
|
4
5
|
import Paywall from "./Paywall.svelte";
|
|
5
6
|
import {
|
|
@@ -31,12 +32,16 @@
|
|
|
31
32
|
import { CUSTOM_VARIABLES_PAYWALL } from "./fixtures/custom-variables-paywall";
|
|
32
33
|
import { COUNTDOWN_PAYWALL } from "../countdown/fixtures/countdown-paywall";
|
|
33
34
|
import { mockDateDecorator } from "storybook-mock-date-decorator";
|
|
34
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
IndividualPackageVariables,
|
|
37
|
+
DUELINGUE_PAYWALL,
|
|
38
|
+
} from "./fixtures/express-purchase-button-paywall";
|
|
35
39
|
import { CustomVariableValue } from "../../types/variables";
|
|
36
40
|
|
|
37
41
|
const { Story } = defineMeta({
|
|
38
42
|
title: "Example/Paywall",
|
|
39
43
|
component: Paywall,
|
|
44
|
+
render: template,
|
|
40
45
|
args: {
|
|
41
46
|
onPurchaseClicked: (selectedPackageId: string, actionId: string) =>
|
|
42
47
|
alert(
|
|
@@ -55,9 +60,11 @@
|
|
|
55
60
|
});
|
|
56
61
|
</script>
|
|
57
62
|
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
{#snippet template(props: ComponentProps<typeof Paywall>)}
|
|
64
|
+
<div class="paywall-story-frame">
|
|
65
|
+
<Paywall {...props} />
|
|
66
|
+
</div>
|
|
67
|
+
{/snippet}
|
|
61
68
|
|
|
62
69
|
<Story
|
|
63
70
|
name="Stack paywall"
|
|
@@ -491,3 +498,13 @@
|
|
|
491
498
|
},
|
|
492
499
|
}}
|
|
493
500
|
/>
|
|
501
|
+
|
|
502
|
+
<style>
|
|
503
|
+
.paywall-story-frame {
|
|
504
|
+
flex: 1 1 auto;
|
|
505
|
+
display: flex;
|
|
506
|
+
flex-direction: column;
|
|
507
|
+
background-color: var(--rc-purchases-ui-bg-color, Canvas);
|
|
508
|
+
color-scheme: light dark;
|
|
509
|
+
}
|
|
510
|
+
</style>
|
|
@@ -32,15 +32,17 @@
|
|
|
32
32
|
type PackageInfo,
|
|
33
33
|
type VariableDictionary,
|
|
34
34
|
} from "../../types/variables";
|
|
35
|
-
import {
|
|
35
|
+
import { paywallRootBackgroundModel } from "../../utils/background-utils";
|
|
36
36
|
import { css } from "../../utils/base-utils";
|
|
37
37
|
import { STICKY_OVERLAY_Z_INDEX } from "../../utils/constants";
|
|
38
|
+
import { applyDocumentBackground } from "../../utils/document-background";
|
|
38
39
|
import { registerFonts } from "../../utils/font-utils";
|
|
39
40
|
import { findSelectedPackageId } from "../../utils/style-utils";
|
|
40
41
|
import { onMount } from "svelte";
|
|
41
42
|
import { derived, readable, writable } from "svelte/store";
|
|
42
43
|
import Stack from "../stack/Stack.svelte";
|
|
43
44
|
import Sheet from "./Sheet.svelte";
|
|
45
|
+
import ViewportBackdrop from "./ViewportBackdrop.svelte";
|
|
44
46
|
import {
|
|
45
47
|
type PackageInfoStore,
|
|
46
48
|
setPackageInfoContext,
|
|
@@ -131,13 +133,38 @@
|
|
|
131
133
|
}: Props = $props();
|
|
132
134
|
|
|
133
135
|
const getColorMode = setColorModeContext(() => preferredColorMode);
|
|
134
|
-
|
|
136
|
+
|
|
137
|
+
const viewportBackdropModel = $derived(
|
|
138
|
+
paywallRootBackgroundModel(paywallData, getColorMode()),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const instanceId: symbol = Symbol();
|
|
142
|
+
$effect(() =>
|
|
143
|
+
applyDocumentBackground(instanceId, viewportBackdropModel, paywallData),
|
|
144
|
+
);
|
|
135
145
|
|
|
136
146
|
const { default_locale, components_config, components_localizations } =
|
|
137
147
|
paywallData;
|
|
138
148
|
const { base } = components_config;
|
|
139
149
|
const defaultPackageId = findSelectedPackageId(base);
|
|
140
150
|
|
|
151
|
+
// Solid colours paint here so the paywall has its own bg without requiring
|
|
152
|
+
// consumer CSS. Gradients and images are deliberately skipped — ViewportBackdrop
|
|
153
|
+
// paints those at viewport scale, and rendering them on both surfaces would
|
|
154
|
+
// anchor the gradient stops to two different boxes and produce a visible seam
|
|
155
|
+
// at the paywall edge.
|
|
156
|
+
const paywallStyle = $derived.by((): string => {
|
|
157
|
+
const m = viewportBackdropModel;
|
|
158
|
+
if (
|
|
159
|
+
m.kind === "style" &&
|
|
160
|
+
m.style.background &&
|
|
161
|
+
!m.style.background.includes("gradient")
|
|
162
|
+
) {
|
|
163
|
+
return css(m.style);
|
|
164
|
+
}
|
|
165
|
+
return "";
|
|
166
|
+
});
|
|
167
|
+
|
|
141
168
|
const { getLocalizedString } = setLocalizationContext(() => ({
|
|
142
169
|
defaultLocale: default_locale,
|
|
143
170
|
selectedLocale,
|
|
@@ -285,8 +312,6 @@
|
|
|
285
312
|
|
|
286
313
|
setPackageInfoContext(packageInfo);
|
|
287
314
|
|
|
288
|
-
const style = $derived(css(mapBackground(colorMode, null, base.background)));
|
|
289
|
-
|
|
290
315
|
onMount(() => {
|
|
291
316
|
registerFonts(uiConfig);
|
|
292
317
|
});
|
|
@@ -305,7 +330,8 @@
|
|
|
305
330
|
</script>
|
|
306
331
|
|
|
307
332
|
<svelte:boundary onerror={onError}>
|
|
308
|
-
<div class={paywallClass} {
|
|
333
|
+
<div class={paywallClass} style={paywallStyle}>
|
|
334
|
+
<ViewportBackdrop model={viewportBackdropModel} />
|
|
309
335
|
{#if header}
|
|
310
336
|
<div
|
|
311
337
|
class="header-wrapper"
|
|
@@ -321,12 +347,20 @@
|
|
|
321
347
|
maxContentWidth
|
|
322
348
|
? `max-width: ${maxContentWidth}; margin-inline: auto;`
|
|
323
349
|
: "",
|
|
324
|
-
pullContentUnderHeader ? `margin-top: -${headerHeight}px;` : "",
|
|
325
350
|
]
|
|
326
351
|
.filter(Boolean)
|
|
327
352
|
.join(" ")}
|
|
328
353
|
>
|
|
329
|
-
<Stack
|
|
354
|
+
<Stack
|
|
355
|
+
{...stack}
|
|
356
|
+
class="paywall-content-scroll"
|
|
357
|
+
style={{
|
|
358
|
+
height: "auto",
|
|
359
|
+
...(pullContentUnderHeader
|
|
360
|
+
? { "margin-top": `-${headerHeight}px` }
|
|
361
|
+
: {}),
|
|
362
|
+
}}
|
|
363
|
+
/>
|
|
330
364
|
{#if sticky_footer}
|
|
331
365
|
<Footer {...sticky_footer} />
|
|
332
366
|
{/if}
|
|
@@ -351,18 +385,6 @@
|
|
|
351
385
|
transition-timing-function: ease-in-out;
|
|
352
386
|
transform-origin: center;
|
|
353
387
|
|
|
354
|
-
&::before {
|
|
355
|
-
content: "";
|
|
356
|
-
position: absolute;
|
|
357
|
-
top: 0;
|
|
358
|
-
left: 0;
|
|
359
|
-
width: 100%;
|
|
360
|
-
height: 100%;
|
|
361
|
-
background: var(--overlay);
|
|
362
|
-
pointer-events: none;
|
|
363
|
-
z-index: 1;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
388
|
&:global(.blur) {
|
|
367
389
|
filter: blur(10px) brightness(0.8);
|
|
368
390
|
transform: scale(1.045);
|
|
@@ -378,13 +400,13 @@
|
|
|
378
400
|
.paywall-content {
|
|
379
401
|
z-index: 2;
|
|
380
402
|
width: 100%;
|
|
381
|
-
flex: 1;
|
|
403
|
+
flex: 1 1 auto;
|
|
382
404
|
display: flex;
|
|
383
405
|
flex-direction: column;
|
|
384
406
|
min-height: 0;
|
|
385
407
|
|
|
386
408
|
& > :global(.paywall-content-scroll) {
|
|
387
|
-
flex
|
|
409
|
+
flex: 1 1 auto;
|
|
388
410
|
min-height: 0;
|
|
389
411
|
overflow-y: auto;
|
|
390
412
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { PaywallRootBackgroundModel } from "../../utils/background-utils";
|
|
3
|
+
|
|
4
|
+
// iOS WebKit only reliably propagates background-color (not background-image)
|
|
5
|
+
// through to the safe-area canvas. A position:fixed element that explicitly
|
|
6
|
+
// fills the viewport — including safe areas — is the only consistent paint
|
|
7
|
+
// surface for gradients and images on iOS Safari.
|
|
8
|
+
const { model }: { model: PaywallRootBackgroundModel } = $props();
|
|
9
|
+
|
|
10
|
+
const backdropStyle = $derived.by((): string => {
|
|
11
|
+
if (
|
|
12
|
+
model.kind === "style" &&
|
|
13
|
+
model.style.background?.includes("gradient")
|
|
14
|
+
) {
|
|
15
|
+
return Object.entries(model.style)
|
|
16
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
17
|
+
.join(";");
|
|
18
|
+
}
|
|
19
|
+
if (model.kind === "image") {
|
|
20
|
+
return [
|
|
21
|
+
`background-image:url("${model.src}")`,
|
|
22
|
+
`background-size:${model.fit}`,
|
|
23
|
+
`background-position:${model.position}`,
|
|
24
|
+
`background-repeat:no-repeat`,
|
|
25
|
+
].join(";");
|
|
26
|
+
}
|
|
27
|
+
return "";
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const overlayStyle = $derived(
|
|
31
|
+
model.kind === "image" && model.overlay && model.overlay !== "none"
|
|
32
|
+
? `background:${model.overlay}`
|
|
33
|
+
: null,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const shouldRender = $derived(backdropStyle !== "");
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
{#if shouldRender}
|
|
40
|
+
<div class="viewport-backdrop" style={backdropStyle}>
|
|
41
|
+
{#if overlayStyle}
|
|
42
|
+
<div class="viewport-backdrop-overlay" style={overlayStyle}></div>
|
|
43
|
+
{/if}
|
|
44
|
+
</div>
|
|
45
|
+
{/if}
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
.viewport-backdrop {
|
|
49
|
+
position: fixed;
|
|
50
|
+
inset: 0;
|
|
51
|
+
z-index: 0;
|
|
52
|
+
pointer-events: none;
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.viewport-backdrop-overlay {
|
|
57
|
+
position: absolute;
|
|
58
|
+
inset: 0;
|
|
59
|
+
pointer-events: none;
|
|
60
|
+
}
|
|
61
|
+
</style>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PaywallRootBackgroundModel } from "../../utils/background-utils";
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
model: PaywallRootBackgroundModel;
|
|
4
|
+
};
|
|
5
|
+
declare const ViewportBackdrop: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type ViewportBackdrop = ReturnType<typeof ViewportBackdrop>;
|
|
7
|
+
export default ViewportBackdrop;
|
|
@@ -70,7 +70,13 @@
|
|
|
70
70
|
});
|
|
71
71
|
let videoElement = $state<HTMLVideoElement | null>(null);
|
|
72
72
|
let hasVideoError = $state(false);
|
|
73
|
-
|
|
73
|
+
/**
|
|
74
|
+
* When `mute_audio` is false, unmuted `play()` often fails after a full reload (autoplay policy).
|
|
75
|
+
* Retrying with muted playback requires keeping the `<video muted>` binding in sync with the element.
|
|
76
|
+
*/
|
|
77
|
+
let playbackMutedOverride = $state(false);
|
|
78
|
+
/** Prevents repeated `load()` / `play()` nudge when the autoplay $effect re-runs (was resetting iOS to readyState 0). */
|
|
79
|
+
let lastAutoplayBootstrapUrl = $state<string | null>(null);
|
|
74
80
|
|
|
75
81
|
// Load video or fallback image metadata to get dimensions
|
|
76
82
|
$effect(() => {
|
|
@@ -138,7 +144,13 @@
|
|
|
138
144
|
if (size.height.type === "fixed") {
|
|
139
145
|
return size.height.value;
|
|
140
146
|
}
|
|
141
|
-
|
|
147
|
+
const w = videoSize.width;
|
|
148
|
+
const h = videoSize.height;
|
|
149
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
|
|
150
|
+
// Avoid 0-height box and NaN before metadata (`NaN <= 0` is false); 16:9 placeholder until dimensions resolve.
|
|
151
|
+
return wrapperWidth > 0 ? Math.round(wrapperWidth * (9 / 16)) : 200;
|
|
152
|
+
}
|
|
153
|
+
return Math.round(wrapperWidth * (h / w));
|
|
142
154
|
});
|
|
143
155
|
|
|
144
156
|
const style = $derived(
|
|
@@ -195,42 +207,211 @@
|
|
|
195
207
|
|
|
196
208
|
const shouldShowFallback = $derived(hasVideoError || !video);
|
|
197
209
|
|
|
198
|
-
//
|
|
210
|
+
// Autoplay: native `autoplay` + programmatic `play()`. Unmuted autoplay is often blocked; we retry
|
|
211
|
+
// once with muted playback (`playbackMutedOverride`) then try to restore sound. Controls follow
|
|
212
|
+
// `show_controls` only. Bootstrap once per URL; `play()` when `readyState >= HAVE_CURRENT_DATA`.
|
|
213
|
+
// Microtask + rAF retries cover decode/policy ordering; refresh leaves `document` already
|
|
214
|
+
// `complete` (`load` won't fire)—extra microtask retries `tryPlay` for muted→unmuted paths.
|
|
215
|
+
// Pair with `translateZ(0)` + rc-workflows `screen-slide-fade`.
|
|
199
216
|
$effect(() => {
|
|
200
217
|
if (!auto_play) {
|
|
201
218
|
untrack(() => {
|
|
202
|
-
|
|
219
|
+
playbackMutedOverride = false;
|
|
203
220
|
});
|
|
204
221
|
return;
|
|
205
222
|
}
|
|
206
223
|
|
|
207
224
|
if (shouldShowFallback || !video) {
|
|
208
225
|
untrack(() => {
|
|
209
|
-
|
|
226
|
+
playbackMutedOverride = false;
|
|
210
227
|
});
|
|
211
228
|
return;
|
|
212
229
|
}
|
|
213
230
|
|
|
214
231
|
const el = videoElement;
|
|
215
|
-
if (!el)
|
|
216
|
-
|
|
232
|
+
if (!el) return;
|
|
233
|
+
|
|
234
|
+
const vurl = video.url;
|
|
235
|
+
|
|
236
|
+
const needsBootstrap = lastAutoplayBootstrapUrl !== vurl;
|
|
237
|
+
if (needsBootstrap) {
|
|
238
|
+
lastAutoplayBootstrapUrl = vurl;
|
|
239
|
+
untrack(() => {
|
|
240
|
+
playbackMutedOverride = false;
|
|
241
|
+
});
|
|
217
242
|
}
|
|
218
243
|
|
|
219
|
-
|
|
220
|
-
|
|
244
|
+
let cancelled = false;
|
|
245
|
+
|
|
246
|
+
/** Keep `muted` / `playsInline` aligned with `mute_audio`, `playbackMutedOverride`, and the template. */
|
|
247
|
+
const applyVideoPlaybackFlags = () => {
|
|
248
|
+
el.muted = mute_audio || playbackMutedOverride;
|
|
249
|
+
el.playsInline = true;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
let playingUnmuteListener: (() => void) | null = null;
|
|
253
|
+
|
|
254
|
+
const clearPlayingUnmuteListener = () => {
|
|
255
|
+
if (playingUnmuteListener !== null) {
|
|
256
|
+
el.removeEventListener("playing", playingUnmuteListener);
|
|
257
|
+
playingUnmuteListener = null;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const tryPlay = () => {
|
|
262
|
+
if (cancelled) return;
|
|
263
|
+
if (!el.paused) return;
|
|
221
264
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
265
|
+
applyVideoPlaybackFlags();
|
|
266
|
+
|
|
267
|
+
if (el.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
225
270
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
271
|
+
const playPromise = el.play();
|
|
272
|
+
if (playPromise !== undefined) {
|
|
273
|
+
playPromise.catch(() => {
|
|
274
|
+
if (!mute_audio && !playbackMutedOverride) {
|
|
275
|
+
untrack(() => {
|
|
276
|
+
playbackMutedOverride = true;
|
|
277
|
+
});
|
|
278
|
+
applyVideoPlaybackFlags();
|
|
279
|
+
|
|
280
|
+
/** Unmuted `play()` after a forced-muted start sometimes only works tied to playback (WebKit). */
|
|
281
|
+
let unmuteAfterMutedStartHandled = false;
|
|
282
|
+
const tryRestoreAudioAfterMutedAutoplay = () => {
|
|
283
|
+
if (unmuteAfterMutedStartHandled || mute_audio || cancelled)
|
|
284
|
+
return;
|
|
285
|
+
unmuteAfterMutedStartHandled = true;
|
|
286
|
+
queueMicrotask(() => {
|
|
287
|
+
if (cancelled) return;
|
|
288
|
+
untrack(() => {
|
|
289
|
+
playbackMutedOverride = false;
|
|
290
|
+
});
|
|
291
|
+
applyVideoPlaybackFlags();
|
|
292
|
+
el.volume = 1;
|
|
293
|
+
const p3 = el.play();
|
|
294
|
+
if (p3 === undefined) return;
|
|
295
|
+
p3.catch(() => {
|
|
296
|
+
untrack(() => {
|
|
297
|
+
playbackMutedOverride = true;
|
|
298
|
+
});
|
|
299
|
+
applyVideoPlaybackFlags();
|
|
300
|
+
void el.play().catch(() => {});
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const onPlayingAfterMutedBootstrap = () => {
|
|
306
|
+
playingUnmuteListener = null;
|
|
307
|
+
tryRestoreAudioAfterMutedAutoplay();
|
|
308
|
+
};
|
|
309
|
+
playingUnmuteListener = onPlayingAfterMutedBootstrap;
|
|
310
|
+
el.addEventListener("playing", onPlayingAfterMutedBootstrap, {
|
|
311
|
+
once: true,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const p2 = el.play();
|
|
315
|
+
if (p2 !== undefined) {
|
|
316
|
+
p2.then(() => {
|
|
317
|
+
// `playing` may have fired before our listener ran; retry unmute once playback exists.
|
|
318
|
+
queueMicrotask(() => {
|
|
319
|
+
if (
|
|
320
|
+
cancelled ||
|
|
321
|
+
mute_audio ||
|
|
322
|
+
unmuteAfterMutedStartHandled ||
|
|
323
|
+
el.paused
|
|
324
|
+
) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!el.muted) {
|
|
328
|
+
clearPlayingUnmuteListener();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
tryRestoreAudioAfterMutedAutoplay();
|
|
332
|
+
});
|
|
333
|
+
}).catch(() => {
|
|
334
|
+
clearPlayingUnmuteListener();
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
231
338
|
});
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const onMediaReady = () => {
|
|
343
|
+
tryPlay();
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
if (el.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
347
|
+
el.addEventListener("loadeddata", onMediaReady);
|
|
348
|
+
el.addEventListener("canplay", onMediaReady);
|
|
349
|
+
} else {
|
|
350
|
+
tryPlay();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Once per `video.url`: muted `play()` nudge helps iOS decode before `HAVE_CURRENT_DATA`.
|
|
355
|
+
* When `mute_audio` is false, `load()` reloads the media so poster/metadata can attach; unmuted
|
|
356
|
+
* autoplay may still require a user gesture (handled in `tryPlay`).
|
|
357
|
+
*/
|
|
358
|
+
const nudgePlayback = () => {
|
|
359
|
+
applyVideoPlaybackFlags();
|
|
360
|
+
|
|
361
|
+
if (mute_audio) {
|
|
362
|
+
void el.play().catch(() => {});
|
|
363
|
+
} else {
|
|
364
|
+
try {
|
|
365
|
+
el.load();
|
|
366
|
+
} catch {
|
|
367
|
+
/* ignore */
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
if (needsBootstrap && el.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
372
|
+
nudgePlayback();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Extra `tryPlay` ticks: next microtask vs next frame vs cold `load`; WebKit reordering varies. */
|
|
376
|
+
const deferTryPlayWithReadyGate = () => {
|
|
377
|
+
queueMicrotask(() => {
|
|
378
|
+
if (cancelled) return;
|
|
379
|
+
if (el.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
380
|
+
tryPlay();
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
requestAnimationFrame(() => {
|
|
384
|
+
if (cancelled) return;
|
|
385
|
+
if (el.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
386
|
+
tryPlay();
|
|
387
|
+
}
|
|
232
388
|
});
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
deferTryPlayWithReadyGate();
|
|
392
|
+
|
|
393
|
+
const onWindowLoad = () => {
|
|
394
|
+
tryPlay();
|
|
395
|
+
};
|
|
396
|
+
if (typeof document !== "undefined" && typeof window !== "undefined") {
|
|
397
|
+
if (document.readyState === "complete") {
|
|
398
|
+
queueMicrotask(() => {
|
|
399
|
+
if (!cancelled) tryPlay();
|
|
400
|
+
});
|
|
401
|
+
} else {
|
|
402
|
+
window.addEventListener("load", onWindowLoad);
|
|
403
|
+
}
|
|
233
404
|
}
|
|
405
|
+
|
|
406
|
+
return () => {
|
|
407
|
+
cancelled = true;
|
|
408
|
+
clearPlayingUnmuteListener();
|
|
409
|
+
el.removeEventListener("loadeddata", onMediaReady);
|
|
410
|
+
el.removeEventListener("canplay", onMediaReady);
|
|
411
|
+
if (typeof window !== "undefined") {
|
|
412
|
+
window.removeEventListener("load", onWindowLoad);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
234
415
|
});
|
|
235
416
|
|
|
236
417
|
const isVisible = $derived(
|
|
@@ -262,9 +443,11 @@
|
|
|
262
443
|
src={video.url}
|
|
263
444
|
style="object-fit:{mapFitMode(fit_mode)}"
|
|
264
445
|
{loop}
|
|
265
|
-
muted={mute_audio}
|
|
266
|
-
controls={show_controls
|
|
446
|
+
muted={mute_audio || playbackMutedOverride}
|
|
447
|
+
controls={show_controls}
|
|
267
448
|
playsinline
|
|
449
|
+
autoplay={auto_play}
|
|
450
|
+
preload={auto_play ? "auto" : "metadata"}
|
|
268
451
|
>
|
|
269
452
|
<track kind="captions" />
|
|
270
453
|
</video>
|
|
@@ -352,4 +535,10 @@
|
|
|
352
535
|
object-fit: contain;
|
|
353
536
|
object-position: center;
|
|
354
537
|
}
|
|
538
|
+
|
|
539
|
+
/* Own compositing layer — mitigates WebKit freezing video frames under animated/transform ancestors */
|
|
540
|
+
video {
|
|
541
|
+
transform: translateZ(0);
|
|
542
|
+
-webkit-transform: translateZ(0);
|
|
543
|
+
}
|
|
355
544
|
</style>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script module lang="ts">
|
|
2
2
|
import { defineMeta } from "@storybook/addon-svelte-csf";
|
|
3
|
+
import type { ComponentProps } from "svelte";
|
|
3
4
|
|
|
4
5
|
import Screen from "./Screen.svelte";
|
|
5
6
|
import { paywallData, uiConfigData } from "../../stories/fixtures";
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
const { Story } = defineMeta({
|
|
21
22
|
title: "Components/Screen",
|
|
22
23
|
component: Screen,
|
|
24
|
+
render: template,
|
|
23
25
|
args: {
|
|
24
26
|
paywallComponents: defaultScreen,
|
|
25
27
|
selectedLocale: defaultScreen.default_locale,
|
|
@@ -28,4 +30,41 @@
|
|
|
28
30
|
});
|
|
29
31
|
</script>
|
|
30
32
|
|
|
33
|
+
{#snippet template(props: ComponentProps<typeof Screen>)}
|
|
34
|
+
<div class="viewport-frame">
|
|
35
|
+
<div class="content-wrapper">
|
|
36
|
+
<Screen {...props} />
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
{/snippet}
|
|
40
|
+
|
|
31
41
|
<Story name="Default" />
|
|
42
|
+
|
|
43
|
+
<style>
|
|
44
|
+
.viewport-frame {
|
|
45
|
+
position: fixed;
|
|
46
|
+
inset: 0;
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
background-color: var(--rc-purchases-ui-bg-color, Canvas);
|
|
50
|
+
color-scheme: light dark;
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.content-wrapper {
|
|
55
|
+
width: 100%;
|
|
56
|
+
display: flex;
|
|
57
|
+
flex: 1 1 auto;
|
|
58
|
+
flex-direction: column;
|
|
59
|
+
min-height: 0;
|
|
60
|
+
overflow: hidden;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
:global(#screen-container) {
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
flex: 1 1 auto;
|
|
67
|
+
min-height: 0;
|
|
68
|
+
overflow: hidden;
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import type { ColorMode } from "../types";
|
|
2
2
|
import type { Background } from "../types/background";
|
|
3
3
|
import type { ColorGradientScheme } from "../types/colors";
|
|
4
|
+
import type { PaywallData } from "../types/paywall";
|
|
5
|
+
export type PaywallRootBackgroundModel = {
|
|
6
|
+
kind: "none";
|
|
7
|
+
} | {
|
|
8
|
+
kind: "style";
|
|
9
|
+
style: Record<string, string>;
|
|
10
|
+
overlay: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "image";
|
|
13
|
+
src: string;
|
|
14
|
+
fit: string;
|
|
15
|
+
position: string;
|
|
16
|
+
overlay: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function paywallRootBackgroundModel(paywallData: PaywallData | null | undefined, colorMode: ColorMode): PaywallRootBackgroundModel;
|
|
4
19
|
export declare function mapBackground(colorMode: ColorMode, background_color: ColorGradientScheme | null | undefined, background: Background | null | undefined): Record<string, string>;
|
|
@@ -23,6 +23,27 @@ function mapBackgroundValue(colorMode, background_color, background) {
|
|
|
23
23
|
}
|
|
24
24
|
return mapBackgroundVideo(colorMode, background.value);
|
|
25
25
|
}
|
|
26
|
+
export function paywallRootBackgroundModel(paywallData, colorMode) {
|
|
27
|
+
const background = paywallData?.components_config?.base?.background;
|
|
28
|
+
if (background == null) {
|
|
29
|
+
return { kind: "none" };
|
|
30
|
+
}
|
|
31
|
+
if (background.type !== "image") {
|
|
32
|
+
const styles = mapBackground(colorMode, null, background);
|
|
33
|
+
const overlay = styles["--overlay"] ?? "none";
|
|
34
|
+
const style = { ...styles };
|
|
35
|
+
delete style["--overlay"];
|
|
36
|
+
return { kind: "style", style, overlay };
|
|
37
|
+
}
|
|
38
|
+
const files = mapColorMode(colorMode, background.value);
|
|
39
|
+
return {
|
|
40
|
+
kind: "image",
|
|
41
|
+
src: files.webp,
|
|
42
|
+
fit: mapFitMode(background.fit_mode),
|
|
43
|
+
position: background.fit_mode === "fill" ? "center" : "top center",
|
|
44
|
+
overlay: mapOverlay(colorMode, background.color_overlay),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
26
47
|
export function mapBackground(colorMode, background_color, background) {
|
|
27
48
|
const value = mapBackgroundValue(colorMode, background_color, background);
|
|
28
49
|
if (background?.type !== "image") {
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { PaywallData } from "../types/paywall";
|
|
2
|
+
import type { PaywallRootBackgroundModel } from "./background-utils";
|
|
3
|
+
export declare function applyDocumentBackground(instanceId: symbol, model: PaywallRootBackgroundModel, paywallData: PaywallData | null | undefined): () => void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Last writer wins, but cleanup only fires for the last writer — otherwise an
|
|
2
|
+
// unmounting paywall would clear a variable set by another paywall that overlapped
|
|
3
|
+
// it during a transition.
|
|
4
|
+
let lastBgWriter = null;
|
|
5
|
+
// iOS promotes sticky header/footer to its own compositor layer, which breaks
|
|
6
|
+
// safe-area painting for the opposite strip. A vertical gradient's edge stop
|
|
7
|
+
// can stand in as a solid fallback for that strip; any other angle would smear
|
|
8
|
+
// a horizontal colour range across the strip that no single colour represents.
|
|
9
|
+
function gradientSafeAreaFallbackColour(gradient, hasHeader, hasFooter) {
|
|
10
|
+
if (!hasHeader && !hasFooter)
|
|
11
|
+
return null;
|
|
12
|
+
if (hasHeader && hasFooter)
|
|
13
|
+
return null;
|
|
14
|
+
const angleMatch = gradient.match(/linear-gradient\((\d+)deg/);
|
|
15
|
+
if (!angleMatch)
|
|
16
|
+
return null;
|
|
17
|
+
if (parseInt(angleMatch[1], 10) % 180 !== 0)
|
|
18
|
+
return null;
|
|
19
|
+
const stops = gradient.match(/#[0-9a-fA-F]{6,8}/g);
|
|
20
|
+
if (!stops || stops.length < 2)
|
|
21
|
+
return null;
|
|
22
|
+
const candidate = hasHeader ? stops[stops.length - 1] : stops[0];
|
|
23
|
+
if (candidate.length === 9 && candidate.slice(-2).toLowerCase() !== "ff") {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
// Published as a CSS variable so consumer surfaces can opt in to safe-area
|
|
29
|
+
// painting (`background: var(--rc-purchases-ui-bg-color, Canvas)` on any
|
|
30
|
+
// element that extends into the safe-area canvas — typically html, body, or
|
|
31
|
+
// a position:fixed root). Writing to documentElement.backgroundColor directly
|
|
32
|
+
// would bleed the paywall colour onto host page chrome that shouldn't take
|
|
33
|
+
// it on, so we expose the value rather than apply it.
|
|
34
|
+
export function applyDocumentBackground(instanceId, model, paywallData) {
|
|
35
|
+
if (typeof document === "undefined")
|
|
36
|
+
return () => { };
|
|
37
|
+
const root = document.documentElement;
|
|
38
|
+
let value = null;
|
|
39
|
+
if (model.kind === "style" && model.style.background) {
|
|
40
|
+
const bg = model.style.background;
|
|
41
|
+
if (!bg.includes("gradient")) {
|
|
42
|
+
value = bg;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const base = paywallData?.components_config?.base;
|
|
46
|
+
value = gradientSafeAreaFallbackColour(bg, !!base?.header, !!base?.sticky_footer);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (value !== null) {
|
|
50
|
+
lastBgWriter = instanceId;
|
|
51
|
+
root.style.setProperty("--rc-purchases-ui-bg-color", value);
|
|
52
|
+
}
|
|
53
|
+
return () => {
|
|
54
|
+
if (lastBgWriter === instanceId) {
|
|
55
|
+
lastBgWriter = null;
|
|
56
|
+
root.style.removeProperty("--rc-purchases-ui-bg-color");
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@revenuecat/purchases-ui-js",
|
|
3
3
|
"description": "Web components for Paywalls. Powered by RevenueCat",
|
|
4
4
|
"private": false,
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "4.1.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "RevenueCat, Inc."
|
|
8
8
|
},
|
|
@@ -93,6 +93,7 @@
|
|
|
93
93
|
"@testing-library/svelte": "^5.3.1",
|
|
94
94
|
"@types/node": "24.9.2",
|
|
95
95
|
"@types/qrcode": "^1.5.6",
|
|
96
|
+
"@types/react": "^19.2.14",
|
|
96
97
|
"@typescript-eslint/parser": "8.57.2",
|
|
97
98
|
"chromatic": "13.3.2",
|
|
98
99
|
"eslint": "9.38.0",
|