PluginProbe ʕ •ᴥ•ʔ
Presto Player / trunk
Presto Player vtrunk
4.3.1 4.3.0 4.2.4 4.2.3 4.2.2 4.2.0 4.2.1 trunk 1.10.0 1.10.1 1.10.2 1.11.0 1.12.0 1.13.0 1.14.0 1.14.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.14 1.5.15 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.10 1.6.11 1.6.12 1.6.13 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15 2.0.16 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.2.0 2.2.1 2.2.2 2.2.3 2.2.3-beta1 2.3.0 2.3.1 2.3.2 2.3.3 3.0.0 3.0.0-beta1 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.1.0 3.1.1 3.1.2 3.1.3 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4
presto-player / src / admin / dashboard / pages / AnalyticsDetail.js
presto-player / src / admin / dashboard / pages Last commit date
learn 1 month ago settings 1 month ago Analytics.js 1 month ago AnalyticsDetail.js 1 month ago Dashboard.js 1 month ago Emails.js 1 month ago Learn.js 1 month ago MediaHub.js 3 weeks ago Onboarding.js 3 weeks ago Settings.js 1 month ago UserAnalyticsDetail.js 1 month ago
AnalyticsDetail.js
514 lines
1 const { __ } = wp.i18n;
2 import { useState, useEffect, useRef, useMemo } from "@wordpress/element";
3 import apiFetch from "@wordpress/api-fetch";
4 import {
5 Container,
6 Button,
7 Text,
8 Input,
9 DatePicker,
10 Tooltip as ForceTooltip,
11 } from "@bsf/force-ui";
12 import {
13 ArrowLeft,
14 Calendar,
15 X,
16 Info,
17 PencilLine,
18 Check,
19 } from "lucide-react";
20 import {
21 ResponsiveContainer,
22 AreaChart,
23 Area,
24 CartesianGrid,
25 XAxis,
26 YAxis,
27 Tooltip,
28 } from "recharts";
29 import { useHistory, useLocation } from "../router/router";
30 import useMediaDetail from "../hooks/useMediaDetail";
31 import useDateRangePicker from "../hooks/useDateRangePicker";
32 import {
33 getRangeLabel,
34 getLastNDays,
35 getAnalyticsPresets,
36 formatHMS,
37 DEFAULT_ANALYTICS_DAYS,
38 } from "../utils";
39 import Player from "../../blocks/shared/Player";
40 import { getProvider } from "../../blocks/util";
41 import AnalyticsDetailSkeleton from "../components/Skeletons/AnalyticsDetailSkeleton";
42 import StatCard from "../components/StatCard";
43 import ChartEmptyState from "../components/ChartEmptyState";
44
45 import {
46 CHART_COLOR,
47 GRID_COLOR,
48 AXIS_LABEL_COLOR,
49 STROKE_WIDTH,
50 RETENTION_GRADIENT_OPACITY,
51 ANIMATION_DURATION,
52 ANIMATION_EASING,
53 useChartGradientId,
54 ChartActiveDot,
55 ChartCrosshair,
56 RetentionTooltip,
57 } from "../components/charts";
58
59 /**
60 * Audience Retention mini-chart using Recharts AreaChart.
61 * Shows watch-time distribution across the video timeline.
62 */
63 const RetentionChart = ({ data }) => {
64 const gradientId = useChartGradientId("retention");
65
66 if (!data || data.length === 0) {
67 return <ChartEmptyState className="h-full" description={null} proGated />;
68 }
69
70 return (
71 <ResponsiveContainer
72 width="100%"
73 height="100%"
74 minHeight={140}
75 className="[&_.recharts-surface]:cursor-crosshair"
76 >
77 <AreaChart
78 data={data}
79 margin={{ top: 8, right: 0, bottom: 0, left: 0 }}
80 >
81 <defs>
82 <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
83 <stop offset="0%" stopColor={CHART_COLOR} stopOpacity={0.7} />
84 <stop offset="90%" stopColor={CHART_COLOR} stopOpacity={0.3} />
85 <stop offset="100%" stopColor={CHART_COLOR} stopOpacity={0} />
86 </linearGradient>
87 </defs>
88 <CartesianGrid
89 horizontal={true}
90 vertical={false}
91 stroke={GRID_COLOR}
92 strokeWidth={0.5}
93 />
94 <XAxis
95 dataKey="seconds"
96 type="number"
97 domain={["dataMin", "dataMax"]}
98 tickLine={false}
99 axisLine={false}
100 tick={{ fontSize: 10, fill: AXIS_LABEL_COLOR }}
101 tickFormatter={formatHMS}
102 />
103 <YAxis
104 hide
105 domain={[0, "auto"]}
106 allowDecimals={false}
107 />
108 <Tooltip
109 content={<RetentionTooltip />}
110 cursor={<ChartCrosshair />}
111 />
112 <Area
113 type="monotone"
114 dataKey="value"
115 stroke={CHART_COLOR}
116 strokeWidth={STROKE_WIDTH}
117 strokeLinecap="round"
118 fill={`url(#${gradientId})`}
119 dot={false}
120 activeDot={<ChartActiveDot />}
121 isAnimationActive={true}
122 animationDuration={ANIMATION_DURATION}
123 animationEasing={ANIMATION_EASING}
124 />
125 </AreaChart>
126 </ResponsiveContainer>
127 );
128 };
129
130 /**
131 * Renders the Presto Player video embed for a given media item.
132 * Uses the same Player component as the original analytics detail page.
133 */
134 const VideoPlayer = ({ media }) => {
135 if (!media?.src) return null;
136
137 return (
138 <div className="w-full aspect-video rounded-xl overflow-hidden [&_.wp-block-video]:h-full [&_.presto-block-video]:h-full [&_presto-player]:h-full">
139 <Player
140 src={media.src}
141 attributes={{
142 title: media.title,
143 poster: media.poster,
144 }}
145 type={getProvider(media.src)}
146 preset={{
147 ...media.preset,
148 "play-large": true,
149 play: true,
150 progress: true,
151 rewind: true,
152 "fast-forward": true,
153 "current-time": true,
154 volume: true,
155 mute: true,
156 i18n: window.prestoPlayer?.i18n,
157 }}
158 branding={media.branding}
159 preload="metadata"
160 />
161 </div>
162 );
163 };
164
165 /**
166 * Analytics Detail Page
167 *
168 * Reached by clicking "View Details" on a media row in the Top Media table
169 * on the Analytics page. URL pattern: ?tab=Analytics&detail=media&id={mediaId}
170 *
171 * Layout:
172 * - Header: back button, video title, date range picker
173 * - Left column: Presto Player video embed (16:9)
174 * - Right column: 3 stat cards (Unique views, Avg watch time, Audience retention)
175 */
176 const AnalyticsDetail = () => {
177 const history = useHistory();
178 const location = useLocation();
179 const mediaId = location.params?.id;
180
181 const { media, stats, isLoading, error, refetch } =
182 useMediaDetail(mediaId);
183
184 const [videoReady, setVideoReady] = useState(false);
185 const [isEditingTitle, setIsEditingTitle] = useState(false);
186 const [editedTitle, setEditedTitle] = useState("");
187 const [savingTitle, setSavingTitle] = useState(false);
188 // Optimistic title used after a successful rename so the new value shows
189 // immediately without paying for a full media+stats refetch.
190 const [titleOverride, setTitleOverride] = useState(null);
191
192 const displayedTitle =
193 titleOverride ?? media?.title ?? __("Media Detail", "presto-player");
194
195 const beginTitleEdit = () => {
196 setEditedTitle(displayedTitle);
197 setIsEditingTitle(true);
198 };
199
200 const cancelTitleEdit = () => {
201 setIsEditingTitle(false);
202 setEditedTitle("");
203 };
204
205 const saveTitleEdit = async () => {
206 const next = editedTitle.trim();
207 if (!next || next === displayedTitle) {
208 cancelTitleEdit();
209 return;
210 }
211 setSavingTitle(true);
212 try {
213 await apiFetch({
214 path: `/presto-player/v1/videos/${mediaId}`,
215 method: "PUT",
216 data: { ...media, title: next },
217 });
218 setTitleOverride(next);
219 setIsEditingTitle(false);
220 } catch (e) {
221 // Stay in edit mode so the user can retry; failure is rare and
222 // visible (the input keeps the typed value).
223 } finally {
224 setSavingTitle(false);
225 }
226 };
227
228 useEffect(() => {
229 setVideoReady(false);
230 if (isLoading || !media?.src) return;
231
232 const markReady = () => setVideoReady(true);
233 document.addEventListener("loadedmetadata", markReady, true);
234 document.addEventListener("canplay", markReady, true);
235 const fallback = window.setTimeout(markReady, 1500);
236
237 return () => {
238 document.removeEventListener("loadedmetadata", markReady, true);
239 document.removeEventListener("canplay", markReady, true);
240 window.clearTimeout(fallback);
241 };
242 }, [isLoading, media?.src]);
243
244 const {
245 selectedDates,
246 isDatePickerOpen,
247 setIsDatePickerOpen,
248 datePickerRef,
249 handleDateApply,
250 handleDateCancel,
251 handleClearFilters,
252 } = useDateRangePicker(refetch);
253
254 const presets = useMemo(() => getAnalyticsPresets(), []);
255 const defaultDateRange = useMemo(
256 () => getLastNDays(DEFAULT_ANALYTICS_DAYS),
257 []
258 );
259
260 const retentionData = stats.retentionData.map((item) => ({
261 seconds: parseInt(item.watch_time, 10) || 0,
262 value: parseInt(item.total, 10) || 0,
263 }));
264
265 const fromUserId = location.params?.from_user;
266 const fromTab = location.params?.from;
267 const handleBack = () => {
268 if (fromUserId) {
269 history.replace({ tab: "Analytics", detail: "user", id: fromUserId, from_user: null });
270 } else if (fromTab) {
271 history.replace({ tab: fromTab, detail: null, id: null, from: null });
272 } else {
273 history.replace({ tab: "Analytics", detail: null, id: null });
274 }
275 };
276
277 // Loading state — show skeleton until data is fetched.
278 // Once data arrives we mount the real layout (so the player can start
279 // loading) and overlay the skeleton until the video is ready to play.
280 if (isLoading) {
281 return <AnalyticsDetailSkeleton />;
282 }
283
284 const showVideoOverlay = Boolean(media?.src) && !videoReady;
285
286 // Error state
287 if (error) {
288 return (
289 <Container
290 className="p-8 bg-background-secondary"
291 direction="column"
292 gap="md"
293 >
294 <Button
295 variant="ghost"
296 size="xs"
297 icon={<ArrowLeft className="size-5" />}
298 onClick={handleBack}
299 />
300 <Container
301 align="center"
302 justify="center"
303 className="p-8 bg-background-primary border border-solid border-border-subtle rounded-lg"
304 >
305 <Text size={14} weight={400} color="secondary">
306 {error}
307 </Text>
308 </Container>
309 </Container>
310 );
311 }
312
313 return (
314 <div className="relative">
315 {showVideoOverlay && (
316 <div className="absolute inset-0 z-10">
317 <AnalyticsDetailSkeleton />
318 </div>
319 )}
320 <Container
321 className="p-8 bg-background-secondary"
322 direction="column"
323 gap="2xl"
324 >
325 <Container align="center" gap="sm">
326 <Button
327 variant="ghost"
328 size="xs"
329 icon={<ArrowLeft className="size-5" />}
330 onClick={handleBack}
331 className="shrink-0"
332 />
333
334 <Container align="center" gap="sm" className="flex-1 min-w-0">
335 {isEditingTitle ? (
336 <>
337 <Input
338 type="text"
339 size="sm"
340 value={editedTitle}
341 onChange={(value) =>
342 setEditedTitle(typeof value === "string" ? value : value?.target?.value || "")
343 }
344 onKeyDown={(e) => {
345 if (e.key === "Enter") {
346 e.preventDefault();
347 saveTitleEdit();
348 } else if (e.key === "Escape") {
349 e.preventDefault();
350 cancelTitleEdit();
351 }
352 }}
353 disabled={savingTitle}
354 aria-label={__("Edit title", "presto-player")}
355 className="flex-1 min-w-0"
356 />
357 <Button
358 variant="primary"
359 size="xs"
360 icon={<Check className="size-4" />}
361 onClick={saveTitleEdit}
362 disabled={savingTitle}
363 aria-label={__("Save title", "presto-player")}
364 />
365 <Button
366 variant="ghost"
367 size="xs"
368 icon={<X className="size-4" />}
369 onClick={cancelTitleEdit}
370 disabled={savingTitle}
371 aria-label={__("Cancel", "presto-player")}
372 />
373 </>
374 ) : (
375 <>
376 <Text
377 size={24}
378 weight={600}
379 color="primary"
380 className="truncate tracking-[-0.14px]"
381 >
382 {displayedTitle}
383 </Text>
384 {media?.id && (
385 <Button
386 variant="ghost"
387 size="xs"
388 icon={<PencilLine className="size-4" />}
389 onClick={beginTitleEdit}
390 aria-label={__("Rename media", "presto-player")}
391 />
392 )}
393 </>
394 )}
395 </Container>
396
397 {/* Date Range Picker */}
398 <Container align="center" gap="xs" className="shrink-0">
399 {(selectedDates.from || selectedDates.to) && (
400 <Button
401 variant="link"
402 size="xs"
403 icon={<X />}
404 onClick={handleClearFilters}
405 className="flex items-center gap-1 text-button-danger hover:text-button-danger transition-colors"
406 aria-label={__("Clear Filters", "presto-player")}
407 >
408 {__("Clear Filters", "presto-player")}
409 </Button>
410 )}
411 <div className="relative" ref={datePickerRef}>
412 <Input
413 type="text"
414 size="sm"
415 value={getRangeLabel(
416 selectedDates?.from && selectedDates?.to
417 ? selectedDates
418 : defaultDateRange
419 )}
420 suffix={<Calendar className="text-icon-secondary" />}
421 onClick={() => setIsDatePickerOpen((prev) => !prev)}
422 className="w-60 cursor-pointer [&>input]:min-h-8 rounded-sm border border-solid border-field-border bg-field-secondary-background"
423 readOnly
424 aria-label={__("Select Date Range", "presto-player")}
425 />
426 {isDatePickerOpen && (
427 <Container className="absolute z-10 mt-2 rounded-lg shadow-lg right-0 bg-background-primary">
428 <DatePicker
429 applyButtonText={__("Apply", "presto-player")}
430 cancelButtonText={__("Cancel", "presto-player")}
431 selectionType="range"
432 showOutsideDays={false}
433 variant="presets"
434 presets={presets}
435 onApply={handleDateApply}
436 onCancel={handleDateCancel}
437 selected={
438 selectedDates?.from && selectedDates?.to
439 ? selectedDates
440 : defaultDateRange
441 }
442 disabled={{ after: new Date() }}
443 />
444 </Container>
445 )}
446 </div>
447 </Container>
448 </Container>
449
450 {/* Main Content: Video + Stats */}
451 <Container gap="lg">
452 {/* Left: Video Player */}
453 <Container className="flex-1 min-w-0">
454 <VideoPlayer media={media} />
455 </Container>
456
457 {/* Right: Stat Cards */}
458 <Container direction="column" gap="lg" className="w-[500px] shrink-0">
459 <StatCard
460 label={
461 <span className="inline-flex items-center gap-1.5">
462 {__("Unique views", "presto-player")}
463 <ForceTooltip
464 content={__("Each viewer is counted once, even if they watch the video multiple times.", "presto-player")}
465 arrow
466 placement="right"
467 >
468 <Info className="size-3.5 text-icon-secondary cursor-help shrink-0" />
469 </ForceTooltip>
470 </span>
471 }
472 value={String(stats.uniqueViews)}
473 />
474 <StatCard
475 label={
476 <span className="inline-flex items-center gap-1.5">
477 {__("Average watch time", "presto-player")}
478 <ForceTooltip
479 content={__("Mean duration watched per view across all viewers in the selected date range.", "presto-player")}
480 arrow
481 placement="right"
482 >
483 <Info className="size-3.5 text-icon-secondary cursor-help shrink-0" />
484 </ForceTooltip>
485 </span>
486 }
487 value={stats.avgWatchTime}
488 />
489 <StatCard
490 className="flex-1 min-h-0"
491 label={
492 <span className="inline-flex items-center gap-1.5">
493 {__("Audience Retention", "presto-player")}
494 <ForceTooltip
495 content={__("Shows how many viewers watched each moment of the video. Drop-offs indicate where people stopped watching.", "presto-player")}
496 arrow
497 placement="right"
498 >
499 <Info className="size-3.5 text-icon-secondary cursor-help shrink-0" />
500 </ForceTooltip>
501 </span>
502 }
503 >
504 <RetentionChart data={retentionData} />
505 </StatCard>
506 </Container>
507 </Container>
508 </Container>
509 </div>
510 );
511 };
512
513 export default AnalyticsDetail;
514