@tribe-nest/forge 3.31.0 → 3.35.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/package.json +9 -3
- package/src/client/_tests/tenantHeaders.spec.ts +77 -0
- package/src/client/createForgeClient.ts +37 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/data/queries/useMyBookings.ts +9 -0
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +140 -2
- package/src/i18n/en.json +140 -2
- package/src/index.ts +5 -0
- package/src/provider/ForgeAppProvider.tsx +10 -0
- package/src/provider/ForgeProvider.tsx +18 -3
- package/src/server/index.ts +76 -17
- package/src/ui/headless/film/FilmWatermark.tsx +180 -0
- package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
- package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
- package/src/ui/headless/film/index.ts +36 -0
- package/src/ui/headless/film/useFilmCatalog.ts +66 -0
- package/src/ui/headless/film/useFilmPlayback.ts +497 -0
- package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
- package/src/ui/headless/film/useStageFullscreen.ts +156 -0
- package/src/ui/headless/index.ts +6 -9
- package/src/ui/index.ts +43 -16
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallStage.tsx +212 -63
- package/src/ui/media/CallWindowNotice.tsx +94 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
- package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
- package/src/ui/media/_tests/callState.spec.ts +63 -16
- package/src/ui/media/bookingSession.tsx +45 -57
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +42 -23
- package/src/ui/media/index.ts +9 -0
- package/src/ui/styled/AccountDashboard.tsx +97 -5
- package/src/ui/styled/FilmCatalog.tsx +278 -0
- package/src/ui/styled/FilmDetail.tsx +661 -0
- package/src/ui/styled/FilmLibrary.tsx +254 -0
- package/src/ui/styled/FilmWatch.tsx +701 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +194 -0
- package/src/ui/styled/forge-utilities.css +835 -0
- package/src/utils/structuredData.ts +88 -17
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
2
|
+
import type { FilmWatermarkPayload } from "../../../data/queries/useFilmPlaybackSession";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The dynamic viewer overlay: this renter's name, email and rental reference,
|
|
6
|
+
* drawn over the player for a few seconds at a time in a moving position.
|
|
7
|
+
*
|
|
8
|
+
* ## What this layer does, and what it does not
|
|
9
|
+
*
|
|
10
|
+
* It deters the screen-record-and-share leak by NAMING the leaker. It stops
|
|
11
|
+
* nobody from recording, and it is the one protection layer a custom player can
|
|
12
|
+
* simply omit - which is why it ships as a droppable component rather than as
|
|
13
|
+
* something welded into a styled block. Keeping the layer costs one component;
|
|
14
|
+
* dropping it costs per-viewer traceability, and a leaked recording is then
|
|
15
|
+
* still traceable to the film (the platform watermark is burned into the pixels
|
|
16
|
+
* by the transcoder) but no longer to the person.
|
|
17
|
+
*
|
|
18
|
+
* ## Why it moves
|
|
19
|
+
*
|
|
20
|
+
* Constant placement invites cropping: a fixed corner is one rectangle to cut
|
|
21
|
+
* off, and a recording cropped once is clean for ever. Randomised position and
|
|
22
|
+
* timing contaminate the WHOLE recording instead, so there is no single edit
|
|
23
|
+
* that removes it.
|
|
24
|
+
*
|
|
25
|
+
* ## Why it watches itself
|
|
26
|
+
*
|
|
27
|
+
* A viewer with dev tools open can delete the node or set `display: none` on it.
|
|
28
|
+
* The overlay observes its own subtree and re-asserts the style it was given, so
|
|
29
|
+
* removing it takes more than one click. This is a speed bump, not a control: it
|
|
30
|
+
* is a client-side layer and everything client-side is ultimately editable. The
|
|
31
|
+
* layers that actually hold are on the server.
|
|
32
|
+
*
|
|
33
|
+
* Mount it INSIDE the element that has the video, positioned, so it shares the
|
|
34
|
+
* player surface. A sibling div is trivially hidden and, worse, is not captured
|
|
35
|
+
* by a screen recording of the video element alone.
|
|
36
|
+
*/
|
|
37
|
+
export interface FilmWatermarkProps extends Partial<FilmWatermarkPayload> {
|
|
38
|
+
/** How often a new placement appears, in ms. Default 45s, jittered. */
|
|
39
|
+
intervalMs?: number;
|
|
40
|
+
/** How long each placement stays up, in ms. Default 6s. */
|
|
41
|
+
visibleMs?: number;
|
|
42
|
+
/** 0..1. Default 0.42 - readable in a recording, unobtrusive while watching. */
|
|
43
|
+
opacity?: number;
|
|
44
|
+
className?: string;
|
|
45
|
+
/** Merged over the computed placement. Does not survive the tamper check. */
|
|
46
|
+
style?: CSSProperties;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The nine placements, as percentages of the player surface. Corners are avoided: they crop first. */
|
|
50
|
+
const PLACEMENTS: { top: string; left: string }[] = [
|
|
51
|
+
{ top: "12%", left: "8%" },
|
|
52
|
+
{ top: "12%", left: "52%" },
|
|
53
|
+
{ top: "38%", left: "18%" },
|
|
54
|
+
{ top: "38%", left: "58%" },
|
|
55
|
+
{ top: "62%", left: "10%" },
|
|
56
|
+
{ top: "62%", left: "48%" },
|
|
57
|
+
{ top: "80%", left: "22%" },
|
|
58
|
+
{ top: "80%", left: "60%" },
|
|
59
|
+
{ top: "50%", left: "34%" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const pick = <T,>(items: T[]): T => items[Math.floor(Math.random() * items.length)] ?? items[0]!;
|
|
63
|
+
|
|
64
|
+
export function FilmWatermark({
|
|
65
|
+
name,
|
|
66
|
+
email,
|
|
67
|
+
rentalRef,
|
|
68
|
+
intervalMs = 45_000,
|
|
69
|
+
visibleMs = 6_000,
|
|
70
|
+
opacity = 0.42,
|
|
71
|
+
className,
|
|
72
|
+
style,
|
|
73
|
+
}: FilmWatermarkProps) {
|
|
74
|
+
const [visible, setVisible] = useState(false);
|
|
75
|
+
const [placement, setPlacement] = useState(() => PLACEMENTS[0]!);
|
|
76
|
+
/** Bumped by the tamper check to force React to re-apply the style it owns. */
|
|
77
|
+
const [assertion, setAssertion] = useState(0);
|
|
78
|
+
const nodeRef = useRef<HTMLDivElement | null>(null);
|
|
79
|
+
|
|
80
|
+
// Identify with whatever we were given. An empty overlay is worse than none:
|
|
81
|
+
// it draws attention to the mechanism while carrying no evidence.
|
|
82
|
+
const label = useMemo(
|
|
83
|
+
() =>
|
|
84
|
+
[name, email, rentalRef]
|
|
85
|
+
.map((part) => (part ?? "").trim())
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join(" · "),
|
|
88
|
+
[name, email, rentalRef],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// The show/hide cycle. Jittered so the interval itself is not a pattern
|
|
92
|
+
// somebody can cut around.
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!label) return;
|
|
95
|
+
let showTimer: ReturnType<typeof setTimeout>;
|
|
96
|
+
let hideTimer: ReturnType<typeof setTimeout>;
|
|
97
|
+
|
|
98
|
+
const cycle = () => {
|
|
99
|
+
setPlacement(pick(PLACEMENTS));
|
|
100
|
+
setVisible(true);
|
|
101
|
+
hideTimer = setTimeout(() => {
|
|
102
|
+
setVisible(false);
|
|
103
|
+
// 0.6x to 1.4x of the nominal interval.
|
|
104
|
+
showTimer = setTimeout(cycle, intervalMs * (0.6 + Math.random() * 0.8));
|
|
105
|
+
}, visibleMs);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// First appearance lands early, so a viewer who stops recording after 30
|
|
109
|
+
// seconds still has it, then settles into the cycle.
|
|
110
|
+
showTimer = setTimeout(cycle, 3_000 + Math.random() * 4_000);
|
|
111
|
+
return () => {
|
|
112
|
+
clearTimeout(showTimer);
|
|
113
|
+
clearTimeout(hideTimer);
|
|
114
|
+
};
|
|
115
|
+
}, [label, intervalMs, visibleMs]);
|
|
116
|
+
|
|
117
|
+
// The tamper check. Watches the node's own attributes and its parent's child
|
|
118
|
+
// list, and re-asserts by bumping state - React then rewrites the style it
|
|
119
|
+
// owns, whatever was done to it.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!label || typeof MutationObserver === "undefined") return;
|
|
122
|
+
const node = nodeRef.current;
|
|
123
|
+
const parent = node?.parentElement;
|
|
124
|
+
if (!node || !parent) return;
|
|
125
|
+
|
|
126
|
+
const observer = new MutationObserver((records) => {
|
|
127
|
+
const touched = records.some(
|
|
128
|
+
(record) =>
|
|
129
|
+
(record.type === "attributes" && record.target === node) ||
|
|
130
|
+
(record.type === "childList" && [...record.removedNodes].includes(node)),
|
|
131
|
+
);
|
|
132
|
+
if (touched) setAssertion((n) => n + 1);
|
|
133
|
+
});
|
|
134
|
+
observer.observe(parent, { childList: true });
|
|
135
|
+
observer.observe(node, { attributes: true, attributeFilter: ["style", "class", "hidden"] });
|
|
136
|
+
return () => observer.disconnect();
|
|
137
|
+
}, [label, assertion]);
|
|
138
|
+
|
|
139
|
+
if (!label) return null;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
ref={nodeRef}
|
|
144
|
+
key={assertion}
|
|
145
|
+
aria-hidden="true"
|
|
146
|
+
data-testid="film-watermark"
|
|
147
|
+
style={{
|
|
148
|
+
position: "absolute",
|
|
149
|
+
top: placement.top,
|
|
150
|
+
left: placement.left,
|
|
151
|
+
maxWidth: "70%",
|
|
152
|
+
// Never intercepts a click: the play/pause surface underneath has to
|
|
153
|
+
// keep working, and a viewer fighting an invisible box is a support
|
|
154
|
+
// ticket that reads as "the player is broken".
|
|
155
|
+
pointerEvents: "none",
|
|
156
|
+
userSelect: "none",
|
|
157
|
+
zIndex: 20,
|
|
158
|
+
opacity: visible ? opacity : 0,
|
|
159
|
+
transition: "opacity 900ms ease-in-out",
|
|
160
|
+
// Deliberate literals: this sits over video frames of unknown colour, so
|
|
161
|
+
// it cannot be a theme token. White text with a dark shadow is the only
|
|
162
|
+
// pair that stays readable over both a night scene and a snowfield.
|
|
163
|
+
color: "#ffffff",
|
|
164
|
+
textShadow: "0 1px 3px rgba(0,0,0,0.85)",
|
|
165
|
+
fontSize: "clamp(10px, 1.4vw, 14px)",
|
|
166
|
+
letterSpacing: "0.02em",
|
|
167
|
+
lineHeight: 1.4,
|
|
168
|
+
whiteSpace: "nowrap",
|
|
169
|
+
overflow: "hidden",
|
|
170
|
+
textOverflow: "ellipsis",
|
|
171
|
+
...style,
|
|
172
|
+
}}
|
|
173
|
+
className={className}
|
|
174
|
+
>
|
|
175
|
+
{label}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export default FilmWatermark;
|