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 |