@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.
Files changed (44) hide show
  1. package/package.json +9 -3
  2. package/src/client/_tests/tenantHeaders.spec.ts +77 -0
  3. package/src/client/createForgeClient.ts +37 -0
  4. package/src/contexts/CartContext.tsx +17 -1
  5. package/src/contexts/_tests/CartContext.spec.tsx +36 -0
  6. package/src/data/queries/useFilmPlaybackSession.ts +224 -0
  7. package/src/data/queries/useFilms.ts +561 -0
  8. package/src/data/queries/useMyBookings.ts +9 -0
  9. package/src/i18n/_tests/translationKeys.spec.ts +15 -0
  10. package/src/i18n/de.json +140 -2
  11. package/src/i18n/en.json +140 -2
  12. package/src/index.ts +5 -0
  13. package/src/provider/ForgeAppProvider.tsx +10 -0
  14. package/src/provider/ForgeProvider.tsx +18 -3
  15. package/src/server/index.ts +76 -17
  16. package/src/ui/headless/film/FilmWatermark.tsx +180 -0
  17. package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
  18. package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
  19. package/src/ui/headless/film/index.ts +36 -0
  20. package/src/ui/headless/film/useFilmCatalog.ts +66 -0
  21. package/src/ui/headless/film/useFilmPlayback.ts +497 -0
  22. package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
  23. package/src/ui/headless/film/useStageFullscreen.ts +156 -0
  24. package/src/ui/headless/index.ts +6 -9
  25. package/src/ui/index.ts +43 -16
  26. package/src/ui/media/BookingCallScreen.tsx +33 -0
  27. package/src/ui/media/CallStage.tsx +212 -63
  28. package/src/ui/media/CallWindowNotice.tsx +94 -0
  29. package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
  30. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  31. package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
  32. package/src/ui/media/_tests/callState.spec.ts +63 -16
  33. package/src/ui/media/bookingSession.tsx +45 -57
  34. package/src/ui/media/bookingWindow.ts +81 -0
  35. package/src/ui/media/callState.ts +42 -23
  36. package/src/ui/media/index.ts +9 -0
  37. package/src/ui/styled/AccountDashboard.tsx +97 -5
  38. package/src/ui/styled/FilmCatalog.tsx +278 -0
  39. package/src/ui/styled/FilmDetail.tsx +661 -0
  40. package/src/ui/styled/FilmLibrary.tsx +254 -0
  41. package/src/ui/styled/FilmWatch.tsx +701 -0
  42. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +194 -0
  43. package/src/ui/styled/forge-utilities.css +835 -0
  44. 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;